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
The following commit(s) were added to refs/heads/sbp by this push:
new 54315beb Allow votes to be cancelled
54315beb is described below
commit 54315beb9485c57bbb5f6f2fa6553985749f28e1
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Mar 25 16:13:06 2026 +0000
Allow votes to be cancelled
---
atr/api/__init__.py | 2 +-
atr/get/manual.py | 6 +-
atr/get/resolve.py | 102 ++++++
atr/get/vote.py | 26 +-
atr/models/api.py | 4 +-
atr/post/manual.py | 3 +
atr/post/resolve.py | 114 +------
atr/shared/manual.py | 2 +-
atr/shared/resolve.py | 18 +-
atr/storage/writers/vote.py | 47 +--
atr/templates/about.html | 2 +-
atr/templates/check-selected-release-info.html | 11 +-
atr/templates/tutorial.html | 2 +-
tests/unit/test_vote_resolution.py | 428 +++++++++++++++++++++++++
14 files changed, 602 insertions(+), 165 deletions(-)
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index a8ce0a7d..9912ba1e 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1517,7 +1517,7 @@ async def vote_resolve(
Resolve a vote.
- A vote can be resolved by passing or failing.
+ A vote can be resolved as passed, failed, or cancelled.
"""
asf_uid = _jwt_asf_uid()
# try:
diff --git a/atr/get/manual.py b/atr/get/manual.py
index e654689d..e3a891b8 100644
--- a/atr/get/manual.py
+++ b/atr/get/manual.py
@@ -159,7 +159,11 @@ def _render_resolve_page(release: sql.Release) ->
htm.Element:
page.p[htm.a(".atr-back-link", href=back_url)[f"← Back to Vote for
{release.short_display_name}"]]
page.h1[f"Resolve vote for {release.short_display_name}"]
- page.p["This is a manual vote resolution."]
+ page.p[
+ "This is a manual vote resolution. "
+ "Provide the vote thread URL and the URL of the thread where you
posted the result. "
+ "For a cancellation, provide the URL of the thread where you sent the
cancellation notice."
+ ]
form.render_block(
page,
diff --git a/atr/get/resolve.py b/atr/get/resolve.py
index 13a83393..063bf29c 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -14,3 +14,105 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+
+from typing import Literal
+
+import atr.blueprints.get as get
+import atr.db.interaction as interaction
+import atr.form
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.post as post
+import atr.shared as shared
+import atr.storage as storage
+import atr.tabulate as tabulate
+import atr.template as template
+import atr.util as util
+import atr.web as web
+
+
[email protected]
+async def selected(
+ session: web.Committer,
+ _resolve: Literal["resolve"],
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+) -> str:
+ """
+ URL: /resolve/<project_key>/<version_key>
+ """
+ asf_uid = session.uid
+ full_name = session.fullname
+
+ release = await session.release(
+ project_key,
+ version_key,
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ with_release_policy=True,
+ with_project_release_policy=True,
+ )
+ if release.vote_manual:
+ raise RuntimeError("This page is for tabulated votes only")
+
+ details = None
+ committee = None
+ thread_id = None
+ archive_url = None
+ fetch_error = None
+
+ latest_vote_task = await interaction.release_latest_vote_task(release)
+ if latest_vote_task is not None:
+ task_mid = interaction.task_mid_get(latest_vote_task)
+ task_recipient = interaction.task_recipient_get(latest_vote_task)
+ if task_mid:
+ async with storage.write(session) as write:
+ wagp = write.as_general_public()
+ archive_url = await
wagp.cache.get_message_archive_url(task_mid, task_recipient)
+
+ if archive_url:
+ thread_id = archive_url.split("/")[-1]
+ if thread_id:
+ try:
+ committee = await tabulate.vote_committee(thread_id, release)
+ except util.FetchError as e:
+ fetch_error = f"Failed to fetch thread metadata: {e}"
+ else:
+ details = await tabulate.vote_details(committee, thread_id,
release)
+ else:
+ fetch_error = "The vote thread could not yet be found."
+ else:
+ fetch_error = "The vote thread could not yet be found."
+
+ defaults = {}
+ if (committee is not None) and (details is not None) and (thread_id is not
None):
+ defaults["email_body"] = tabulate.vote_resolution(
+ committee,
+ release,
+ details.votes,
+ details.summary,
+ details.passed,
+ details.outcome,
+ full_name,
+ asf_uid,
+ thread_id,
+ )
+ defaults["vote_result"] = "Passed" if details.passed else "Failed"
+
+ resolve_form = atr.form.render(
+ model_cls=shared.resolve.SubmitForm,
+ action=util.as_url(post.resolve.selected,
project_key=release.project.key, version_key=release.version),
+ submit_label="Resolve vote",
+ textarea_rows=24,
+ defaults=defaults,
+ )
+
+ return await template.render(
+ "resolve-tabulated.html",
+ release=release,
+ tabulated_votes=details.votes if (details is not None) else {},
+ summary=details.summary if (details is not None) else {},
+ outcome=details.outcome if (details is not None) else "",
+ resolve_form=resolve_form,
+ fetch_error=fetch_error,
+ archive_url=archive_url,
+ )
diff --git a/atr/get/vote.py b/atr/get/vote.py
index c4f7a4e9..d527c239 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -32,6 +32,8 @@ import atr.form as form
import atr.get.checklist as checklist
import atr.get.download as download
import atr.get.keys as keys
+import atr.get.manual as manual
+import atr.get.resolve as resolve
import atr.get.root as root
import atr.htm as htm
import atr.mapping as mapping
@@ -460,16 +462,20 @@ def _render_section_resolve(page: htm.Block, release:
sql.Release, user_category
else:
page.p["When the voting period concludes, use the resolution page to
tally votes and record the outcome."]
- # POST form for resolve button
- resolve_url = util.as_url(
- post.resolve.selected,
- project_key=release.project.key,
- version_key=release.version,
- )
- page.form(".mb-0", method="post", action=resolve_url)[
- form.csrf_input(),
- htpy.input(type="hidden", name="variant", value="tabulate"),
- htpy.button(".btn.btn-success", type="submit")[
+ if release.vote_manual:
+ resolve_url = util.as_url(
+ manual.resolve_selected,
+ project_key=release.project.key,
+ version_key=release.version,
+ )
+ else:
+ resolve_url = util.as_url(
+ resolve.selected,
+ project_key=release.project.key,
+ version_key=release.version,
+ )
+ page.div[
+ htpy.a(".btn.btn-success", href=resolve_url)[
htpy.i(".bi.bi-clipboard-check.me-1"),
"Resolve vote",
],
diff --git a/atr/models/api.py b/atr/models/api.py
index 05f15fa6..70bbf62c 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -415,7 +415,7 @@ class PublisherVoteResolveArgs(schema.Strict):
publisher: str = schema.example("user")
jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
version: safe.VersionKey = schema.example("0.0.1")
- resolution: Literal["passed", "failed"] = schema.example("passed")
+ resolution: Literal["passed", "failed", "cancelled"] =
schema.example("passed")
class PublisherVoteResolveResults(schema.Strict):
@@ -600,7 +600,7 @@ class UsersListResults(schema.Strict):
class VoteResolveArgs(schema.Strict):
project: safe.ProjectKey = schema.example("example")
version: safe.VersionKey = schema.example("0.0.1")
- resolution: Literal["passed", "failed"] = schema.example("passed")
+ resolution: Literal["passed", "failed", "cancelled"] =
schema.example("passed")
class VoteResolveResults(schema.Strict):
diff --git a/atr/post/manual.py b/atr/post/manual.py
index 22ca401a..34cc39f3 100644
--- a/atr/post/manual.py
+++ b/atr/post/manual.py
@@ -69,6 +69,9 @@ async def resolve_selected(
case "Failed":
vote_result = "failed"
destination = get.compose.selected
+ case "Cancelled":
+ vote_result = "cancelled"
+ destination = get.compose.selected
async with storage.write_as_project_committee_member(project_key) as wacm:
success_message = await wacm.vote.resolve_manually(project_key,
version_key, vote_result)
diff --git a/atr/post/resolve.py b/atr/post/resolve.py
index 4ab38ff2..51b74446 100644
--- a/atr/post/resolve.py
+++ b/atr/post/resolve.py
@@ -20,16 +20,10 @@ from typing import Literal
import quart
import atr.blueprints.post as post
-import atr.db.interaction as interaction
-import atr.form
import atr.get as get
import atr.models.safe as safe
-import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
-import atr.tabulate as tabulate
-import atr.template as template
-import atr.util as util
import atr.web as web
@@ -39,33 +33,27 @@ async def selected(
_resolve: Literal["resolve"],
project_key: safe.ProjectKey,
version_key: safe.VersionKey,
- resolve_form: shared.resolve.ResolveForm,
-) -> web.WerkzeugResponse | str:
+ submit_form: shared.resolve.SubmitForm,
+) -> web.WerkzeugResponse:
"""
URL: /resolve/<project_key>/<version_key>
"""
- match resolve_form:
- case shared.resolve.SubmitForm() as submit_form:
- return await _submit(session, submit_form, project_key,
version_key)
-
- case shared.resolve.TabulateForm():
- return await _tabulate(session, project_key, version_key)
-
-
-async def _submit(
- session: web.Committer,
- submit_form: shared.resolve.SubmitForm,
- project_key: safe.ProjectKey,
- version_key: safe.VersionKey,
-) -> web.WerkzeugResponse:
email_body = submit_form.email_body
vote_result = submit_form.vote_result
+ match vote_result:
+ case "Passed":
+ writer_result = "passed"
+ case "Failed":
+ writer_result = "failed"
+ case "Cancelled":
+ writer_result = "cancelled"
+
async with storage.write_as_project_committee_member(project_key) as wacm:
_release, voting_round, success_message, error_message = await
wacm.vote.resolve(
project_key,
version_key,
- "passed" if (vote_result == "Passed") else "failed",
+ writer_result,
session.fullname,
email_body,
)
@@ -79,85 +67,9 @@ async def _submit(
destination = get.finish.selected
case "Failed", _:
destination = get.compose.selected
+ case "Cancelled", _:
+ destination = get.compose.selected
return await session.redirect(
destination, project_key=str(project_key),
version_key=str(version_key), success=success_message
)
-
-
-async def _tabulate(session: web.Committer, project_key: safe.ProjectKey,
version_key: safe.VersionKey) -> str:
- asf_uid = session.uid
- full_name = session.fullname
-
- release = await session.release(
- project_key,
- version_key,
- phase=sql.ReleasePhase.RELEASE_CANDIDATE,
- with_release_policy=True,
- with_project_release_policy=True,
- )
- if release.vote_manual:
- raise RuntimeError("This page is for tabulated votes only")
-
- details = None
- committee = None
- thread_id = None
- archive_url = None
- fetch_error = None
-
- latest_vote_task = await interaction.release_latest_vote_task(release)
- if latest_vote_task is not None:
- task_mid = interaction.task_mid_get(latest_vote_task)
- task_recipient = interaction.task_recipient_get(latest_vote_task)
- if task_mid:
- async with storage.write(session) as write:
- wagp = write.as_general_public()
- archive_url = await
wagp.cache.get_message_archive_url(task_mid, task_recipient)
-
- if archive_url:
- thread_id = archive_url.split("/")[-1]
- if thread_id:
- try:
- committee = await tabulate.vote_committee(thread_id, release)
- except util.FetchError as e:
- fetch_error = f"Failed to fetch thread metadata: {e}"
- else:
- details = await tabulate.vote_details(committee, thread_id,
release)
- else:
- fetch_error = "The vote thread could not yet be found."
- else:
- fetch_error = "The vote thread could not yet be found."
-
- defaults = {}
- if (committee is not None) and (details is not None) and (thread_id is not
None):
- defaults["email_body"] = tabulate.vote_resolution(
- committee,
- release,
- details.votes,
- details.summary,
- details.passed,
- details.outcome,
- full_name,
- asf_uid,
- thread_id,
- )
- defaults["vote_result"] = "passed" if details.passed else "failed"
-
- resolve_form = atr.form.render(
- model_cls=shared.resolve.SubmitForm,
- action=util.as_url(selected, project_key=release.project.key,
version_key=release.version),
- submit_label="Resolve vote",
- textarea_rows=24,
- defaults=defaults,
- )
-
- return await template.render(
- "resolve-tabulated.html",
- release=release,
- tabulated_votes=details.votes if (details is not None) else {},
- summary=details.summary if (details is not None) else {},
- outcome=details.outcome if (details is not None) else "",
- resolve_form=resolve_form,
- fetch_error=fetch_error,
- archive_url=archive_url,
- )
diff --git a/atr/shared/manual.py b/atr/shared/manual.py
index a337a52e..bbca494a 100644
--- a/atr/shared/manual.py
+++ b/atr/shared/manual.py
@@ -23,7 +23,7 @@ import atr.form as form
class ResolveVoteForm(form.Form):
- vote_result: Literal["Passed", "Failed"] = form.label("Vote result",
widget=form.Widget.RADIO)
+ vote_result: Literal["Passed", "Failed", "Cancelled"] = form.label("Vote
result", widget=form.Widget.RADIO)
vote_thread_url: str = form.label("Vote thread URL")
vote_result_url: str = form.label("Vote result URL")
diff --git a/atr/shared/resolve.py b/atr/shared/resolve.py
index 4de6da46..03d25a71 100644
--- a/atr/shared/resolve.py
+++ b/atr/shared/resolve.py
@@ -15,25 +15,11 @@
# specific language governing permissions and limitations
# under the License.
-from typing import Annotated, Literal
+from typing import Literal
import atr.form as form
-type SUBMIT = Literal["submit"]
-type TABULATE = Literal["tabulate"]
-
class SubmitForm(form.Form):
- variant: SUBMIT = form.value(SUBMIT)
email_body: str = form.label("Email body", widget=form.Widget.TEXTAREA)
- vote_result: Literal["Passed", "Failed"] = form.label("Vote result",
widget=form.Widget.RADIO)
-
-
-class TabulateForm(form.Empty):
- variant: TABULATE = form.value(TABULATE)
-
-
-type ResolveForm = Annotated[
- SubmitForm | TabulateForm,
- form.DISCRIMINATOR,
-]
+ vote_result: Literal["Passed", "Failed", "Cancelled"] = form.label("Vote
result", widget=form.Widget.RADIO)
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 5eaf8418..e0a9e7a7 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -243,7 +243,7 @@ class CommitteeMember(CommitteeParticipant):
self,
project_key: safe.ProjectKey,
version_key: safe.VersionKey,
- vote_result: Literal["passed", "failed"],
+ vote_result: Literal["passed", "failed", "cancelled"],
asf_fullname: str,
resolution_body: str,
) -> tuple[sql.Release, int | None, str, str | None]:
@@ -283,7 +283,7 @@ class CommitteeMember(CommitteeParticipant):
self,
project_key: safe.ProjectKey,
version_key: safe.VersionKey,
- vote_result: Literal["passed", "failed"],
+ vote_result: Literal["passed", "failed", "cancelled"],
) -> str:
release = await self.__data.release(
key=sql.release_key(str(project_key), str(version_key)),
@@ -301,24 +301,26 @@ class CommitteeMember(CommitteeParticipant):
if (release.project.committee is not None) and
release.project.committee.is_podling:
raise ValueError("Podling releases require the standard two round
vote process")
- if vote_result == "passed":
- release.phase = sql.ReleasePhase.RELEASE_PREVIEW
- release.vote_resolved = datetime.datetime.now(datetime.UTC)
- await self.__data.commit()
- await self.__data.refresh(release)
- success_message = "Vote marked as passed"
-
- description = "Create a preview revision from the last candidate
draft"
- await self.__write_as.revision.create_revision_with_quarantine(
- project_key, release.safe_version_key, self.__asf_uid,
description=description
- )
- else:
- release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
- # The vote_resolved property refers to when the vote succeeded only
- release.vote_resolved = None
- await self.__data.commit()
- await self.__data.refresh(release)
- success_message = "Vote marked as failed"
+ match vote_result:
+ case "passed":
+ release.phase = sql.ReleasePhase.RELEASE_PREVIEW
+ release.vote_resolved = datetime.datetime.now(datetime.UTC)
+ await self.__data.commit()
+ await self.__data.refresh(release)
+ success_message = "Vote marked as passed"
+
+ description = "Create a preview revision from the last
candidate draft"
+ await self.__write_as.revision.create_revision_with_quarantine(
+ project_key, release.safe_version_key, self.__asf_uid,
description=description
+ )
+ case "failed" | "cancelled":
+ release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+ # The vote_resolved property refers to when the vote succeeded
only
+ release.vote_resolved = None
+ release.podling_thread_id = None
+ await self.__data.commit()
+ await self.__data.refresh(release)
+ success_message = f"Vote marked as {vote_result}"
self.__write_as.append_to_audit_log(
asf_uid=self.__asf_uid,
@@ -333,7 +335,7 @@ class CommitteeMember(CommitteeParticipant):
project_key: safe.ProjectKey,
release: sql.Release,
voting_round: int | None,
- vote_result: Literal["passed", "failed"],
+ vote_result: Literal["passed", "failed", "cancelled"],
latest_vote_task: sql.Task,
asf_fullname: str,
resolution_body: str,
@@ -411,9 +413,10 @@ class CommitteeMember(CommitteeParticipant):
release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
# The vote_resolved property refers to when the vote succeeded only
release.vote_resolved = None
+ release.podling_thread_id = None
await self.__data.commit()
await self.__data.refresh(release)
- success_message = "Vote marked as failed"
+ success_message = f"Vote marked as {vote_result}"
error_message = await self.send_resolution(
release,
diff --git a/atr/templates/about.html b/atr/templates/about.html
index 8ad7a377..b5433df2 100644
--- a/atr/templates/about.html
+++ b/atr/templates/about.html
@@ -124,7 +124,7 @@
<tr>
<th class="step">Resolve vote</th>
<td class="ui">
- <code>/resolve/tabulated/P/V</code>
+ <code>/resolve/P/V</code>
</td>
<td class="cli">
<code>atr vote resolve P V passed</code>
diff --git a/atr/templates/check-selected-release-info.html
b/atr/templates/check-selected-release-info.html
index 859f95d2..b8e45483 100644
--- a/atr/templates/check-selected-release-info.html
+++ b/atr/templates/check-selected-release-info.html
@@ -124,15 +124,8 @@
<a href="{{ as_url(get.manual.resolve_selected,
project_key=release.project.key, version_key=release.version) }}"
class="btn btn-success"><i class="bi bi-clipboard-check
me-1"></i> Resolve vote</a>
{% elif can_resolve and resolve_form %}
- <form action="{{ as_url(post.resolve.selected,
project_key=release.project.key, version_key=release.version) }}"
- method="post"
- class="mb-0">
- {{ csrf_input|safe }}
- <input type="hidden" name="variant" value="tabulate" />
- <button type="submit" class="btn btn-success">
- <i class="bi bi-clipboard-check me-1"></i> Resolve vote
- </button>
- </form>
+ <a href="{{ as_url(get.resolve.selected,
project_key=release.project.key, version_key=release.version) }}"
+ class="btn btn-success"><i class="bi bi-clipboard-check
me-1"></i> Resolve vote</a>
{% endif %}
{% endif %}
</div>
diff --git a/atr/templates/tutorial.html b/atr/templates/tutorial.html
index 7ee8ee09..c2497ed4 100644
--- a/atr/templates/tutorial.html
+++ b/atr/templates/tutorial.html
@@ -87,7 +87,7 @@
alt="Illustration: Review the voting thread, and cast your own vote."
/>
</p>
<p>
- When the voting period concludes and the mandate is clear, record the
outcome using the vote resolution form, accessed by pressing the
<strong>Resolve vote</strong> button. Submitting this form also sends the
outcome of the vote to the mailing list. A successful vote promotes the release
candidate to the next phase; a failed vote returns it to the <em>Compose</em>
phase.
+ When the voting period concludes and the mandate is clear, record the
outcome using the vote resolution form, accessed by pressing the
<strong>Resolve vote</strong> button. Submitting this form also sends the
outcome of the vote to the mailing list. A successful vote promotes the release
candidate to the next phase; a failed or cancelled vote returns it to the
<em>Compose</em> phase.
</p>
<p>
<img src="{{ static_url('png/tutorial-vote-04.png') }}"
diff --git a/tests/unit/test_vote_resolution.py
b/tests/unit/test_vote_resolution.py
new file mode 100644
index 00000000..d1e8cb1a
--- /dev/null
+++ b/tests/unit/test_vote_resolution.py
@@ -0,0 +1,428 @@
+# 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 unittest.mock as mock
+from types import SimpleNamespace
+
+import pytest
+import quart
+
+import atr.get.manual as manual
+import atr.get.resolve as resolve
+import atr.get.vote
+import atr.htm as htm
+import atr.models.results as results
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage.writers.vote as vote
+
+
[email protected]
+def render_app() -> quart.Quart:
+ app = quart.Quart(__name__)
+ app.secret_key = "test-secret"
+ app.config["TESTING"] = True
+ return app
+
+
+def test_automatic_vote_resolve_section_links_to_standard_resolve(monkeypatch:
pytest.MonkeyPatch) -> None:
+ """Non-manual vote releases link to the standard resolution page."""
+
+ def fake_as_url(endpoint, **kwargs) -> str:
+ if endpoint is manual.resolve_selected:
+ return
f"/manual/resolve/{kwargs['project_key']}/{kwargs['version_key']}"
+ if endpoint is resolve.selected:
+ return f"/resolve/{kwargs['project_key']}/{kwargs['version_key']}"
+ raise AssertionError(f"Unexpected endpoint: {endpoint}")
+
+ monkeypatch.setattr(atr.get.vote.util, "as_url", fake_as_url)
+
+ page = htm.Block()
+ release = SimpleNamespace(
+ vote_manual=False,
+ project=SimpleNamespace(key="project"),
+ version="1.0.0",
+ )
+
+ atr.get.vote._render_section_resolve(page, release,
atr.get.vote.UserCategory.COMMITTER_RM)
+
+ html = str(page.collect())
+ assert 'href="/resolve/project/1.0.0"' in html
+ assert 'href="/manual/resolve/project/1.0.0"' not in html
+
+
[email protected]
+async def test_cancelled_resolve_release_clears_podling_thread_id() -> None:
+ """Candidate cancelled clears podling_thread_id."""
+ data = _mock_data()
+ write_as = _mock_write_as()
+ writer = _writer_with_mocks(data, write_as)
+
+ release = _candidate_release(podling_thread_id="abc123")
+ data.merge = mock.AsyncMock(return_value=release)
+
+ await writer.resolve_release(
+ _project_key(),
+ release,
+ None,
+ "cancelled",
+ _latest_vote_task(),
+ "Chair",
+ "The vote has been cancelled.",
+ )
+
+ assert release.podling_thread_id is None
+
+
[email protected]
+async def test_cancelled_resolve_release_produces_correct_message() -> None:
+ """Candidate cancelled produces 'Vote marked as cancelled'."""
+ data = _mock_data()
+ write_as = _mock_write_as()
+ writer = _writer_with_mocks(data, write_as)
+
+ release = _candidate_release()
+ data.merge = mock.AsyncMock(return_value=release)
+
+ _release, _round, success, _error = await writer.resolve_release(
+ _project_key(),
+ release,
+ None,
+ "cancelled",
+ _latest_vote_task(),
+ "Chair",
+ "The vote has been cancelled.",
+ )
+
+ assert success == "Vote marked as cancelled"
+
+
[email protected]
+async def test_cancelled_resolve_release_returns_to_draft() -> None:
+ """Candidate cancelled returns the release to draft and does not create a
preview revision."""
+ data = _mock_data()
+ write_as = _mock_write_as()
+ writer = _writer_with_mocks(data, write_as)
+
+ release = _candidate_release()
+ data.merge = mock.AsyncMock(return_value=release)
+
+ _release, _round, success, _error = await writer.resolve_release(
+ _project_key(),
+ release,
+ None,
+ "cancelled",
+ _latest_vote_task(),
+ "Chair",
+ "The vote has been cancelled.",
+ )
+
+ assert release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+ assert release.vote_resolved is None
+ assert success == "Vote marked as cancelled"
+ write_as.revision.create_revision_with_quarantine.assert_not_awaited()
+
+
[email protected]
+async def test_failed_resolve_release_clears_podling_thread_id() -> None:
+ """Candidate failed also clears podling_thread_id (bug fix)."""
+ data = _mock_data()
+ write_as = _mock_write_as()
+ writer = _writer_with_mocks(data, write_as)
+
+ release = _candidate_release(podling_thread_id="abc123")
+ data.merge = mock.AsyncMock(return_value=release)
+
+ await writer.resolve_release(
+ _project_key(),
+ release,
+ None,
+ "failed",
+ _latest_vote_task(),
+ "Chair",
+ "The vote has failed.",
+ )
+
+ assert release.podling_thread_id is None
+ assert release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+ assert release.vote_resolved is None
+
+
[email protected]
+async def
test_manual_cancelled_returns_to_draft_and_clears_podling_thread_id() -> None:
+ """Manual cancelled returns the release to draft and clears
podling_thread_id."""
+ data = _mock_data()
+ write_as = _mock_write_as()
+ writer = _writer_with_mocks(data, write_as)
+
+ release = _manual_candidate_release(podling_thread_id="thread123")
+ query = mock.MagicMock()
+ query.demand = mock.AsyncMock(return_value=release)
+ data.release = mock.MagicMock(return_value=query)
+
+ success = await writer.resolve_manually(
+ _project_key(),
+ _version_key(),
+ "cancelled",
+ )
+
+ assert release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+ assert release.vote_resolved is None
+ assert release.podling_thread_id is None
+ assert success == "Vote marked as cancelled"
+ write_as.revision.create_revision_with_quarantine.assert_not_awaited()
+
+
[email protected]
+async def test_manual_failed_returns_to_draft_and_clears_podling_thread_id()
-> None:
+ """Manual failed returns the release to draft and clears podling_thread_id
(bug fix)."""
+ data = _mock_data()
+ write_as = _mock_write_as()
+ writer = _writer_with_mocks(data, write_as)
+
+ release = _manual_candidate_release(podling_thread_id="thread123")
+ query = mock.MagicMock()
+ query.demand = mock.AsyncMock(return_value=release)
+ data.release = mock.MagicMock(return_value=query)
+
+ success = await writer.resolve_manually(
+ _project_key(),
+ _version_key(),
+ "failed",
+ )
+
+ assert release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+ assert release.vote_resolved is None
+ assert release.podling_thread_id is None
+ assert success == "Vote marked as failed"
+
+
[email protected]
+async def test_manual_passed_creates_preview_revision() -> None:
+ """Manual passed promotes to preview and creates a revision."""
+ data = _mock_data()
+ write_as = _mock_write_as()
+ writer = _writer_with_mocks(data, write_as)
+
+ release = _manual_candidate_release()
+ query = mock.MagicMock()
+ query.demand = mock.AsyncMock(return_value=release)
+ data.release = mock.MagicMock(return_value=query)
+
+ success = await writer.resolve_manually(
+ _project_key(),
+ _version_key(),
+ "passed",
+ )
+
+ assert release.phase == sql.ReleasePhase.RELEASE_PREVIEW
+ assert release.vote_resolved is not None
+ assert success == "Vote marked as passed"
+ write_as.revision.create_revision_with_quarantine.assert_awaited_once()
+
+
[email protected]
+async def test_manual_resolve_page_explains_cancellation_notice_url(
+ monkeypatch: pytest.MonkeyPatch, render_app: quart.Quart
+) -> None:
+ """Manual resolve page explains which thread URL to provide when
cancelling."""
+
+ monkeypatch.setattr(
+ manual.util,
+ "as_url",
+ lambda _endpoint, **_kwargs: "/vote/project/1.0.0",
+ )
+
+ release = SimpleNamespace(
+ project=SimpleNamespace(key="project"),
+ version="1.0.0",
+ short_display_name="Project 1.0.0",
+ )
+
+ async with
render_app.test_request_context("/manual/resolve/project/1.0.0"):
+ html = str(manual._render_resolve_page(release))
+
+ assert "manual vote resolution" in html
+ assert "where you posted the result" in html
+ assert "cancellation notice" in html
+
+
+def test_manual_vote_resolve_section_links_to_manual_resolve(monkeypatch:
pytest.MonkeyPatch) -> None:
+ """Manual vote releases link to the manual resolution page."""
+
+ def fake_as_url(endpoint, **kwargs) -> str:
+ if endpoint is manual.resolve_selected:
+ return
f"/manual/resolve/{kwargs['project_key']}/{kwargs['version_key']}"
+ if endpoint is resolve.selected:
+ return f"/resolve/{kwargs['project_key']}/{kwargs['version_key']}"
+ raise AssertionError(f"Unexpected endpoint: {endpoint}")
+
+ monkeypatch.setattr(atr.get.vote.util, "as_url", fake_as_url)
+
+ page = htm.Block()
+ release = SimpleNamespace(
+ vote_manual=True,
+ project=SimpleNamespace(key="project"),
+ version="1.0.0",
+ )
+
+ atr.get.vote._render_section_resolve(page, release,
atr.get.vote.UserCategory.COMMITTER_RM)
+
+ html = str(page.collect())
+ assert 'href="/manual/resolve/project/1.0.0"' in html
+ assert 'href="/resolve/project/1.0.0"' not in html
+
+
[email protected]
+async def test_send_resolution_cancelled_builds_cancelled_subject() -> None:
+ """send_resolution accepts cancelled and builds a CANCELLED subject."""
+ data = _mock_data()
+ writer = _writer_with_data(data)
+ latest_vote_task = _latest_vote_task()
+ release = SimpleNamespace(
+ project=SimpleNamespace(
+ key="project",
+ display_name="Project",
+ ),
+ version="1.0.0",
+ )
+
+ error = await writer.send_resolution(
+ release,
+ "cancelled",
+ "The vote has been cancelled.",
+ "chair",
+ "Project Chair",
+ latest_vote_task,
+ )
+
+ assert error is None
+ data.add_all.assert_called_once()
+ queued_task = data.add_all.call_args.args[0][0]
+ assert "CANCELLED" in queued_task.task_args["subject"]
+ assert queued_task.task_args["email_to"] == "[email protected]"
+ assert queued_task.task_args["email_cc"] == ["[email protected]"]
+ assert queued_task.task_args["email_bcc"] ==
["[email protected]"]
+
+
+def _candidate_release(podling_thread_id: str | None = None) ->
SimpleNamespace:
+ return SimpleNamespace(
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ vote_resolved=datetime.datetime.now(datetime.UTC),
+ podling_thread_id=podling_thread_id,
+ version="1.0.0",
+ latest_revision_number="00001",
+ committee=SimpleNamespace(
+ key="project",
+ display_name="Project",
+ is_podling=False,
+ ),
+ project=SimpleNamespace(
+ key="project",
+ display_name="Project",
+ short_display_name="Project",
+ committee=SimpleNamespace(
+ key="project",
+ display_name="Project",
+ is_podling=False,
+ ),
+ ),
+ safe_key="project-1.0.0",
+ safe_project_key="project",
+ safe_version_key="1.0.0",
+ safe_latest_revision_number="00001",
+ key="project-1.0.0",
+ )
+
+
+def _latest_vote_task() -> SimpleNamespace:
+ return SimpleNamespace(
+ result=results.VoteInitiate(
+ kind="vote_initiate",
+ message="Vote announcement email sent successfully",
+ email_to="[email protected]",
+ vote_end="2026-03-31 12:00:00 UTC",
+ subject="[VOTE] Release project 1.0.0",
+ mid="[email protected]",
+ mail_send_warnings=[],
+ ),
+ task_args={
+ "email_to": "[email protected]",
+ "email_cc": ["[email protected]"],
+ "email_bcc": ["[email protected]"],
+ },
+ )
+
+
+def _manual_candidate_release(podling_thread_id: str | None = None) ->
SimpleNamespace:
+ return SimpleNamespace(
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ vote_manual=True,
+ vote_started=datetime.datetime.now(datetime.UTC),
+ vote_resolved=datetime.datetime.now(datetime.UTC),
+ podling_thread_id=podling_thread_id,
+ version="1.0.0",
+ safe_version_key="1.0.0",
+ project=SimpleNamespace(
+ key="project",
+ display_name="Project",
+ committee=SimpleNamespace(
+ key="project",
+ is_podling=False,
+ ),
+ ),
+ )
+
+
+def _mock_data() -> mock.MagicMock:
+ data = mock.MagicMock()
+ data.commit = mock.AsyncMock()
+ data.flush = mock.AsyncMock()
+ data.merge = mock.AsyncMock()
+ data.refresh = mock.AsyncMock()
+ return data
+
+
+def _mock_write_as() -> mock.MagicMock:
+ write_as = mock.MagicMock()
+ write_as.append_to_audit_log = mock.MagicMock()
+ write_as.revision.create_revision_with_quarantine = mock.AsyncMock()
+ return write_as
+
+
+def _project_key() -> safe.ProjectKey:
+ return safe.ProjectKey("project")
+
+
+def _version_key() -> safe.VersionKey:
+ return safe.VersionKey("1.0.0")
+
+
+def _writer_with_data(data: mock.MagicMock) -> vote.CommitteeMember:
+ writer = object.__new__(vote.CommitteeMember)
+ writer._CommitteeMember__data = data
+ return writer
+
+
+def _writer_with_mocks(data: mock.MagicMock, write_as: mock.MagicMock) ->
vote.CommitteeMember:
+ writer = object.__new__(vote.CommitteeMember)
+ writer._CommitteeMember__data = data
+ writer._CommitteeMember__write_as = write_as
+ writer._CommitteeMember__asf_uid = "chair"
+ writer._CommitteeMember__committee_key = "project"
+ return writer
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]