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-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new 1c96d34  Allow the configuration of vote and announcement subject 
templates
1c96d34 is described below

commit 1c96d34071545b022bd784f1f160f2af18c06dab
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Dec 31 19:17:25 2025 +0000

    Allow the configuration of vote and announcement subject templates
---
 atr/construct.py                                | 61 ++++++++++++++++++--
 atr/get/announce.py                             | 17 ++++--
 atr/get/projects.py                             | 75 ++++++++++++++++++++++++-
 atr/get/voting.py                               | 21 +++++--
 atr/models/sql.py                               | 22 ++++++++
 atr/shared/projects.py                          |  8 +++
 atr/storage/writers/policy.py                   | 34 +++++++++++
 atr/storage/writers/vote.py                     | 35 +++++++-----
 migrations/versions/0034_2025.12.31_ac4dcf44.py | 29 ++++++++++
 9 files changed, 270 insertions(+), 32 deletions(-)

diff --git a/atr/construct.py b/atr/construct.py
index 2066ffc..40c709f 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -27,20 +27,20 @@ import atr.db.interaction as interaction
 import atr.models.sql as sql
 import atr.util as util
 
-type Context = Literal["announce", "checklist", "vote"]
+type Context = Literal["announce", "announce_subject", "checklist", "vote", 
"vote_subject"]
 
 TEMPLATE_VARIABLES: list[tuple[str, str, set[Context]]] = [
     ("CHECKLIST_URL", "URL to the release checklist", {"vote"}),
-    ("COMMITTEE", "Committee display name", {"announce", "checklist", "vote"}),
+    ("COMMITTEE", "Committee display name", {"announce", "checklist", "vote", 
"vote_subject"}),
     ("DOWNLOAD_URL", "URL to download the release", {"announce"}),
     ("DURATION", "Vote duration in hours", {"vote"}),
     ("KEYS_FILE", "URL to the KEYS file", {"vote"}),
-    ("PROJECT", "Project display name", {"announce", "checklist", "vote"}),
+    ("PROJECT", "Project display name", {"announce", "announce_subject", 
"checklist", "vote", "vote_subject"}),
     ("RELEASE_CHECKLIST", "Release checklist content", {"vote"}),
     ("REVIEW_URL", "URL to review the release", {"checklist", "vote"}),
-    ("REVISION", "Revision number", {"announce", "checklist", "vote"}),
-    ("TAG", "Revision tag, if set", {"announce", "checklist", "vote"}),
-    ("VERSION", "Version name", {"announce", "checklist", "vote"}),
+    ("REVISION", "Revision number", {"announce", "checklist", "vote", 
"vote_subject"}),
+    ("TAG", "Revision tag, if set", {"announce", "checklist", "vote", 
"vote_subject"}),
+    ("VERSION", "Version name", {"announce", "announce_subject", "checklist", 
"vote", "vote_subject"}),
     ("VOTE_ENDS_UTC", "Vote end date and time in UTC", {"vote"}),
     ("YOUR_ASF_ID", "Your Apache UID", {"announce", "vote"}),
     ("YOUR_FULL_NAME", "Your full name", {"announce", "vote"}),
@@ -120,6 +120,26 @@ async def announce_release_default(project_name: str) -> 
str:
     return project.policy_announce_release_template
 
 
+def announce_release_subject(subject: str, options: AnnounceReleaseOptions) -> 
str:
+    subject = subject.replace("{{PROJECT}}", options.project_name)
+    subject = subject.replace("{{VERSION}}", options.version_name)
+
+    return subject
+
+
+async def announce_release_subject_default(project_name: str) -> str:
+    async with db.session() as data:
+        project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE, _release_policy=True).demand(
+            RuntimeError(f"Project {project_name} not found")
+        )
+
+    return project.policy_announce_release_subject
+
+
+def announce_subject_template_variables() -> list[tuple[str, str]]:
+    return [(name, desc) for (name, desc, contexts) in TEMPLATE_VARIABLES if 
"announce_subject" in contexts]
+
+
 def announce_template_variables() -> list[tuple[str, str]]:
     return [(name, desc) for (name, desc, contexts) in TEMPLATE_VARIABLES if 
"announce" in contexts]
 
@@ -244,5 +264,34 @@ async def start_vote_default(project_name: str) -> str:
     return project.policy_start_vote_template
 
 
+def start_vote_subject(
+    subject: str,
+    options: StartVoteOptions,
+    revision_number: str,
+    revision_tag: str,
+    committee_name: str,
+) -> str:
+    subject = subject.replace("{{COMMITTEE}}", committee_name)
+    subject = subject.replace("{{PROJECT}}", options.project_name)
+    subject = subject.replace("{{REVISION}}", revision_number)
+    subject = subject.replace("{{TAG}}", revision_tag)
+    subject = subject.replace("{{VERSION}}", options.version_name)
+
+    return subject
+
+
+async def start_vote_subject_default(project_name: str) -> str:
+    async with db.session() as data:
+        project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE, _release_policy=True).demand(
+            RuntimeError(f"Project {project_name} not found")
+        )
+
+    return project.policy_start_vote_subject
+
+
+def vote_subject_template_variables() -> list[tuple[str, str]]:
+    return [(name, desc) for (name, desc, contexts) in TEMPLATE_VARIABLES if 
"vote_subject" in contexts]
+
+
 def vote_template_variables() -> list[tuple[str, str]]:
     return [(name, desc) for (name, desc, contexts) in TEMPLATE_VARIABLES if 
"vote" in contexts]
diff --git a/atr/get/announce.py b/atr/get/announce.py
index a24f60a..2c2d5c8 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -43,14 +43,19 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
         project_name, version_name, with_committee=True, 
phase=sql.ReleasePhase.RELEASE_PREVIEW
     )
 
-    # Variables used in defaults for subject and body
-    project_display_name = release.project.display_name or release.project.name
-
-    # The subject cannot be changed by the user
-    default_subject = f"[ANNOUNCE] {project_display_name} {version_name} 
released"
-    # The body can be changed, either from VoteTemplate or from the form
+    # Get the templates from the release policy
+    default_subject_template = await 
construct.announce_release_subject_default(project_name)
     default_body = await construct.announce_release_default(project_name)
 
+    # Expand the subject template
+    options = construct.AnnounceReleaseOptions(
+        asfuid=session.uid,
+        fullname=session.fullname,
+        project_name=release.project.display_name or project_name,
+        version_name=version_name,
+    )
+    default_subject = 
construct.announce_release_subject(default_subject_template, options)
+
     # The download path suffix can be changed
     # The defaults depend on whether the project is top level or not
     if (committee := release.project.committee) is None:
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 2003a82..085f4ff 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -215,6 +215,59 @@ async def view(session: web.Committer, name: str) -> 
web.WerkzeugResponse | str:
     )
 
 
+def _input_with_variables(
+    field_name: str,
+    default_value: str,
+    template_variables: list[tuple[str, str]],
+    documentation: str | None = None,
+) -> htm.Element:
+    text_input = htpy.input(
+        f"#{field_name}.form-control.font-monospace",
+        type="text",
+        name=field_name,
+        value=default_value,
+    )
+
+    variable_rows = []
+    for name, description in template_variables:
+        variable_rows.append(
+            htm.tr[
+                htm.td(".font-monospace.text-nowrap.py-1")[f"{{{{{name}}}}}"],
+                htm.td(".py-1")[description],
+                htm.td(".text-end.py-1")[
+                    htpy.button(
+                        ".btn.btn-sm.btn-outline-secondary.copy-var-btn",
+                        type="button",
+                        data_variable=f"{{{{{name}}}}}",
+                    )["Copy"]
+                ],
+            ]
+        )
+
+    variables_table = htm.table(".table.table-sm.mb-0")[
+        htm.thead[
+            htm.tr[
+                htm.th(".py-1")["Variable"],
+                htm.th(".py-1")["Description"],
+                htm.th(".py-1")[""],
+            ]
+        ],
+        htm.tbody[*variable_rows],
+    ]
+
+    details = htm.details(".mt-2")[
+        htm.summary(".text-muted")["Available template variables"],
+        htm.div(".mt-2")[variables_table],
+    ]
+
+    elements: list[htm.Element | htm.VoidElement] = [text_input]
+    if documentation:
+        elements.append(htm.div(".text-muted.mt-1.form-text")[documentation])
+    elements.append(details)
+
+    return htm.div[elements]
+
+
 def _render_categories_section(project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light")[htm.h3(".mb-2")["Categories"]]
@@ -322,6 +375,13 @@ def _render_finish_form(project: sql.Project) -> 
htm.Element:
         htm.h3(".mb-0")["Release policy - Finish options"]
     ]
 
+    announce_release_subject_widget = _input_with_variables(
+        field_name="announce_release_subject",
+        default_value=project.policy_announce_release_subject or "",
+        template_variables=construct.announce_subject_template_variables(),
+        documentation="Subject line template for announcement emails.",
+    )
+
     announce_release_template_widget = _textarea_with_variables(
         field_name="announce_release_template",
         default_value=project.policy_announce_release_template or "",
@@ -339,6 +399,7 @@ def _render_finish_form(project: sql.Project) -> 
htm.Element:
             defaults={
                 "project_name": project.name,
                 "github_finish_workflow_path": 
"\n".join(project.policy_github_finish_workflow_path),
+                "announce_release_subject": 
project.policy_announce_release_subject or "",
                 "announce_release_template": 
project.policy_announce_release_template or "",
                 "preserve_download_files": 
project.policy_preserve_download_files,
             },
@@ -346,7 +407,10 @@ def _render_finish_form(project: sql.Project) -> 
htm.Element:
             border=True,
             # wider_widgets=True,
             textarea_rows=10,
-            custom={"announce_release_template": 
announce_release_template_widget},
+            custom={
+                "announce_release_subject": announce_release_subject_widget,
+                "announce_release_template": announce_release_template_widget,
+            },
         )
     return card.collect()
 
@@ -539,6 +603,7 @@ def _render_vote_form(project: sql.Project) -> htm.Element:
         "pause_for_rm": project.policy_pause_for_rm,
         "release_checklist": project.policy_release_checklist or "",
         "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 "",
     }
 
@@ -552,6 +617,13 @@ def _render_vote_form(project: sql.Project) -> htm.Element:
         documentation="Markdown text describing how to test release 
candidates.",
     )
 
+    start_vote_subject_widget = _input_with_variables(
+        field_name="start_vote_subject",
+        default_value=project.policy_start_vote_subject or "",
+        template_variables=construct.vote_subject_template_variables(),
+        documentation="Subject line template for vote emails.",
+    )
+
     start_vote_template_widget = _textarea_with_variables(
         field_name="start_vote_template",
         default_value=project.policy_start_vote_template or "",
@@ -574,6 +646,7 @@ def _render_vote_form(project: sql.Project) -> htm.Element:
             skip=skip_fields,
             custom={
                 "release_checklist": release_checklist_widget,
+                "start_vote_subject": start_vote_subject_widget,
                 "start_vote_template": start_vote_template_widget,
             },
         )
diff --git a/atr/get/voting.py b/atr/get/voting.py
index f12b7a0..e5e9f13 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -64,15 +64,24 @@ async def selected_revision(
             min_hours = release.release_policy.min_hours
 
         revision_obj = await data.revision(release_name=release.name, 
number=revision).get()
-        if revision_obj and revision_obj.tag:
-            subject_suffix = f" ({revision_obj.tag})"
-        else:
-            subject_suffix = f" (revision {revision})"
+        revision_number = str(revision)
+        revision_tag = revision_obj.tag if (revision_obj and revision_obj.tag) 
else ""
 
-        # TODO: Add the draft revision number or tag to the subject
-        default_subject = f"[VOTE] Release {release.project.display_name} 
{release.version}{subject_suffix}"
+        default_subject_template = await 
construct.start_vote_subject_default(project_name)
         default_body = await construct.start_vote_default(project_name)
 
+        options = construct.StartVoteOptions(
+            asfuid=session.uid,
+            fullname=session.fullname,
+            project_name=release.project.display_name or project_name,
+            version_name=release.version,
+            vote_duration=min_hours,
+            vote_end="",
+        )
+        default_subject = construct.start_vote_subject(
+            default_subject_template, options, revision_number, revision_tag, 
committee.display_name
+        )
+
         keys_warning = await _check_keys_warning(committee)
 
         content = await _render_page(
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 5991d2d..e2ddadc 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -555,6 +555,10 @@ On behalf of the Apache {{COMMITTEE}} project team,
 {{YOUR_FULL_NAME}} ({{YOUR_ASF_ID}})
 """
 
+    @property
+    def policy_announce_release_subject_default(self) -> str:
+        return "[ANNOUNCE] {{PROJECT}} {{VERSION}} released"
+
     @property
     def policy_start_vote_default(self) -> str:
         return """Hello {{COMMITTEE}},
@@ -587,10 +591,20 @@ Thanks,
 {{YOUR_FULL_NAME}} ({{YOUR_ASF_ID}})
 """
 
+    @property
+    def policy_start_vote_subject_default(self) -> str:
+        return "[VOTE] Release {{PROJECT}} {{VERSION}}"
+
     @property
     def policy_default_min_hours(self) -> int:
         return 72
 
+    @property
+    def policy_announce_release_subject(self) -> str:
+        if ((policy := self.release_policy) is None) or 
(policy.announce_release_subject == ""):
+            return self.policy_announce_release_subject_default
+        return policy.announce_release_subject
+
     @property
     def policy_announce_release_template(self) -> str:
         if ((policy := self.release_policy) is None) or 
(policy.announce_release_template == ""):
@@ -638,6 +652,12 @@ Thanks,
             return ""
         return policy.vote_comment_template
 
+    @property
+    def policy_start_vote_subject(self) -> str:
+        if ((policy := self.release_policy) is None) or 
(policy.start_vote_subject == ""):
+            return self.policy_start_vote_subject_default
+        return policy.start_vote_subject
+
     @property
     def policy_start_vote_template(self) -> str:
         if ((policy := self.release_policy) is None) or 
(policy.start_vote_template == ""):
@@ -1001,7 +1021,9 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
     release_checklist: str = sqlmodel.Field(default="")
     vote_comment_template: str = sqlmodel.Field(default="")
     pause_for_rm: bool = sqlmodel.Field(default=False)
+    start_vote_subject: str = sqlmodel.Field(default="")
     start_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(
         default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 20344e5..aec1233 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -191,6 +191,10 @@ class VotePolicyForm(form.Form):
         "Plain text template for vote comments. Voters can edit before 
submitting.",
         widget=form.Widget.TEXTAREA,
     )
+    start_vote_subject: str = form.label(
+        "Start vote subject",
+        widget=form.Widget.CUSTOM,
+    )
     start_vote_template: str = form.label(
         "Start vote template",
         widget=form.Widget.CUSTOM,
@@ -221,6 +225,10 @@ class FinishPolicyForm(form.Form):
         "The full paths to the GitHub workflows to use for the release, 
including the .github/workflows/ prefix.",
         widget=form.Widget.TEXTAREA,
     )
+    announce_release_subject: str = form.label(
+        "Announce release subject",
+        widget=form.Widget.CUSTOM,
+    )
     announce_release_template: str = form.label(
         "Announce release template",
         widget=form.Widget.CUSTOM,
diff --git a/atr/storage/writers/policy.py b/atr/storage/writers/policy.py
index cb71a7d..d49b97a 100644
--- a/atr/storage/writers/policy.py
+++ b/atr/storage/writers/policy.py
@@ -109,6 +109,7 @@ class CommitteeMember(CommitteeParticipant):
         project, release_policy = await 
self.__get_or_create_policy(project_name)
 
         release_policy.github_finish_workflow_path = 
_split_lines(form.github_finish_workflow_path)
+        self.__set_announce_release_subject(form.announce_release_subject or 
"", project, release_policy)
         self.__set_announce_release_template(form.announce_release_template or 
"", project, release_policy)
         release_policy.preserve_download_files = form.preserve_download_files
 
@@ -127,6 +128,7 @@ class CommitteeMember(CommitteeParticipant):
             release_policy.pause_for_rm = form.pause_for_rm
             release_policy.release_checklist = form.release_checklist or ""
             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)
         elif project.committee and project.committee.is_podling:
             raise storage.AccessError("Manual voting is not allowed for 
podlings.")
@@ -153,6 +155,22 @@ class CommitteeMember(CommitteeParticipant):
 
         return project, release_policy
 
+    def __set_announce_release_subject(
+        self,
+        submitted_subject: str,
+        project: models.sql.Project,
+        release_policy: models.sql.ReleasePolicy,
+    ) -> None:
+        submitted_subject = submitted_subject.strip()
+        current_default_text = project.policy_announce_release_subject_default
+        current_default_hash = 
util.compute_sha3_256(current_default_text.encode())
+        submitted_hash = util.compute_sha3_256(submitted_subject.encode())
+
+        if submitted_hash == current_default_hash:
+            release_policy.announce_release_subject = ""
+        else:
+            release_policy.announce_release_subject = submitted_subject
+
     def __set_announce_release_template(
         self,
         submitted_template: str,
@@ -182,6 +200,22 @@ class CommitteeMember(CommitteeParticipant):
         else:
             release_policy.min_hours = submitted_min_hours
 
+    def __set_start_vote_subject(
+        self,
+        submitted_subject: str,
+        project: models.sql.Project,
+        release_policy: models.sql.ReleasePolicy,
+    ) -> None:
+        submitted_subject = submitted_subject.strip()
+        current_default_text = project.policy_start_vote_subject_default
+        current_default_hash = 
util.compute_sha3_256(current_default_text.encode())
+        submitted_hash = util.compute_sha3_256(submitted_subject.encode())
+
+        if submitted_hash == current_default_hash:
+            release_policy.start_vote_subject = ""
+        else:
+            release_policy.start_vote_subject = submitted_subject
+
     def __set_start_vote_template(
         self,
         submitted_template: str,
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index ce2aa65..f188a68 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -174,19 +174,28 @@ class CommitteeParticipant(FoundationCommitter):
         vote_end = vote_start + datetime.timedelta(hours=vote_duration_choice)
         vote_end_str = vote_end.strftime("%Y-%m-%d %H:%M:%S UTC")
 
-        # Perform template substitutions in the body before passing to task
+        options = construct.StartVoteOptions(
+            asfuid=asf_uid,
+            fullname=asf_fullname,
+            project_name=project_name,
+            version_name=version_name,
+            vote_duration=vote_duration_choice,
+            vote_end=vote_end_str,
+        )
+
+        # Get revision tag for subject substitution
+        revision_obj = await self.__data.revision(release_name=release.name, 
number=selected_revision_number).get()
+        revision_tag = revision_obj.tag if (revision_obj and revision_obj.tag) 
else ""
+
+        # Get committee name for subject substitution
+        committee_name = release.committee.display_name if release.committee 
else ""
+
+        # Perform template substitutions before passing to task
         # This must be done here and not in the task because we need 
util.as_url
-        body_substituted = await construct.start_vote_body(
-            body_data,
-            construct.StartVoteOptions(
-                asfuid=asf_uid,
-                fullname=asf_fullname,
-                project_name=project_name,
-                version_name=version_name,
-                vote_duration=vote_duration_choice,
-                vote_end=vote_end_str,
-            ),
+        subject_substituted = construct.start_vote_subject(
+            subject_data, options, selected_revision_number, revision_tag, 
committee_name
         )
+        body_substituted = await construct.start_vote_body(body_data, options)
 
         # Create a task for vote initiation
         task = sql.Task(
@@ -198,7 +207,7 @@ class CommitteeParticipant(FoundationCommitter):
                 vote_duration=vote_duration_choice,
                 initiator_id=asf_uid,
                 initiator_fullname=asf_fullname,
-                subject=subject_data,
+                subject=subject_substituted,
                 body=body_substituted,
             ).model_dump(),
             asf_uid=asf_uid,
@@ -349,7 +358,7 @@ class CommitteeMember(CommitteeParticipant):
                 asf_uid=self.__asf_uid,
                 asf_fullname=asf_fullname,
                 
vote_duration_choice=latest_vote_task.task_args["vote_duration"],
-                subject_data=f"[VOTE] Release {release.project.display_name} 
{release.version}",
+                subject_data=await 
construct.start_vote_subject_default(release.project.name),
                 body_data=await 
construct.start_vote_default(release.project.name),
                 release=release,
                 promote=False,
diff --git a/migrations/versions/0034_2025.12.31_ac4dcf44.py 
b/migrations/versions/0034_2025.12.31_ac4dcf44.py
new file mode 100644
index 0000000..8cb4c82
--- /dev/null
+++ b/migrations/versions/0034_2025.12.31_ac4dcf44.py
@@ -0,0 +1,29 @@
+"""Add subject template options to release policies
+
+Revision ID: 0034_2025.12.31_ac4dcf44
+Revises: 0033_2025.12.31_f2d97d96
+Create Date: 2025-12-31 18:59:47.025592+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0034_2025.12.31_ac4dcf44"
+down_revision: str | None = "0033_2025.12.31_f2d97d96"
+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("start_vote_subject", sa.String(), 
nullable=False, server_default=""))
+        batch_op.add_column(sa.Column("announce_release_subject", sa.String(), 
nullable=False, server_default=""))
+
+
+def downgrade() -> None:
+    with op.batch_alter_table("releasepolicy", schema=None) as batch_op:
+        batch_op.drop_column("announce_release_subject")
+        batch_op.drop_column("start_vote_subject")


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

Reply via email to