This is an automated email from the ASF dual-hosted git repository.

akm pushed a commit to branch binding-vote-email-616
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit 596009289ea8060375bc737b9e91815cdd933b74
Author: Andrew K. Musselman <[email protected]>
AuthorDate: Mon Feb 2 12:30:30 2026 -0800

    Binding vote noted in email #616
---
 atr/post/vote.py            |  20 ++++-
 atr/storage/writers/vote.py |  44 ++++++++--
 tests/unit/test_vote.py     | 201 ++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 258 insertions(+), 7 deletions(-)

diff --git a/atr/post/vote.py b/atr/post/vote.py
index cdbb5ac..324a271 100644
--- a/atr/post/vote.py
+++ b/atr/post/vote.py
@@ -22,6 +22,7 @@ import atr.get as get
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
+import atr.user as user
 import atr.web as web
 
 
@@ -40,8 +41,25 @@ async def selected_post(
     vote = cast_vote_form.decision
     comment = cast_vote_form.comment
 
+    # Determine if the vote is binding based on committee membership
+    # This logic mirrors atr/get/vote.py _render_vote_authenticated()
+    is_pmc_member = user.is_committee_member(release.committee, session.uid)
+
+    if release.committee.is_podling:
+        # For podlings, Incubator PMC membership grants binding status
+        async with storage.write() as write:
+            try:
+                _wacm = write.as_committee_member("incubator")
+                is_binding = True
+            except storage.AccessError:
+                is_binding = False
+    else:
+        is_binding = is_pmc_member
+
     async with storage.write_as_committee_participant(release.committee.name) 
as wacm:
-        email_recipient, error_message = await 
wacm.vote.send_user_vote(release, vote, comment, session.fullname)
+        email_recipient, error_message = await wacm.vote.send_user_vote(
+            release, vote, comment, session.fullname, is_binding
+        )
 
     if error_message:
         await quart.flash(error_message, "error")
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 3084909..13ff310 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -81,6 +81,7 @@ class CommitteeParticipant(FoundationCommitter):
         vote: str,
         comment: str,
         fullname: str,
+        is_binding: bool = False,
     ) -> tuple[str, str]:
         # Get the email thread
         latest_vote_task = await interaction.release_latest_vote_task(release)
@@ -102,12 +103,13 @@ class CommitteeParticipant(FoundationCommitter):
         email_recipient = latest_vote_task.task_args["email_to"]
         email_sender = f"{self.__asf_uid}@apache.org"
         subject = f"Re: {original_subject}"
-        body = [f"{vote.lower()} ({self.__asf_uid}) {fullname}"]
-        if comment:
-            body.append(f"{comment}")
-            # Only include the signature if there is a comment
-            body.append(f"-- \n{fullname} ({self.__asf_uid})")
-        body_text = "\n\n".join(body)
+        body_text = format_vote_email_body(
+            vote=vote,
+            asf_uid=self.__asf_uid,
+            fullname=fullname,
+            is_binding=is_binding,
+            comment=comment,
+        )
         in_reply_to = vote_thread_mid
 
         task = sql.Task(
@@ -473,3 +475,33 @@ class CommitteeMember(CommitteeParticipant):
     # def __committee_member_or_admin(self, committee: sql.Committee, asf_uid: 
str) -> None:
     #     if not (user.is_committee_member(committee, asf_uid) or 
user.is_admin(asf_uid)):
     #         raise storage.AccessError("You do not have permission to perform 
this action")
+
+
+def format_vote_email_body(
+    vote: str,
+    asf_uid: str,
+    fullname: str,
+    is_binding: bool,
+    comment: str = "",
+) -> str:
+    """Format the body of a vote email.
+
+    Args:
+        vote: The vote value (+1, 0, or -1)
+        asf_uid: The ASF user ID of the voter
+        fullname: The full name of the voter
+        is_binding: Whether this is a binding vote (PMC member)
+        comment: Optional comment to include
+
+    Returns:
+        The formatted email body text
+    """
+    if is_binding:
+        body = [f"{vote} (binding) ({asf_uid}) {fullname}"]
+    else:
+        body = [f"{vote} ({asf_uid}) {fullname}"]
+    if comment:
+        body.append(f"{comment}")
+        # Only include the signature if there is a comment
+        body.append(f"-- \n{fullname} ({asf_uid})")
+    return "\n\n".join(body)
diff --git a/tests/unit/test_vote.py b/tests/unit/test_vote.py
new file mode 100644
index 0000000..170bc49
--- /dev/null
+++ b/tests/unit/test_vote.py
@@ -0,0 +1,201 @@
+# 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.
+
+"""Tests for vote email formatting and binding vote determination."""
+
+import atr.storage.writers.vote as vote_writer
+import atr.user as user
+
+
+class _MockCommittee:
+    def __init__(
+        self,
+        name: str = "testproject",
+        display_name: str = "Test Project",
+        committee_members: list[str] | None = None,
+        committers: list[str] | None = None,
+    ):
+        self.name = name
+        self.display_name = display_name
+        self.committee_members = committee_members or []
+        self.committers = committers or []
+
+
+def test_binding_vote_body_format() -> None:
+    """Binding votes include '(binding)' in the email body."""
+    body = vote_writer.format_vote_email_body(
+        vote="+1",
+        asf_uid="pmcmember",
+        fullname="PMC Member",
+        is_binding=True,
+    )
+    assert body == "+1 (binding) (pmcmember) PMC Member"
+
+
+def test_non_binding_vote_body_format() -> None:
+    """Non-binding votes do not include '(binding)' in the email body."""
+    body = vote_writer.format_vote_email_body(
+        vote="+1",
+        asf_uid="contributor",
+        fullname="A Contributor",
+        is_binding=False,
+    )
+    assert body == "+1 (contributor) A Contributor"
+
+
+def test_negative_binding_vote_body_format() -> None:
+    """Negative binding votes are formatted correctly."""
+    body = vote_writer.format_vote_email_body(
+        vote="-1",
+        asf_uid="pmcmember",
+        fullname="PMC Member",
+        is_binding=True,
+    )
+    assert body == "-1 (binding) (pmcmember) PMC Member"
+
+
+def test_zero_binding_vote_body_format() -> None:
+    """Zero binding votes are formatted correctly."""
+    body = vote_writer.format_vote_email_body(
+        vote="0",
+        asf_uid="voter",
+        fullname="Abstaining Voter",
+        is_binding=True,
+    )
+    assert body == "0 (binding) (voter) Abstaining Voter"
+
+
+def test_zero_non_binding_vote_body_format() -> None:
+    """Zero non-binding votes are formatted correctly."""
+    body = vote_writer.format_vote_email_body(
+        vote="0",
+        asf_uid="voter",
+        fullname="Abstaining Voter",
+        is_binding=False,
+    )
+    assert body == "0 (voter) Abstaining Voter"
+
+
+def test_binding_vote_with_comment() -> None:
+    """Binding votes with comments include the comment and signature."""
+    body = vote_writer.format_vote_email_body(
+        vote="+1",
+        asf_uid="pmcmember",
+        fullname="PMC Member",
+        is_binding=True,
+        comment="Verified signatures and checksums. Tests pass.",
+    )
+    expected = (
+        "+1 (binding) (pmcmember) PMC Member\n\n"
+        "Verified signatures and checksums. Tests pass.\n\n"
+        "-- \nPMC Member (pmcmember)"
+    )
+    assert body == expected
+
+
+def test_non_binding_vote_with_comment() -> None:
+    """Non-binding votes with comments include the comment and signature."""
+    body = vote_writer.format_vote_email_body(
+        vote="+1",
+        asf_uid="contributor",
+        fullname="A Contributor",
+        is_binding=False,
+        comment="Looks good to me!",
+    )
+    expected = "+1 (contributor) A Contributor\n\nLooks good to me!\n\n-- \nA 
Contributor (contributor)"
+    assert body == expected
+
+
+def test_negative_vote_with_comment() -> None:
+    """Negative votes with comments are formatted correctly."""
+    body = vote_writer.format_vote_email_body(
+        vote="-1",
+        asf_uid="reviewer",
+        fullname="Careful Reviewer",
+        is_binding=True,
+        comment="Found a license issue in the dependencies.",
+    )
+    expected = (
+        "-1 (binding) (reviewer) Careful Reviewer\n\n"
+        "Found a license issue in the dependencies.\n\n"
+        "-- \nCareful Reviewer (reviewer)"
+    )
+    assert body == expected
+
+
+def test_empty_comment_no_signature() -> None:
+    """Empty comments do not add a signature."""
+    body = vote_writer.format_vote_email_body(
+        vote="+1",
+        asf_uid="pmcmember",
+        fullname="PMC Member",
+        is_binding=True,
+        comment="",
+    )
+    assert body == "+1 (binding) (pmcmember) PMC Member"
+    assert "-- \n" not in body
+
+
+def test_pmc_member_has_binding_vote() -> None:
+    """PMC members have binding votes."""
+    committee = _MockCommittee(
+        committee_members=["alice", "bob", "charlie"],
+        committers=["alice", "bob", "charlie", "dave", "eve"],
+    )
+    is_binding = user.is_committee_member(committee, "alice")  # type: 
ignore[arg-type]
+    assert is_binding is True
+
+
+def test_committer_non_pmc_has_non_binding_vote() -> None:
+    """Committers who are not PMC members have non-binding votes."""
+    committee = _MockCommittee(
+        committee_members=["alice", "bob", "charlie"],
+        committers=["alice", "bob", "charlie", "dave", "eve"],
+    )
+    is_binding = user.is_committee_member(committee, "dave")  # type: 
ignore[arg-type]
+    assert is_binding is False
+
+
+def test_non_committer_has_non_binding_vote() -> None:
+    """Non-committers have non-binding votes."""
+    committee = _MockCommittee(
+        committee_members=["alice", "bob", "charlie"],
+        committers=["alice", "bob", "charlie", "dave", "eve"],
+    )
+    is_binding = user.is_committee_member(committee, "frank")  # type: 
ignore[arg-type]
+    assert is_binding is False
+
+
+def test_none_committee_returns_false() -> None:
+    """None committee returns False for membership."""
+    is_binding = user.is_committee_member(None, "anyone")
+    assert is_binding is False
+
+
+def test_admin_not_on_pmc_has_non_binding_vote() -> None:
+    """Admins who are not PMC members do not get binding votes."""
+    committee = _MockCommittee(
+        committee_members=["alice", "bob"],
+        committers=["alice", "bob", "charlie"],
+    )
+    # admin_user is not on this PMC
+    is_pmc_member = user.is_committee_member(committee, "admin_user")  # type: 
ignore[arg-type]
+    assert is_pmc_member is False
+
+    # Binding is determined ONLY by PMC membership, not admin status
+    is_binding = is_pmc_member
+    assert is_binding is False


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to