You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/05/27 15:59:08 UTC
(tooling-trusted-releases) branch arm updated (f845f685 -> 00e8c7d9)
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a change to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
discard f845f685 #1280 - Add email template for finish vote. Includes ATR Tally as a variable
add 996394bb Allow optional publishing to SVN during vote resolution, in a task
add 32c80f80 Clarify Maven groupId guidance
add 5137317b Add license header to Maven guidance tests
new 00e8c7d9 #1280 - Add email template for finish vote. Includes ATR Tally as a variable
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (f845f685)
\
N -- N -- N refs/heads/arm (00e8c7d9)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
atr/constants.py | 4 +
atr/db/interaction.py | 10 +-
atr/get/voting.py | 56 +++++
atr/models/args.py | 13 +-
atr/models/results.py | 1 -
atr/paths.py | 10 +-
atr/post/keys.py | 7 +-
atr/post/voting.py | 43 ++++
atr/shared/distribution.py | 13 +-
atr/shared/voting.py | 10 +
atr/storage/writers/announce.py | 104 +++++---
atr/storage/writers/distributions.py | 14 +-
atr/storage/writers/release.py | 140 +++++++++++
atr/storage/writers/vote.py | 117 ++++++++-
atr/tasks/__init__.py | 3 +-
atr/tasks/svn.py | 7 +-
atr/tasks/{keys.py => svnpub.py} | 13 +-
atr/util.py | 126 ++++++++--
tests/unit/test_distribution_maven_guidance.py | 50 ++++
tests/unit/test_svn_publish_announce.py | 64 +++++
tests/unit/test_svn_publish_helpers.py | 102 ++++++--
tests/unit/test_svn_publish_start_vote.py | 127 ++++++++++
tests/unit/test_svn_publish_tasks_db.py | 26 +-
tests/unit/test_svn_publish_writer.py | 319 +++++++++++++++++++++++++
24 files changed, 1250 insertions(+), 129 deletions(-)
copy atr/tasks/{keys.py => svnpub.py} (67%)
create mode 100644 tests/unit/test_distribution_maven_guidance.py
create mode 100644 tests/unit/test_svn_publish_announce.py
create mode 100644 tests/unit/test_svn_publish_start_vote.py
create mode 100644 tests/unit/test_svn_publish_writer.py
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: #1280 - Add email template for finish vote. Includes ATR Tally as a variable
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 00e8c7d93b1b96ec4e17b959d4959d91fe8bc4e7
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Wed May 27 15:48:28 2026 +0100
#1280 - Add email template for finish vote. Includes ATR Tally as a variable
---
atr/api/__init__.py | 1 +
atr/construct.py | 24 ++++++-
atr/get/projects.py | 9 +++
atr/get/resolve.py | 49 +++++++++----
atr/get/vote.py | 10 +--
atr/models/api.py | 2 +
atr/models/sql.py | 22 ++++++
atr/post/resolve.py | 4 +-
atr/shared/projects.py | 4 ++
atr/storage/writers/policy.py | 17 +++++
atr/tabulate.py | 92 +++++--------------------
atr/tasks/vote.py | 23 +++++--
migrations/versions/0088_2026.05.27_449e0006.py | 27 ++++++++
tests/unit/test_vote_resolution.py | 4 ++
14 files changed, 182 insertions(+), 106 deletions(-)
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 92aabb25..05a42187 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -839,6 +839,7 @@ async def policy_get(
policy_source_artifact_paths=project.policy_source_artifact_paths,
policy_start_vote_subject=project.policy_start_vote_subject,
policy_start_vote_template=project.policy_start_vote_template,
+ policy_finish_vote_template=project.policy_finish_vote_template,
policy_vote_comment_template=project.policy_vote_comment_template,
).model_dump(mode="json"), 200
diff --git a/atr/construct.py b/atr/construct.py
index ae4c72a6..9d8f3c52 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -32,7 +32,7 @@ import atr.models.sql as sql
import atr.paths as paths
import atr.util as util
-type Context = Literal["announce", "announce_subject", "checklist", "vote", "vote_subject"]
+type Context = Literal["announce", "announce_subject", "checklist", "finish_vote", "vote", "vote_subject"]
class _AnnounceSubjectValues(TypedDict):
@@ -71,6 +71,16 @@ class _ChecklistValues(TypedDict):
VERSION: str
+class _FinishVoteValues(TypedDict):
+ ATR_TALLY: str
+ COMMITTEE: str
+ OUTCOME: str
+ PROJECT_NAME: str
+ VERSION: str
+ YOUR_ASF_ID: str
+ YOUR_FULL_NAME: str
+
+
class _VoteSubjectValues(TypedDict):
COMMITTEE: str
PROJECT_NAME: str
@@ -100,6 +110,7 @@ class _VoteValues(TypedDict):
TEMPLATE_DESCRIPTIONS: Final[dict[str, str]] = {
+ "ATR_TALLY": "Vote tally block - URL, ballots, counts",
"BUG_DATABASE": "Bug database URL",
"CHECKLIST_URL": "URL to the release checklist",
"COMMITTEE": "Committee name",
@@ -111,6 +122,7 @@ TEMPLATE_DESCRIPTIONS: Final[dict[str, str]] = {
"KEYS_FILE": "URL to the KEYS file",
"LIFECYCLE_PAGE": "Lifecycle page URL",
"MAILING_LISTS": "Mailing lists page URL",
+ "OUTCOME": "Vote outcome - 'passed' or 'failed'",
"PROJECT_NAME": "Project name",
"PROJECT_KEY": "ATR key for the project",
"RELEASE_CHECKLIST": "Release checklist content",
@@ -263,6 +275,14 @@ def checklist_template_variables() -> list[tuple[str, str]]:
return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(_ChecklistValues.__required_keys__)]
+def finish_vote_body(body: str, values: _FinishVoteValues) -> str:
+ return _substitute(body, values, "finish_vote")
+
+
+def finish_vote_template_variables() -> list[tuple[str, str]]:
+ return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(_FinishVoteValues.__required_keys__)]
+
+
async def start_vote_default(project_key: safe.ProjectKey) -> str:
async with db.session() as data:
project = await data.project(
@@ -409,6 +429,8 @@ def _substitute(text: str, values: _AnnounceValues, context: Literal["announce"]
@overload
def _substitute(text: str, values: _ChecklistValues, context: Literal["checklist"]) -> str: ...
@overload
+def _substitute(text: str, values: _FinishVoteValues, context: Literal["finish_vote"]) -> str: ...
+@overload
def _substitute(text: str, values: _VoteSubjectValues, context: Literal["vote_subject"]) -> str: ...
@overload
def _substitute(text: str, values: _VoteValues, context: Literal["vote"]) -> str: ...
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 43168f51..850e12ed 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -1051,6 +1051,7 @@ async def _render_vote_form(project: sql.Project) -> htm.Element:
"vote_comment_template": project.policy_vote_comment_template or "",
"start_vote_subject": project.policy_start_vote_subject or "",
"start_vote_template": project.policy_start_vote_template or "",
+ "finish_vote_template": project.policy_finish_vote_template or "",
}
release_checklist_widget = _textarea_with_variables(
@@ -1075,6 +1076,13 @@ async def _render_vote_form(project: sql.Project) -> htm.Element:
rows=18,
documentation="Email template for messages to start a vote on a release.",
)
+ finish_vote_template_widget = _textarea_with_variables(
+ field_name="finish_vote_template",
+ default_value=project.policy_finish_vote_template or "",
+ template_variables=construct.finish_vote_template_variables(),
+ rows=18,
+ documentation="Email template for vote resolution messages.",
+ )
vote_mode_widget = _vote_mode_radios(project)
with card.block(htm.div, classes=".card-body") as card_body:
@@ -1092,6 +1100,7 @@ async def _render_vote_form(project: sql.Project) -> htm.Element:
"release_checklist": release_checklist_widget,
"start_vote_subject": start_vote_subject_widget,
"start_vote_template": start_vote_template_widget,
+ "finish_vote_template": finish_vote_template_widget,
"vote_mode": vote_mode_widget,
},
)
diff --git a/atr/get/resolve.py b/atr/get/resolve.py
index 5467cfd2..d476481a 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -22,6 +22,7 @@ from typing import Literal
import htpy
import atr.blueprints.get as get
+import atr.construct as construct
import atr.db.interaction as interaction
import atr.form
import atr.htm as htm
@@ -174,33 +175,53 @@ async def selected( # noqa: C901
"vote_mode": release.effective_vote_mode,
"vote_seq": vote_seq,
}
+ if release.podling_thread_id:
+ committee_name = "Incubator"
+ elif release.committee is not None:
+ committee_name = release.committee.display_name
+ elif committee is not None:
+ committee_name = committee.display_name
+ else:
+ committee_name = release.project.key
+
+ # ATR_TALLY is final here - tabulation has been attempted. OUTCOME stays as
+ # the variable since the user picks via the form; POST substitutes it
+ atr_tally = ""
+ outcome = "{{OUTCOME}}"
if trusted_summary is not None:
- defaults["email_body"] = tabulate.trusted_vote_resolution(
- release,
+ atr_tally = tabulate.trusted_tally_block(
trusted_summary,
- trusted_passed,
- full_name,
- asf_uid,
- thread_id,
binding_label,
non_binding_label,
+ thread_id=thread_id,
+ podling_thread_id=release.podling_thread_id,
)
- elif (not is_trusted_mode) and (committee is not None) and (details is not None) and (thread_id is not None):
- defaults["email_body"] = tabulate.vote_resolution(
- committee,
- release,
+ outcome = "passed" if trusted_passed else "failed"
+ elif (not is_trusted_mode) and (details is not None) and (thread_id is not None):
+ atr_tally = tabulate.email_tally_block(
details.votes,
details.summary,
- details.passed,
- details.outcome,
- full_name,
- asf_uid,
thread_id,
binding_label,
non_binding_label,
+ podling_thread_id=release.podling_thread_id,
)
+ outcome = "passed" if details.passed else "failed"
defaults["vote_result"] = "Passed" if details.passed else "Failed"
+ defaults["email_body"] = construct.finish_vote_body(
+ release.project.policy_finish_vote_template,
+ {
+ "ATR_TALLY": atr_tally,
+ "COMMITTEE": committee_name,
+ "OUTCOME": outcome,
+ "PROJECT_NAME": release.project.short_display_name,
+ "VERSION": release.version,
+ "YOUR_ASF_ID": asf_uid,
+ "YOUR_FULL_NAME": full_name,
+ },
+ )
+
if is_trusted_mode:
binding_sufficient = trusted_passed
else:
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 62b41894..8889aad4 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -634,7 +634,7 @@ async def _render_trusted_vote_authenticated(
session.uid,
)
- vote_round = _vote_round(release)
+ vote_round = interaction.trusted_vote_round(release)
is_binding, binding_committee = await user.is_binding_for_release(release.committee, session.uid, vote_round)
binding_word, non_binding_word = user.binding_terminology(vote_round)
potency = binding_word if is_binding else non_binding_word
@@ -696,7 +696,7 @@ async def _render_vote_authenticated(
await _render_trusted_vote_authenticated(page, release, session, archive_url, vote_recipient, latest_vote_task)
return
- vote_round = _vote_round(release)
+ vote_round = interaction.trusted_vote_round(release)
is_binding, binding_committee = await user.is_binding_for_release(release.committee, session.uid, vote_round)
binding_word, non_binding_word = user.binding_terminology(vote_round)
@@ -917,12 +917,6 @@ def _vote_recipient(release: sql.Release, latest_vote_task: sql.Task | None) ->
return f"dev@{release.committee.key}.apache.org"
-def _vote_round(release: sql.Release) -> int | None:
- if (release.committee is not None) and release.committee.is_podling:
- return 2 if (release.podling_thread_id is not None) else 1
- return None
-
-
def _voting_committee_membership(binding_committee: str, vote_round: int | None) -> str:
if binding_committee == "Incubator":
return f"{binding_committee} IPMC member"
diff --git a/atr/models/api.py b/atr/models/api.py
index 9e914506..29d2f532 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -326,6 +326,7 @@ class PolicyGetResults(schema.Strict):
policy_source_artifact_paths: list[str]
policy_start_vote_subject: str
policy_start_vote_template: str
+ policy_finish_vote_template: str
policy_vote_comment_template: str
@@ -354,6 +355,7 @@ class PolicyArgsBase(schema.Strict):
source_excludes_rat: list[str] | None = None
start_vote_subject: str | None = None
start_vote_template: str | None = None
+ finish_vote_template: str | None = None
vote_comment_template: str | None = None
vote_mode: sql.VoteMode | None = None
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 2ad1b1ca..e46d2e93 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -961,6 +961,20 @@ Thanks,
def policy_start_vote_subject_default(self) -> str:
return "[VOTE] Release {{PROJECT_NAME}} {{VERSION}}"
+ @property
+ def policy_finish_vote_default(self) -> str:
+ return """Dear {{COMMITTEE}} participants,
+
+The vote on {{PROJECT_NAME}} {{VERSION}} {{OUTCOME}}.
+
+{{ATR_TALLY}}
+
+Thank you for your participation.
+
+Sincerely,
+{{YOUR_FULL_NAME}} ({{YOUR_ASF_ID}})
+"""
+
@property
def policy_default_min_hours(self) -> int:
return 72
@@ -1028,6 +1042,12 @@ Thanks,
return self.policy_start_vote_default
return policy.start_vote_template
+ @property
+ def policy_finish_vote_template(self) -> str:
+ if ((policy := self.release_policy) is None) or (policy.finish_vote_template == ""):
+ return self.policy_finish_vote_default
+ return policy.finish_vote_template
+
@property
def policy_binary_artifact_paths(self) -> list[str]:
if (policy := self.release_policy) is None:
@@ -1786,6 +1806,7 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
vote_comment_template: str = sqlmodel.Field(default="")
start_vote_subject: str = sqlmodel.Field(default="")
start_vote_template: str = sqlmodel.Field(default="")
+ finish_vote_template: str = sqlmodel.Field(default="")
announce_release_subject: str = sqlmodel.Field(default="")
announce_release_template: str = sqlmodel.Field(default="")
binary_artifact_paths: list[str] = sqlmodel.Field(
@@ -1832,6 +1853,7 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
vote_comment_template=self.vote_comment_template,
start_vote_subject=self.start_vote_subject,
start_vote_template=self.start_vote_template,
+ finish_vote_template=self.finish_vote_template,
announce_release_subject=self.announce_release_subject,
announce_release_template=self.announce_release_template,
binary_artifact_paths=list(self.binary_artifact_paths),
diff --git a/atr/post/resolve.py b/atr/post/resolve.py
index 2cf3130b..9cc87be9 100644
--- a/atr/post/resolve.py
+++ b/atr/post/resolve.py
@@ -38,7 +38,6 @@ async def selected(
"""
URL: /resolve/<project_key>/<version_key>
"""
- email_body = submit_form.email_body
vote_result = submit_form.vote_result
match vote_result:
@@ -49,6 +48,9 @@ async def selected(
case "Cancelled":
writer_result = "cancelled"
+ # GET defers {{OUTCOME}} until now because it depends on the user's selection
+ email_body = submit_form.email_body.replace("{{OUTCOME}}", writer_result)
+
automatic_resolve_when_finished = _read_auto_resolve_flag(submit_form)
notify_when_finished = _read_notify_flag(submit_form)
bcc_private_list = _read_bcc_private_flag(submit_form)
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 37c39839..04f18745 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -202,6 +202,10 @@ class VotePolicyForm(form.Form):
"Start vote template",
widget=form.Widget.CUSTOM,
)
+ finish_vote_template: str = form.label(
+ "Finish vote template",
+ widget=form.Widget.CUSTOM,
+ )
@pydantic.model_validator(mode="after")
def validate_vote_fields(self) -> VotePolicyForm:
diff --git a/atr/storage/writers/policy.py b/atr/storage/writers/policy.py
index 088b1f37..52191996 100644
--- a/atr/storage/writers/policy.py
+++ b/atr/storage/writers/policy.py
@@ -330,6 +330,7 @@ class CommitteeMember(CommitteeParticipant):
release_policy.vote_comment_template = form.vote_comment_template or ""
self.__set_start_vote_subject(form.start_vote_subject or "", project, release_policy)
self.__set_start_vote_template(form.start_vote_template or "", project, release_policy)
+ self.__set_finish_vote_template(form.finish_vote_template or "", project, release_policy)
elif release_policy.vote_mode != models.sql.VoteMode.MANUAL:
raise ValueError(f"Unsupported vote mode: {release_policy.vote_mode}")
@@ -462,6 +463,22 @@ class CommitteeMember(CommitteeParticipant):
else:
release_policy.start_vote_template = submitted_template
+ def __set_finish_vote_template(
+ self,
+ submitted_template: str,
+ project: models.sql.Project,
+ release_policy: models.sql.ReleasePolicy,
+ ) -> None:
+ submitted_template = submitted_template.replace("\r\n", "\n")
+ current_default_text = project.policy_finish_vote_default
+ current_default_hash = hashes.compute_sha3_256(current_default_text.encode())
+ submitted_hash = hashes.compute_sha3_256(submitted_template.encode())
+
+ if submitted_hash == current_default_hash:
+ release_policy.finish_vote_template = ""
+ else:
+ release_policy.finish_vote_template = submitted_template
+
def _normalise_text_list(values: list[str]) -> list[str]:
return [value.strip() for value in values if value.strip()]
diff --git a/atr/tabulate.py b/atr/tabulate.py
index a952e828..410d6fd3 100644
--- a/atr/tabulate.py
+++ b/atr/tabulate.py
@@ -54,21 +54,14 @@ 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,
+def trusted_tally_block(
summary: TrustedVoteSummaryData,
- passed: bool,
- full_name: str,
- asf_uid: str,
- thread_id: str | None,
binding_label: str = "Binding",
non_binding_label: str = "Non-binding",
+ thread_id: str | None = None,
+ podling_thread_id: str | None = None,
) -> str:
- return "\n".join(
- _trusted_vote_resolution_body(
- release, summary, passed, full_name, asf_uid, thread_id, binding_label, non_binding_label
- )
- )
+ return "\n".join(_trusted_tally_block(summary, binding_label, non_binding_label, thread_id, podling_thread_id))
async def vote_committee(thread_id: str, release: sql.Release) -> sql.Committee | None:
@@ -137,34 +130,16 @@ def vote_outcome(
return _vote_outcome_format(duration_hours_remaining, binding_plus_one, binding_minus_one)
-def vote_resolution(
- committee: sql.Committee,
- release: sql.Release,
+def email_tally_block(
tabulated_votes: dict[str, models.tabulate.VoteEmail],
summary: dict[str, int],
- passed: bool,
- outcome: str,
- full_name: str,
- asf_uid: str,
thread_id: str,
binding_label: str = "Binding",
non_binding_label: str = "Non-binding",
+ podling_thread_id: str | None = None,
) -> 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,
- binding_label,
- non_binding_label,
- )
+ _email_tally_block(tabulated_votes, summary, thread_id, binding_label, non_binding_label, podling_thread_id)
)
@@ -355,29 +330,17 @@ def _received_spf_envelope_from(received_spf: list[object]) -> str | None:
return None
-def _trusted_vote_resolution_body(
- release: sql.Release,
+def _trusted_tally_block(
summary: TrustedVoteSummaryData,
- passed: bool,
- full_name: str,
- asf_uid: str,
- thread_id: str | None,
binding_label: str,
non_binding_label: str,
+ thread_id: str | None,
+ podling_thread_id: str | None,
) -> 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:
+ if 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 f"https://lists.apache.org/thread/{podling_thread_id}"
yield ""
if thread_id is not None:
@@ -395,11 +358,6 @@ def _trusted_vote_resolution_body(
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:
@@ -519,32 +477,18 @@ def _vote_outcome_format(
return True, msg
-def _vote_resolution_body(
- committee: sql.Committee,
- release: sql.Release,
+def _email_tally_block(
tabulated_votes: dict[str, models.tabulate.VoteEmail],
summary: dict[str, int],
- passed: bool,
- outcome: str,
- full_name: str,
- asf_uid: str,
thread_id: str,
binding_label: str,
non_binding_label: str,
+ podling_thread_id: str | None,
) -> Generator[str]:
- committee_key = committee.display_name
- 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:
+ if 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 f"https://lists.apache.org/thread/{podling_thread_id}"
yield ""
yield "The current vote thread is archived at the following URL:"
else:
@@ -554,10 +498,6 @@ def _vote_resolution_body(
yield ""
yield from _vote_resolution_body_votes(tabulated_votes, summary, binding_label, non_binding_label)
- yield "Thank you for your participation."
- yield ""
- yield "Sincerely,"
- yield f"{full_name} ({asf_uid})"
def _vote_resolution_body_votes(
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index 609a7f8e..80bed965 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -19,6 +19,7 @@ import datetime
from typing import Final, Literal
import atr.config as config
+import atr.construct as construct
import atr.db as db
import atr.db.interaction as interaction
import atr.log as log
@@ -120,15 +121,25 @@ async def auto_resolve(task_args: args.VoteAutoResolve) -> results.Results | Non
)
thread_id = await _thread_id_for_vote_task(task_mid, task_recipient, task_args.resolver_id)
- resolution_body = tabulate.trusted_vote_resolution(
- release,
+ committee_name = "Incubator" if release.podling_thread_id else release.committee.display_name
+ atr_tally = tabulate.trusted_tally_block(
summary,
- passed,
- task_args.resolver_fullname,
- task_args.resolver_id,
- thread_id,
binding_label,
non_binding_label,
+ thread_id=thread_id,
+ podling_thread_id=release.podling_thread_id,
+ )
+ resolution_body = construct.finish_vote_body(
+ release.project.policy_finish_vote_template,
+ {
+ "ATR_TALLY": atr_tally,
+ "COMMITTEE": committee_name,
+ "OUTCOME": "passed" if passed else "failed",
+ "PROJECT_NAME": release.project.short_display_name,
+ "VERSION": release.version,
+ "YOUR_ASF_ID": task_args.resolver_id,
+ "YOUR_FULL_NAME": task_args.resolver_fullname,
+ },
)
try:
diff --git a/migrations/versions/0088_2026.05.27_449e0006.py b/migrations/versions/0088_2026.05.27_449e0006.py
new file mode 100644
index 00000000..165e9f09
--- /dev/null
+++ b/migrations/versions/0088_2026.05.27_449e0006.py
@@ -0,0 +1,27 @@
+"""Add finish_vote_template to release policies
+
+Revision ID: 0088_2026.05.27_449e0006
+Revises: 0087_2026.05.26_3eabb86e
+Create Date: 2026-05-27 11:00:00.000000+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0088_2026.05.27_449e0006"
+down_revision: str | None = "0087_2026.05.26_3eabb86e"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("releasepolicy", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("finish_vote_template", sa.String(), nullable=False, server_default=""))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("releasepolicy", schema=None) as batch_op:
+ batch_op.drop_column("finish_vote_template")
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index f72a070e..92a677f6 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -599,6 +599,7 @@ async def test_resolve_page_allows_manual_continuation_when_archive_lookup_fails
)
assert context["resolve_form"] == "FORM"
assert form_render.call_args.kwargs["defaults"] == {
+ "email_body": "",
"result_email_to": "dev@project.apache.org",
"result_subject": "[RESULT] [VOTE] Release Project 1.0.0 <RESOLUTION>",
"vote_mode": sql.VoteMode.EMAIL,
@@ -625,6 +626,7 @@ async def test_resolve_page_allows_manual_continuation_when_tabulation_is_invali
)
assert context["resolve_form"] == "FORM"
assert form_render.call_args.kwargs["defaults"] == {
+ "email_body": "",
"result_email_to": "dev@project.apache.org",
"result_subject": "[RESULT] [VOTE] Release Project 1.0.0 <RESOLUTION>",
"vote_mode": sql.VoteMode.EMAIL,
@@ -657,6 +659,7 @@ async def test_resolve_page_allows_manual_continuation_when_thread_fetch_fails(
)
assert context["resolve_form"] == "FORM"
assert form_render.call_args.kwargs["defaults"] == {
+ "email_body": "",
"result_email_to": "dev@project.apache.org",
"result_subject": "[RESULT] [VOTE] Release Project 1.0.0 <RESOLUTION>",
"vote_mode": sql.VoteMode.EMAIL,
@@ -1298,6 +1301,7 @@ def _candidate_release(podling_thread_id: str | None = None) -> SimpleNamespace:
display_name="Project",
short_display_name="Project",
release_policy=None,
+ policy_finish_vote_template="{{ATR_TALLY}}",
committee=SimpleNamespace(
key="project",
display_name="Project",
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org