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 2025/04/25 18:31:58 UTC

(tooling-trusted-release) branch main updated: Move the route to start a vote on a selected revision

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


The following commit(s) were added to refs/heads/main by this push:
     new c24b693  Move the route to start a vote on a selected revision
c24b693 is described below

commit c24b693777cb0fdef8e1470810705ceff01c9c55
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Fri Apr 25 19:31:39 2025 +0100

    Move the route to start a vote on a selected revision
---
 atr/routes/draft.py                                | 236 ------------------
 atr/routes/modules.py                              |   1 +
 atr/routes/vote.py                                 | 272 +++++++++++++++++++++
 atr/templates/compose-selected.html                |   2 +-
 ...vote-start.html => vote-selected-revision.html} |   2 +-
 5 files changed, 275 insertions(+), 238 deletions(-)

diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index ec36586..d9bd973 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -38,13 +38,10 @@ import atr.db.models as models
 import atr.mail as mail
 import atr.revision as revision
 import atr.routes as routes
-import atr.routes.candidate as candidate
 import atr.routes.compose as compose
 import atr.routes.root as root
 import atr.routes.upload as upload
 import atr.tasks.sbom as sbom
-import atr.tasks.vote as tasks_vote
-import atr.user as user
 import atr.util as util
 
 if TYPE_CHECKING:
@@ -481,185 +478,6 @@ async def vote_preview(session: routes.CommitterSession) -> quart.wrappers.respo
     return quart.Response(body, mimetype="text/plain")
 
 
-# TODO: Rename to vote.revision?
-@routes.committer("/vote/<project_name>/<version>/<revision>", methods=["GET", "POST"])
-async def vote_start(
-    session: routes.CommitterSession, project_name: str, version: str, revision: str
-) -> response.Response | str:
-    """Show the vote initiation form for a release."""
-    await session.check_access(project_name)
-    async with db.session() as data:
-        project = await data.project(name=project_name).demand(routes.FlashError("Project not found"))
-        release = await data.release(project_name=project.name, version=version, _committee=True).demand(
-            routes.FlashError("Release candidate not found")
-        )
-        # Check that the user is on the project committee for the release
-        # TODO: Consider relaxing this to all committers
-        # Otherwise we must not show the vote form
-        if not user.is_committee_member(release.committee, session.uid):
-            return await session.redirect(
-                compose.selected, error="You must be on the PMC of this project to start a vote"
-            )
-        committee = util.unwrap(release.committee)
-
-        sender = f"{session.uid}@apache.org"
-        permitted_recipients = util.permitted_vote_recipients(session.uid)
-
-        if release.vote_policy:
-            min_hours = release.vote_policy.min_hours
-        else:
-            min_hours = 72
-
-        class VoteInitiateForm(util.QuartFormTyped):
-            """Form for initiating a release vote."""
-
-            release_name = wtforms.HiddenField("Release Name")
-            mailing_list = wtforms.RadioField(
-                "Send vote email to",
-                choices=[
-                    (recipient, recipient) if (recipient != sender) else (recipient, f"{recipient} (preview only)")
-                    for recipient in permitted_recipients
-                ],
-                validators=[wtforms.validators.InputRequired("Mailing list selection is required")],
-                default="user-tests@tooling.apache.org",
-            )
-            vote_duration = wtforms.IntegerField(
-                "Minimum vote duration in hours",
-                validators=[
-                    wtforms.validators.InputRequired("Vote duration is required"),
-                    util.validate_vote_duration,
-                ],
-                default=min_hours,
-            )
-            subject = wtforms.StringField("Subject", validators=[wtforms.validators.Optional()])
-            body = wtforms.TextAreaField("Body", validators=[wtforms.validators.Optional()])
-            submit = wtforms.SubmitField("Send vote email")
-
-        version = release.version
-        committee_name = committee.name
-        committee_display = committee.display_name
-        project_name = release.project.name if release.project else "Unknown"
-
-        default_subject = f"[VOTE] Release Apache {committee_display} {project_name} {version}"
-        default_body = f"""Hello {committee_name},
-
-I'd like to call a vote on releasing the following artifacts as
-Apache {committee_display} {project_name} {version}.
-
-The release candidate can be found at:
-
-https://apache.example.org/{committee_name}/{project_name}-{version}/
-
-The release artifacts are signed with the GPG key with fingerprint:
-
-  [KEY_FINGERPRINT]
-
-Please review the release candidate and vote accordingly.
-
-[ ] +1 Release this package
-[ ] +0 Abstain
-[ ] -1 Do not release this package (please provide specific comments)
-
-This vote will remain open for [DURATION] hours.
-
-Thanks,
-[YOUR_NAME]
-"""
-
-        form = await VoteInitiateForm.create_form(
-            data=await quart.request.form if quart.request.method == "POST" else None,
-        )
-        # Set hidden field data explicitly
-        form.release_name.data = release.name
-
-        if quart.request.method == "GET":
-            form.subject.data = default_subject
-            form.body.data = default_body
-
-        if await form.validate_on_submit():
-            email_to: str = util.unwrap(form.mailing_list.data)
-            vote_duration_choice: int = util.unwrap(form.vote_duration.data)
-            subject_data: str = util.unwrap(form.subject.data)
-            body_data: str = util.unwrap(form.body.data)
-
-            if committee is None:
-                raise base.ASFQuartException("Release has no associated committee", errorcode=400)
-
-            if email_to not in permitted_recipients:
-                # This will be checked again by tasks/vote.py for extra safety
-                raise base.ASFQuartException("Invalid mailing list choice", errorcode=400)
-            if email_to != sender:
-                error = await _promote(data, release.name, project_name, version, revision)
-                if error:
-                    return await session.redirect(root.index, error=error)
-
-                # This is now handled by the _promote call, above
-                # # Update the release phase to the voting phase only if not sending a test message to the user
-                # release.phase = models.ReleasePhase.RELEASE_CANDIDATE
-
-                # Store when the release was put into the voting phase
-                release.vote_started = datetime.datetime.now(datetime.UTC)
-
-                # TODO: We also need to store the duration of the vote
-                # We can't allow resolution of the vote until the duration has elapsed
-                # But we allow the user to specify in the form
-                # And yet we also have VotePolicy.min_hours
-                # Presumably this sets the default, and the form takes precedence?
-                # VotePolicy.min_hours can also be 0, though
-
-            # Create a task for vote initiation
-            task = models.Task(
-                status=models.TaskStatus.QUEUED,
-                task_type=models.TaskType.VOTE_INITIATE,
-                task_args=tasks_vote.Initiate(
-                    release_name=release.name,
-                    email_to=email_to,
-                    vote_duration=vote_duration_choice,
-                    initiator_id=session.uid,
-                    subject=subject_data,
-                    body=body_data,
-                ).model_dump(),
-                release_name=release.name,
-            )
-
-            data.add(task)
-            # Flush to get the task ID
-            await data.flush()
-            await data.commit()
-
-            # NOTE: During debugging, this email is actually sent elsewhere
-            # TODO: We should perhaps move that logic here, so that we can show the debugging address
-            # We should also log all outgoing email and the session so that users can confirm
-            # And can be warned if there was a failure
-            # (The message should be shown on the vote resolution page)
-            # TODO: Link to the vote resolution page in the flash message
-            if email_to == sender:
-                # Test email, with no promotion
-                return await session.redirect(
-                    compose.selected,
-                    success=f"The vote announcement email will soon be sent to {email_to}. "
-                    "This is a test, and the release is not being voted on.",
-                    project_name=project_name,
-                    version_name=version,
-                )
-
-            resolve_release: routes.RouteHandler[str] = candidate.resolve_release  # type: ignore[has-type]
-            return await session.redirect(
-                resolve_release,
-                success=f"The vote announcement email will soon be sent to {email_to}.",
-                project_name=project_name,
-                version_name=version,
-            )
-
-        # For GET requests or failed POST validation
-        return await quart.render_template(
-            "draft-vote-start.html",
-            release=release,
-            form=form,
-            revision=revision,
-        )
-
-
 async def _delete_candidate_draft(data: db.Session, candidate_draft_name: str) -> None:
     """Delete a candidate draft and all its associated files."""
     # Check that the release exists
@@ -679,57 +497,3 @@ async def _delete_candidate_draft(data: db.Session, candidate_draft_name: str) -
     await data.ns_text_del_all(release.name + " preview")
     # Delete the release record
     await data.delete(release)
-
-
-async def _promote(
-    data: db.Session,
-    candidate_draft_name: str,
-    project_name: str,
-    version_name: str,
-    revision_name: str,
-) -> str | None:
-    """Promote a candidate draft to a new phase."""
-    # Get the release
-    # TODO: Use session.release here
-    release = await data.release(name=candidate_draft_name, _project=True).demand(
-        routes.FlashError("Candidate draft not found")
-    )
-
-    # Verify that it's in the correct phase
-    if release.phase != models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
-        return "This release is not in the candidate draft phase"
-
-    base_dir = util.release_directory_base(release)
-    # Use the directory of the specified revision
-    # TODO: This ensures that we promote the correct revision, but does not stop conflicts
-    # We need to obtain a lock when promoting
-    source_dir = base_dir / revision_name
-    target_dir: pathlib.Path
-
-    # Count how many files are in the source directory
-    file_count = await util.number_of_release_files(release)
-    if file_count == 0:
-        return "This candidate draft is empty, containing no files"
-
-    # Promote it to the target phase
-    # TODO: Obtain a lock for this
-    # NOTE: The functionality for skipping phases has been removed
-    release.stage = models.ReleaseStage.RELEASE_CANDIDATE
-    release.phase = models.ReleasePhase.RELEASE_CANDIDATE
-    release.revision = None
-    target_dir = util.release_directory(release)
-
-    if await aiofiles.os.path.exists(target_dir):
-        return f"Target directory {target_dir.name} already exists"
-
-    await data.commit()
-    # We updated the release
-    # This could act like a lock, but it would be difficult
-    # TODO: Ideally we'd store the revision on the release object instead
-    # Then we could make it atomic through the database
-
-    logging.warning(f"Moving {source_dir} to {target_dir} (base: {base_dir})")
-    await aioshutil.move(str(source_dir), str(target_dir))
-    await aioshutil.rmtree(str(base_dir))  # type: ignore[call-arg]
-
-    return None
diff --git a/atr/routes/modules.py b/atr/routes/modules.py
index 1a46e9c..ecedd6d 100644
--- a/atr/routes/modules.py
+++ b/atr/routes/modules.py
@@ -31,6 +31,7 @@ import atr.routes.revisions as revisions
 import atr.routes.root as root
 import atr.routes.start as start
 import atr.routes.upload as upload
+import atr.routes.vote as vote
 
 
 # Export data for a custom linter script
diff --git a/atr/routes/vote.py b/atr/routes/vote.py
new file mode 100644
index 0000000..2c81b7d
--- /dev/null
+++ b/atr/routes/vote.py
@@ -0,0 +1,272 @@
+# 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.
+
+import datetime
+import logging
+from typing import TYPE_CHECKING
+
+import aiofiles.os
+import aioshutil
+import asfquart.base as base
+import quart
+import werkzeug.wrappers.response as response
+import wtforms
+
+import atr.db as db
+import atr.db.models as models
+import atr.routes as routes
+import atr.routes.candidate as candidate
+import atr.routes.compose as compose
+import atr.routes.root as root
+import atr.tasks.vote as tasks_vote
+import atr.user as user
+import atr.util as util
+
+if TYPE_CHECKING:
+    import pathlib
+
+
+@routes.committer("/vote/<project_name>/<version_name>/<revision>", methods=["GET", "POST"])
+async def selected_revision(
+    session: routes.CommitterSession, project_name: str, version_name: str, revision: str
+) -> response.Response | str:
+    """Show the vote initiation form for a release."""
+    await session.check_access(project_name)
+    async with db.session() as data:
+        project = await data.project(name=project_name).demand(routes.FlashError("Project not found"))
+        release = await data.release(project_name=project.name, version=version_name, _committee=True).demand(
+            routes.FlashError("Release candidate not found")
+        )
+        # Check that the user is on the project committee for the release
+        # TODO: Consider relaxing this to all committers
+        # Otherwise we must not show the vote form
+        if not user.is_committee_member(release.committee, session.uid):
+            return await session.redirect(
+                compose.selected, error="You must be on the PMC of this project to start a vote"
+            )
+        committee = util.unwrap(release.committee)
+
+        sender = f"{session.uid}@apache.org"
+        permitted_recipients = util.permitted_vote_recipients(session.uid)
+
+        if release.vote_policy:
+            min_hours = release.vote_policy.min_hours
+        else:
+            min_hours = 72
+
+        class VoteInitiateForm(util.QuartFormTyped):
+            """Form for initiating a release vote."""
+
+            release_name = wtforms.HiddenField("Release Name")
+            mailing_list = wtforms.RadioField(
+                "Send vote email to",
+                choices=[
+                    (recipient, recipient) if (recipient != sender) else (recipient, f"{recipient} (preview only)")
+                    for recipient in permitted_recipients
+                ],
+                validators=[wtforms.validators.InputRequired("Mailing list selection is required")],
+                default="user-tests@tooling.apache.org",
+            )
+            vote_duration = wtforms.IntegerField(
+                "Minimum vote duration in hours",
+                validators=[
+                    wtforms.validators.InputRequired("Vote duration is required"),
+                    util.validate_vote_duration,
+                ],
+                default=min_hours,
+            )
+            subject = wtforms.StringField("Subject", validators=[wtforms.validators.Optional()])
+            body = wtforms.TextAreaField("Body", validators=[wtforms.validators.Optional()])
+            submit = wtforms.SubmitField("Send vote email")
+
+        version = release.version
+        committee_name = committee.name
+        committee_display = committee.display_name
+        project_name = release.project.name if release.project else "Unknown"
+
+        default_subject = f"[VOTE] Release Apache {committee_display} {project_name} {version}"
+        default_body = f"""Hello {committee_name},
+
+I'd like to call a vote on releasing the following artifacts as
+Apache {committee_display} {project_name} {version}.
+
+The release candidate can be found at:
+
+https://apache.example.org/{committee_name}/{project_name}-{version}/
+
+The release artifacts are signed with the GPG key with fingerprint:
+
+  [KEY_FINGERPRINT]
+
+Please review the release candidate and vote accordingly.
+
+[ ] +1 Release this package
+[ ] +0 Abstain
+[ ] -1 Do not release this package (please provide specific comments)
+
+This vote will remain open for [DURATION] hours.
+
+Thanks,
+[YOUR_NAME]
+"""
+
+        form = await VoteInitiateForm.create_form(
+            data=await quart.request.form if quart.request.method == "POST" else None,
+        )
+        # Set hidden field data explicitly
+        form.release_name.data = release.name
+
+        if quart.request.method == "GET":
+            form.subject.data = default_subject
+            form.body.data = default_body
+
+        if await form.validate_on_submit():
+            email_to: str = util.unwrap(form.mailing_list.data)
+            vote_duration_choice: int = util.unwrap(form.vote_duration.data)
+            subject_data: str = util.unwrap(form.subject.data)
+            body_data: str = util.unwrap(form.body.data)
+
+            if committee is None:
+                raise base.ASFQuartException("Release has no associated committee", errorcode=400)
+
+            if email_to not in permitted_recipients:
+                # This will be checked again by tasks/vote.py for extra safety
+                raise base.ASFQuartException("Invalid mailing list choice", errorcode=400)
+            if email_to != sender:
+                error = await _promote(data, release.name, project_name, version, revision)
+                if error:
+                    return await session.redirect(root.index, error=error)
+
+                # This is now handled by the _promote call, above
+                # # Update the release phase to the voting phase only if not sending a test message to the user
+                # release.phase = models.ReleasePhase.RELEASE_CANDIDATE
+
+                # Store when the release was put into the voting phase
+                release.vote_started = datetime.datetime.now(datetime.UTC)
+
+                # TODO: We also need to store the duration of the vote
+                # We can't allow resolution of the vote until the duration has elapsed
+                # But we allow the user to specify in the form
+                # And yet we also have VotePolicy.min_hours
+                # Presumably this sets the default, and the form takes precedence?
+                # VotePolicy.min_hours can also be 0, though
+
+            # Create a task for vote initiation
+            task = models.Task(
+                status=models.TaskStatus.QUEUED,
+                task_type=models.TaskType.VOTE_INITIATE,
+                task_args=tasks_vote.Initiate(
+                    release_name=release.name,
+                    email_to=email_to,
+                    vote_duration=vote_duration_choice,
+                    initiator_id=session.uid,
+                    subject=subject_data,
+                    body=body_data,
+                ).model_dump(),
+                release_name=release.name,
+            )
+
+            data.add(task)
+            # Flush to get the task ID
+            await data.flush()
+            await data.commit()
+
+            # NOTE: During debugging, this email is actually sent elsewhere
+            # TODO: We should perhaps move that logic here, so that we can show the debugging address
+            # We should also log all outgoing email and the session so that users can confirm
+            # And can be warned if there was a failure
+            # (The message should be shown on the vote resolution page)
+            # TODO: Link to the vote resolution page in the flash message
+            if email_to == sender:
+                # Test email, with no promotion
+                return await session.redirect(
+                    compose.selected,
+                    success=f"The vote announcement email will soon be sent to {email_to}. "
+                    "This is a test, and the release is not being voted on.",
+                    project_name=project_name,
+                    version_name=version,
+                )
+
+            resolve_release: routes.RouteHandler[str] = candidate.resolve_release  # type: ignore[has-type]
+            return await session.redirect(
+                resolve_release,
+                success=f"The vote announcement email will soon be sent to {email_to}.",
+                project_name=project_name,
+                version_name=version,
+            )
+
+        # For GET requests or failed POST validation
+        return await quart.render_template(
+            "vote-selected-revision.html",
+            release=release,
+            form=form,
+            revision=revision,
+        )
+
+
+async def _promote(
+    data: db.Session,
+    candidate_draft_name: str,
+    project_name: str,
+    version_name: str,
+    revision_name: str,
+) -> str | None:
+    """Promote a candidate draft to a new phase."""
+    # Get the release
+    # TODO: Use session.release here
+    release = await data.release(name=candidate_draft_name, _project=True).demand(
+        routes.FlashError("Candidate draft not found")
+    )
+
+    # Verify that it's in the correct phase
+    if release.phase != models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+        return "This release is not in the candidate draft phase"
+
+    base_dir = util.release_directory_base(release)
+    # Use the directory of the specified revision
+    # TODO: This ensures that we promote the correct revision, but does not stop conflicts
+    # We need to obtain a lock when promoting
+    source_dir = base_dir / revision_name
+    target_dir: pathlib.Path
+
+    # Count how many files are in the source directory
+    file_count = await util.number_of_release_files(release)
+    if file_count == 0:
+        return "This candidate draft is empty, containing no files"
+
+    # Promote it to the target phase
+    # TODO: Obtain a lock for this
+    # NOTE: The functionality for skipping phases has been removed
+    release.stage = models.ReleaseStage.RELEASE_CANDIDATE
+    release.phase = models.ReleasePhase.RELEASE_CANDIDATE
+    release.revision = None
+    target_dir = util.release_directory(release)
+
+    if await aiofiles.os.path.exists(target_dir):
+        return f"Target directory {target_dir.name} already exists"
+
+    await data.commit()
+    # We updated the release
+    # This could act like a lock, but it would be difficult
+    # TODO: Ideally we'd store the revision on the release object instead
+    # Then we could make it atomic through the database
+
+    logging.warning(f"Moving {source_dir} to {target_dir} (base: {base_dir})")
+    await aioshutil.move(str(source_dir), str(target_dir))
+    await aioshutil.rmtree(str(base_dir))  # type: ignore[call-arg]
+
+    return None
diff --git a/atr/templates/compose-selected.html b/atr/templates/compose-selected.html
index 4886a5c..b00133f 100644
--- a/atr/templates/compose-selected.html
+++ b/atr/templates/compose-selected.html
@@ -92,7 +92,7 @@
       <a href="{{ as_url(routes.revisions.selected, project_name=release.project.name, version_name=release.version) }}"
          title="View revision history"
          class="btn btn-secondary"><i class="fas fa-history me-1"></i> Revisions</a>
-      <a href="{{ as_url(routes.draft.vote_start, project_name=release.project.name, version=release.version, revision=release.revision) }}"
+      <a href="{{ as_url(routes.vote.selected_revision, project_name=release.project.name, version_name=release.version, revision=release.revision) }}"
          title="Start a vote on this draft"
          class="btn btn-success"><i class="fas fa-check-circle me-1"></i> Start vote</a>
       <button class="btn btn-danger"
diff --git a/atr/templates/draft-vote-start.html b/atr/templates/vote-selected-revision.html
similarity index 98%
rename from atr/templates/draft-vote-start.html
rename to atr/templates/vote-selected-revision.html
index 62f042c..74640a0 100644
--- a/atr/templates/draft-vote-start.html
+++ b/atr/templates/vote-selected-revision.html
@@ -40,7 +40,7 @@
   <form method="post"
         id="vote-initiate-form"
         class="atr-canary py-4 px-5"
-        action="{{ as_url(routes.draft.vote_start, project_name=release.project.name, version=release.version, revision=revision) }}">
+        action="{{ as_url(routes.vote.selected_revision, project_name=release.project.name, version_name=release.version, revision=release.revision) }}">
     {{ form.hidden_tag() if form.hidden_tag }}
     {{ form.release_name }}
 


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