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/07/13 15:46:01 UTC

(tooling-trusted-releases) 01/01: Use templates by default in the API when constructing vote messages

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

commit e85ddb2611ea9c484e9bbb1776093fa937ac8e5a
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Mon Jul 13 16:33:12 2026 +0100

    Use templates by default in the API when constructing vote messages
---
 atr/api/__init__.py               |  41 +++++++++++++--
 atr/models/api.py                 |   6 ++-
 tests/unit/test_api_vote_start.py | 105 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 146 insertions(+), 6 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 8bbf4cd6..2a4f31db 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -33,6 +33,7 @@ import atr.blueprints.api_auth as api_auth
 import atr.cle as cle
 import atr.config as config
 import atr.constants as constants
+import atr.construct as construct
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.hashes as hashes
@@ -1975,16 +1976,17 @@ async def vote_start(
         util.validate_email_recipients(data)
         util.validate_vote_duration(data.vote_duration)
 
+        subject, body, fullname = await _vote_start_subject_and_body(data, release, asf_uid, scanned_revision)
+
         async with storage.write_as_project_release_manager(data.project, asf_uid) as warm:
-            # TODO: Get fullname and use instead of asf_uid
             task = await warm.vote.start(
                 data.email_to,
                 data.project,
                 data.version,
                 data.vote_duration,
-                data.subject,
-                data.body,
-                asf_uid,
+                subject,
+                body,
+                fullname,
                 permitted_recipients=permitted_recipients,
                 email_cc=data.email_cc,
                 email_bcc=data.email_bcc,
@@ -2275,6 +2277,37 @@ async def _resolve_signing_key_from_signature(
     raise exceptions.NotFound("No matching signing key found for signature")
 
 
+async def _vote_start_subject_and_body(
+    data: models.api.VoteStartArgs, release: sql.Release, asf_uid: str, revision_number: safe.RevisionNumber
+) -> tuple[str, str, str]:
+    fullname = await _ldap_fullname(asf_uid)
+    if (data.subject is not None) and (data.body is not None):
+        return data.subject, data.body, fullname
+    subject_template = ""
+    body_template = ""
+    if data.subject is None:
+        subject_template = release.project.policy_start_vote_subject
+    if data.body is None:
+        if release.expedited:
+            body_template = construct.START_VOTE_EXPEDITED_DEFAULT
+        else:
+            body_template = release.project.policy_start_vote_template
+    options = construct.StartVoteOptions(
+        asfuid=asf_uid,
+        fullname=fullname,
+        project_key=data.project,
+        version_key=data.version,
+        revision_number=revision_number,
+        vote_duration=(0 if release.expedited else data.vote_duration),
+    )
+    subject, body = await construct.start_vote_subject_and_body(subject_template, body_template, options)
+    if data.subject is not None:
+        subject = data.subject
+    if data.body is not None:
+        body = data.body
+    return subject, body, fullname
+
+
 async def _vote_tabulate_email_details(
     release: sql.Release,
     is_trusted: bool,
diff --git a/atr/models/api.py b/atr/models/api.py
index 439afd0a..62aeb7c4 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -900,8 +900,10 @@ class VoteStartArgs(schema.Strict):
     email_cc: list[str] = schema.default_example([], ["dev@example.apache.org"])
     email_bcc: list[str] = schema.default_example([], ["dev@example.apache.org"])
     vote_duration: int = schema.example(72)
-    subject: str = schema.example("[VOTE] Apache Example 0.0.1 release")
-    body: str = schema.example("The Apache Example team is pleased to announce the release of Example 0.0.1...")
+    subject: str | None = schema.default_example(None, "[VOTE] Apache Example 0.0.1 release")
+    body: str | None = schema.default_example(
+        None, "The Apache Example team is pleased to announce the release of Example 0.0.1..."
+    )
     second_round_email_to: str | None = schema.default_example(None, "general@incubator.apache.org")
     notify_when_finished: bool = schema.default_example(False, False)
     automatic_resolve_when_finished: bool = schema.default_example(False, False)
diff --git a/tests/unit/test_api_vote_start.py b/tests/unit/test_api_vote_start.py
new file mode 100644
index 00000000..e8875137
--- /dev/null
+++ b/tests/unit/test_api_vote_start.py
@@ -0,0 +1,105 @@
+# 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.
+
+from types import SimpleNamespace
+
+import pytest
+
+import atr.api as api
+import atr.construct as construct
+import atr.models as models
+import atr.models.safe as safe
+
+
+@pytest.mark.asyncio
+async def test_vote_start_expedited_release_uses_expedited_template(monkeypatch) -> None:
+    calls = _patch_rendering(monkeypatch)
+
+    subject, body, fullname = await api._vote_start_subject_and_body(
+        _args(subject="Custom subject"), _release(expedited=True), "user", safe.RevisionNumber("00001")
+    )
+
+    assert (subject, body, fullname) == ("Custom subject", "RENDERED BODY", "Example User")
+    assert calls["subject"] == ""
+    assert calls["body"] == construct.START_VOTE_EXPEDITED_DEFAULT
+    assert calls["options"].vote_duration == 0
+
+
+@pytest.mark.asyncio
+async def test_vote_start_omitted_subject_and_body_render_policy_templates(monkeypatch) -> None:
+    calls = _patch_rendering(monkeypatch)
+
+    subject, body, fullname = await api._vote_start_subject_and_body(
+        _args(), _release(), "user", safe.RevisionNumber("00001")
+    )
+
+    assert (subject, body, fullname) == ("RENDERED SUBJECT", "RENDERED BODY", "Example User")
+    assert calls["subject"] == "[VOTE] {{PROJECT_NAME}} {{VERSION}}"
+    assert calls["body"] == "Body {{VERSION}}"
+    assert calls["options"].revision_number == safe.RevisionNumber("00001")
+    assert calls["options"].vote_duration == 72
+    assert calls["options"].fullname == "Example User"
+
+
+@pytest.mark.asyncio
+async def test_vote_start_supplied_subject_and_body_pass_through(monkeypatch) -> None:
+    calls = _patch_rendering(monkeypatch)
+
+    subject, body, fullname = await api._vote_start_subject_and_body(
+        _args(subject="Custom subject", body="Custom body"), _release(), "user", safe.RevisionNumber("00001")
+    )
+
+    assert (subject, body, fullname) == ("Custom subject", "Custom body", "Example User")
+    assert calls == {}
+
+
+def _args(subject: str | None = None, body: str | None = None) -> models.api.VoteStartArgs:
+    return models.api.VoteStartArgs(
+        project=safe.ProjectKey("example"),
+        version=safe.VersionKey("1.0.0"),
+        email_to="dev@example.apache.org",
+        vote_duration=72,
+        subject=subject,
+        body=body,
+    )
+
+
+def _patch_rendering(monkeypatch: pytest.MonkeyPatch) -> dict:
+    calls: dict = {}
+
+    async def fake_render(subject: str, body: str, options: construct.StartVoteOptions) -> tuple[str, str]:
+        calls["subject"] = subject
+        calls["body"] = body
+        calls["options"] = options
+        return "RENDERED SUBJECT", "RENDERED BODY"
+
+    async def fake_fullname(asf_uid: str) -> str:
+        return "Example User"
+
+    monkeypatch.setattr(api, "_ldap_fullname", fake_fullname)
+    monkeypatch.setattr(construct, "start_vote_subject_and_body", fake_render)
+    return calls
+
+
+def _release(expedited: bool = False) -> SimpleNamespace:
+    return SimpleNamespace(
+        expedited=expedited,
+        project=SimpleNamespace(
+            policy_start_vote_subject="[VOTE] {{PROJECT_NAME}} {{VERSION}}",
+            policy_start_vote_template="Body {{VERSION}}",
+        ),
+    )


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