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