You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/10/16 18:56:15 UTC

(tooling-trusted-releases) branch sbp updated: Include the tally when resolving votes through the API

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

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new ded881f1 Include the tally when resolving votes through the API
ded881f1 is described below

commit ded881f1655d2ef913b02c37d5643e8fb39fdfdd
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Fri Oct 16 19:55:24 2026 +0100

    Include the tally when resolving votes through the API
---
 atr/api/__init__.py                | 66 ++++++++++++++++++++++++++++++-
 atr/docs/client-guide.md           |  2 +-
 tests/unit/test_vote_resolution.py | 80 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 145 insertions(+), 3 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index d9409e9a..2efeeeb9 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -2109,14 +2109,17 @@ async def vote_resolve(
     # try:
     try:
         async with storage.write_as_project_release_manager(data.project, asf_uid) as warm:
+            release, resolution_body, fullname = await _vote_resolve_release_and_body(warm, data, asf_uid)
             # TODO: Get fullname and use instead of asf_uid
             # TODO: Add resolution templating to atr.construct
             _release, _voting_round, _success_message, _error_message = await warm.vote.resolve(
                 data.project,
                 data.version,
                 data.resolution,
-                asf_uid,
-                f"The vote {data.resolution}.",
+                fullname,
+                resolution_body,
+                expected_vote_seq=release.current_vote_seq,
+                expected_vote_mode=release.effective_vote_mode,
                 early_reason=data.early_reason,
             )
     except storage.AccessError as e:
@@ -2593,6 +2596,65 @@ def _stored_certificate_issuer_match(
     return _matched_issuer_fingerprint(parsed, issuer_fingerprints, issuer_key_ids)
 
 
+async def _vote_resolve_release_and_body(
+    warm: storage.WriteAsReleaseManager, data: models.api.VoteResolveArgs, asf_uid: str
+) -> tuple[sql.Release, str, str]:
+    async with db.session() as db_data:
+        release = await db_data.release(
+            project_key=str(data.project),
+            version=str(data.version),
+            phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+            _project_release_policy=True,
+        ).get()
+    if (release is None) or user.embargo_hides_release(release, asf_uid, is_member=False):
+        raise storage.AccessError("Release not found", status=404)
+    if release.committee is None:
+        raise storage.AccessError(errors.RELEASE_NO_COMMITTEE, status=500)
+    fullname = await _ldap_fullname(asf_uid)
+    template = await construct.resolve_template(release.project, "finish_vote")
+    body = construct.finish_vote_body(
+        template.body,
+        {
+            "ATR_TALLY": await _vote_resolve_tally(warm, release),
+            "COMMITTEE": "Incubator" if release.podling_thread_id else release.committee.display_name,
+            "OUTCOME": "was cancelled" if (data.resolution == "cancelled") else data.resolution,
+            "PROJECT_NAME": release.project.short_display_name,
+            "VERSION": release.version,
+            "YOUR_ASF_ID": asf_uid,
+            "YOUR_FULL_NAME": fullname,
+        },
+    )
+    return release, body, fullname
+
+
+async def _vote_resolve_tally(warm: storage.WriteAsReleaseManager, release: sql.Release) -> str:
+    binding_label, non_binding_label = user.binding_terminology(interaction.trusted_vote_round(release))
+    thread_id = None
+    latest_vote_task = await interaction.release_current_vote_task(release)
+    if (latest_vote_task is not None) and (not release.expedited):
+        archive_url = await warm.cache.get_message_archive_url(
+            interaction.task_mid_get(latest_vote_task), interaction.task_recipient_get(latest_vote_task)
+        )
+        thread_id = archive_url.split("/")[-1] if archive_url else None
+    if (release.effective_vote_mode == sql.VoteMode.TRUSTED) and (release.current_vote_seq is not None):
+        ballots = await interaction.effective_trusted_ballots(release, release.current_vote_seq)
+        summary = await interaction.trusted_ballot_summary(release, ballots)
+        return tabulate.trusted_tally_block(
+            summary, binding_label, non_binding_label, thread_id=thread_id, podling_thread_id=release.podling_thread_id
+        )
+    if thread_id is None:
+        return ""
+    try:
+        committee = await tabulate.vote_committee(thread_id, release)
+        details = await tabulate.vote_details(committee, thread_id, release)
+    except (util.FetchError, ValueError) as e:
+        log.warning(f"Email tabulation unavailable for {release.key}: {e}")
+        return ""
+    return tabulate.email_tally_block(
+        details.votes, details.summary, thread_id, binding_label, non_binding_label, release.podling_thread_id
+    )
+
+
 async def _vote_start_subject_and_body(
     data: models.api.VoteStartArgs,
     release: sql.Release,
diff --git a/atr/docs/client-guide.md b/atr/docs/client-guide.md
index 9c9d9135..9154b386 100644
--- a/atr/docs/client-guide.md
+++ b/atr/docs/client-guide.md
@@ -130,7 +130,7 @@ When the result is clear, record it:
 atr vote resolve example 1.0.0 passed
 ```
 
-ATR replies to the vote thread and moves the release to the finish phase. The client's reply says only that the vote passed, so resolve on the website instead if you want the tally in the email. If the vote failed, use `failed`. The release returns to compose, where you can fix the files and start a fresh vote. To cancel a vote early, use the website.
+ATR replies to the vote thread, including the tally when available, and moves the release to the finish phase. If the vote failed, use `failed`. The release returns to compose, where you can fix the files and start a fresh vote. To cancel a vote early, use the website.
 
 ## Finish the release
 
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index 840ce3ea..f8366b05 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -1806,6 +1806,86 @@ def test_vote_resolution_bypass(
     assert interaction.vote_resolution_bypass(release, "admin") is expected
 
 
+@pytest.mark.parametrize(
+    ("vote_mode", "resolution", "outcome", "counts"),
+    [
+        (
+            sql.VoteMode.EMAIL,
+            "passed",
+            "passed",
+            "There were 3 binding votes.\n\nOf these binding votes, 3 were +1, 0 were -1, and 0 were 0.\n",
+        ),
+        (
+            sql.VoteMode.TRUSTED,
+            "failed",
+            "failed",
+            "There were 3 binding votes.\nBinding votes: +1: 3, 0: 0, -1: 0.\nNon-binding votes: +1: 0, 0: 0, -1: 1.",
+        ),
+        (sql.VoteMode.EMAIL, "cancelled", "was cancelled", None),
+    ],
+)
+@pytest.mark.asyncio
+async def test_vote_resolve_api_sends_tally(
+    monkeypatch: pytest.MonkeyPatch,
+    vote_mode: sql.VoteMode,
+    resolution: Literal["passed", "failed", "cancelled"],
+    outcome: str,
+    counts: str | None,
+) -> None:
+    release = _candidate_release()
+    release.vote_mode = vote_mode
+    release.effective_vote_mode = vote_mode
+    release.current_vote_seq = 7
+    release.project.policy_finish_vote_template = (
+        "{{YOUR_FULL_NAME}} ({{YOUR_ASF_ID}}): {{PROJECT_NAME}} {{VERSION}} {{OUTCOME}}.\n\n{{ATR_TALLY}}"
+    )
+    thread_url = "https://lists.apache.org/thread/0123456789abcdef0123456789abcdef"
+    db_data = mock.MagicMock()
+    db_data.release.return_value.get = mock.AsyncMock(return_value=release)
+    session = mock.MagicMock()
+    session.return_value.__aenter__.return_value = db_data
+    warm = mock.MagicMock()
+    warm.cache.get_message_archive_url = mock.AsyncMock(return_value=thread_url)
+    warm.vote.resolve = mock.AsyncMock(return_value=(release, None, "", None))
+    write = mock.MagicMock()
+    write.return_value.__aenter__.return_value = warm
+    vote_details = mock.AsyncMock(
+        return_value=SimpleNamespace(
+            votes={}, summary={**_empty_vote_summary(), "binding_votes": 3, "binding_votes_yes": 3}, passed=False
+        ),
+        side_effect=None if counts else ValueError("Invalid thread"),
+    )
+    trusted_summary = interaction.TrustedVoteSummary(binding_votes_yes=3, non_binding_votes_no=1)
+    monkeypatch.setattr(atr.api, "_jwt_asf_uid", lambda: "chair")
+    monkeypatch.setattr(atr.api, "_ldap_fullname", mock.AsyncMock(return_value="Project Chair"))
+    monkeypatch.setattr(atr.api.db, "session", session)
+    monkeypatch.setattr(atr.api.storage, "write_as_project_release_manager", write)
+    monkeypatch.setattr(
+        atr.api.interaction, "release_current_vote_task", mock.AsyncMock(return_value=_latest_vote_task())
+    )
+    monkeypatch.setattr(atr.api.interaction, "effective_trusted_ballots", mock.AsyncMock(return_value=[]))
+    monkeypatch.setattr(atr.api.interaction, "trusted_ballot_summary", mock.AsyncMock(return_value=trusted_summary))
+    monkeypatch.setattr(atr.api.tabulate, "vote_committee", mock.AsyncMock(return_value=release.committee))
+    monkeypatch.setattr(atr.api.tabulate, "vote_details", vote_details)
+
+    await inspect.unwrap(atr.api.vote_resolve)(
+        "vote/resolve",
+        SimpleNamespace(project="project", version="1.0.0", resolution=resolution, early_reason=""),
+    )
+
+    tally = f"The vote thread is archived at the following URL:\n\n{thread_url}\n\n{counts}" if counts else ""
+    warm.vote.resolve.assert_awaited_once_with(
+        "project",
+        "1.0.0",
+        resolution,
+        "Project Chair",
+        f"Project Chair (chair): Project 1.0.0 {outcome}.\n\n{tally}",
+        expected_vote_seq=7,
+        expected_vote_mode=vote_mode,
+        early_reason="",
+    )
+
+
 @pytest.mark.asyncio
 async def test_vote_tabulate_api_passes_trusted_receipt_exclusions(monkeypatch: pytest.MonkeyPatch) -> None:
     release = _candidate_release()


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org