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 345e161  Add separate routes for manually starting a vote
345e161 is described below

commit 345e161a16497f50c540e4f77566652b88287e28
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Nov 7 20:10:49 2025 +0000

    Add separate routes for manually starting a vote
---
 atr/form.py                |  14 ++++--
 atr/get/__init__.py        |   2 +
 atr/get/manual.py          | 107 +++++++++++++++++++++++++++++++++++++++++++++
 atr/post/__init__.py       |   2 +
 atr/post/manual.py         |  62 ++++++++++++++++++++++++++
 atr/shared/__init__.py     |   2 +
 atr/shared/distribution.py |   2 +-
 atr/shared/manual.py       |  68 ++++++++++++++++++++++++++++
 8 files changed, 255 insertions(+), 4 deletions(-)

diff --git a/atr/form.py b/atr/form.py
index 6736468..06744e4 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -191,6 +191,10 @@ async def render(
     if action is None:
         action = quart.request.path
 
+    is_empty_form = model_cls is Empty
+    if is_empty_form and (form_classes == ".atr-canary"):
+        form_classes = ""
+
     flash_error_data: dict[str, Any] = _get_flash_error_data() if 
use_error_data else {}
 
     field_rows: list[htm.Element] = []
@@ -223,9 +227,13 @@ async def render(
     if cancel_url:
         cancel_link = htpy.a(href=cancel_url, class_="btn btn-link 
text-secondary")["Cancel"]
         submit_div_contents.append(cancel_link)
-    submit_div = htm.div(".col-sm-9.offset-sm-3")
-    submit_row = htm.div(".row")[submit_div[submit_div_contents]]
-    form_children.append(submit_row)
+
+    if is_empty_form:
+        form_children.extend(submit_div_contents)
+    else:
+        submit_div = htm.div(".col-sm-9.offset-sm-3")
+        submit_row = htm.div(".row")[submit_div[submit_div_contents]]
+        form_children.append(submit_row)
 
     return htm.form(form_classes, action=action, method="post", 
enctype="multipart/form-data")[form_children]
 
diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index 95baae7..0273f3c 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -31,6 +31,7 @@ import atr.get.file as file
 import atr.get.finish as finish
 import atr.get.ignores as ignores
 import atr.get.keys as keys
+import atr.get.manual as manual
 import atr.get.preview as preview
 import atr.get.projects as projects
 import atr.get.published as published
@@ -64,6 +65,7 @@ __all__ = [
     "finish",
     "ignores",
     "keys",
+    "manual",
     "preview",
     "projects",
     "published",
diff --git a/atr/get/manual.py b/atr/get/manual.py
new file mode 100644
index 0000000..7cb5b16
--- /dev/null
+++ b/atr/get/manual.py
@@ -0,0 +1,107 @@
+# 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 atr.blueprints.get as get
+import atr.db as db
+import atr.form as form
+import atr.get.compose as compose
+import atr.htm as htm
+import atr.post.manual as post_manual
+import atr.shared.distribution as distribution
+import atr.shared.manual as shared_manual
+import atr.template as template
+import atr.util as util
+import atr.web as web
+
+
[email protected]("/manual/<project_name>/<version_name>/<revision>")
+async def selected_revision(
+    session: web.Committer, project_name: str, version_name: str, revision: str
+) -> web.WerkzeugResponse | str:
+    await session.check_access(project_name)
+
+    async with db.session() as data:
+        match await shared_manual.validated_release(session, project_name, 
version_name, revision, data):
+            case str() as error:
+                return await session.redirect(
+                    compose.selected,
+                    error=error,
+                    project_name=project_name,
+                    version_name=version_name,
+                    revision=revision,
+                )
+            case (release, _committee):
+                pass
+
+        content = await _render_page(release=release, revision=revision)
+
+        return await template.blank(
+            title=f"Start manual vote on {release.project.short_display_name} 
{release.version}", content=content
+        )
+
+
+async def _render_page(release, revision: str) -> htm.Element:
+    page = htm.Block()
+
+    back_link_url = util.as_url(
+        compose.selected,
+        project_name=release.project.name,
+        version_name=release.version,
+    )
+    distribution.html_nav(
+        page,
+        back_link_url,
+        f"Compose {release.short_display_name}",
+        "COMPOSE",
+    )
+
+    page.h1(".mb-4")[
+        "Start manual vote on ",
+        htm.strong[release.project.short_display_name],
+        " ",
+        htm.em[release.version],
+    ]
+
+    page.div(".px-3.py-4.mb-4.bg-light.border.rounded")[
+        htm.p(".mb-0")[
+            "This release has the manual vote process enabled. "
+            "Press the button below to promote this release to candidate 
status."
+        ]
+    ]
+
+    page.p[
+        "Once the vote is started, you must manually send the vote email to 
the appropriate mailing list, "
+        "wait for the vote to complete, and then manually advance the release 
to the next phase. "
+        "The ATR will then require you to submit the vote and vote result 
thread URLs to proceed."
+    ]
+
+    cancel_url = util.as_url(compose.selected, 
project_name=release.project.name, version_name=release.version)
+    manual_form = await form.render(
+        model_cls=form.Empty,
+        submit_label="Start manual vote",
+        cancel_url=cancel_url,
+        action=util.as_url(
+            post_manual.selected_revision,
+            project_name=release.project.name,
+            version_name=release.version,
+            revision=revision,
+        ),
+    )
+
+    page.append(manual_form)
+
+    return page.collect()
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index c014cf3..4d5209c 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -24,6 +24,7 @@ import atr.post.draft as draft
 import atr.post.finish as finish
 import atr.post.ignores as ignores
 import atr.post.keys as keys
+import atr.post.manual as manual
 import atr.post.preview as preview
 import atr.post.projects as projects
 import atr.post.resolve as resolve
@@ -47,6 +48,7 @@ __all__ = [
     "finish",
     "ignores",
     "keys",
+    "manual",
     "preview",
     "projects",
     "resolve",
diff --git a/atr/post/manual.py b/atr/post/manual.py
new file mode 100644
index 0000000..25a5919
--- /dev/null
+++ b/atr/post/manual.py
@@ -0,0 +1,62 @@
+# 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 atr.blueprints.post as post
+import atr.db as db
+import atr.get.vote as vote
+import atr.shared.manual as shared_manual
+import atr.storage as storage
+import atr.web as web
+
+
[email protected]("/manual/<project_name>/<version_name>/<revision>")
[email protected]()
+async def selected_revision(
+    session: web.Committer, project_name: str, version_name: str, revision: str
+) -> web.WerkzeugResponse | str:
+    await session.check_access(project_name)
+
+    async with db.session() as data:
+        match await shared_manual.validated_release(session, project_name, 
version_name, revision, data):
+            case str() as error:
+                return await session.redirect(
+                    vote.selected,
+                    error=error,
+                    project_name=project_name,
+                    version_name=version_name,
+                )
+            case (release, _committee):
+                pass
+
+        async with storage.write(session) as write:
+            wacp = await 
write.as_project_committee_participant(release.project_name)
+            error = await wacp.release.promote_to_candidate(release.name, 
revision, vote_manual=True)
+
+        if error:
+            return await session.redirect(
+                vote.selected,
+                error=error,
+                project_name=project_name,
+                version_name=version_name,
+            )
+
+        return await session.redirect(
+            vote.selected,
+            success="The manual vote process has been started.",
+            project_name=project_name,
+            version_name=version_name,
+        )
diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py
index 1426353..e8a07e1 100644
--- a/atr/shared/__init__.py
+++ b/atr/shared/__init__.py
@@ -30,6 +30,7 @@ import atr.shared.draft as draft
 import atr.shared.finish as finish
 import atr.shared.ignores as ignores
 import atr.shared.keys as keys
+import atr.shared.manual as manual
 import atr.shared.projects as projects
 import atr.shared.resolve as resolve
 import atr.shared.start as start
@@ -191,6 +192,7 @@ __all__ = [
     "finish",
     "ignores",
     "keys",
+    "manual",
     "projects",
     "resolve",
     "start",
diff --git a/atr/shared/distribution.py b/atr/shared/distribution.py
index 2d26fc4..fc1b22a 100644
--- a/atr/shared/distribution.py
+++ b/atr/shared/distribution.py
@@ -90,7 +90,7 @@ class FormProjectVersion:
 # TODO: Move this to an appropriate module
 def html_nav(container: htm.Block, back_url: str, back_anchor: str, phase: 
Phase) -> None:
     classes = ".d-flex.justify-content-between.align-items-center"
-    block = htm.Block(htm.p(classes))
+    block = htm.Block(htm.p(classes=classes))
     block.a(".atr-back-link", href=back_url)[f"← Back to {back_anchor}"]
     span = htm.Block(htm.span)
 
diff --git a/atr/shared/manual.py b/atr/shared/manual.py
new file mode 100644
index 0000000..21c917d
--- /dev/null
+++ b/atr/shared/manual.py
@@ -0,0 +1,68 @@
+# 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 atr.db as db
+import atr.db.interaction as interaction
+import atr.models.sql as sql
+import atr.user as user
+import atr.util as util
+import atr.web as web
+
+
+async def validated_release(
+    session: web.Committer,
+    project_name: str,
+    version_name: str,
+    revision: str,
+    data: db.Session,
+) -> tuple[sql.Release, sql.Committee] | str:
+    """Validate release for manual vote and return (release, committee) or 
error message."""
+    release = await session.release(
+        project_name,
+        version_name,
+        data=data,
+        with_project=True,
+        with_committee=True,
+        with_project_release_policy=True,
+    )
+
+    selected_revision_number = release.latest_revision_number
+    if selected_revision_number is None:
+        return "No revision found for this release"
+
+    if selected_revision_number != revision:
+        return "This revision does not match the revision you are voting on"
+
+    committee = release.committee
+    if committee is None:
+        return "The committee for this release was not found"
+
+    if not release.project.policy_manual_vote:
+        return "This release does not have manual vote mode enabled"
+
+    if release.project.policy_strict_checking:
+        if await interaction.has_failing_checks(release, revision, 
caller_data=data):
+            return "This release candidate draft has errors. Please fix the 
errors before starting a vote."
+
+    if not (user.is_committee_member(committee, session.uid) or 
user.is_admin(session.uid)):
+        return "You must be on the PMC of this project to start a vote"
+
+    has_files = await util.has_files(release)
+    if not has_files:
+        return "This release candidate draft has no files yet. Please add some 
files before starting a vote."
+
+    return release, committee


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

Reply via email to