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

commit 68434f0c72855d748b76dfa62b1710b4c2516845
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 +-
 playwright/test.py                             |  16 +-
 tests/unit/test_vote_resolution.py             | 428 +++++++++++++++++++++++++
 15 files changed, 610 insertions(+), 173 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/playwright/test.py b/playwright/test.py
index 778ffdbe..28f23cd4 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -225,16 +225,16 @@ def lifecycle_05_resolve_vote(page: Page, credentials: 
Credentials, version_key:
     if not link_found:
         logging.warning("Vote thread link not detected after 15s, proceeding 
anyway")
 
-    logging.info("Locating the 'Resolve vote' button")
-    tabulate_form_locator = 
page.locator(f'form[action="/resolve/{TEST_PROJECT}/{version_key}"]')
-    expect(tabulate_form_locator).to_be_visible()
+    logging.info("Locating the 'Resolve vote' link")
+    resolve_link_locator = page.locator(
+        f'a[href="/resolve/{TEST_PROJECT}/{esc_id(version_key)}"]', 
has_text="Resolve vote"
+    )
+    expect(resolve_link_locator).to_be_visible()
 
-    tabulate_button_locator = tabulate_form_locator.get_by_role("button", 
name="Resolve vote")
-    expect(tabulate_button_locator).to_be_enabled()
-    logging.info("Clicking 'Tabulate votes' button")
-    tabulate_button_locator.click()
+    logging.info("Clicking 'Resolve vote' link")
+    resolve_link_locator.click()
 
-    logging.info("Waiting for navigation to tabulated votes page")
+    logging.info("Waiting for navigation to resolve page")
     wait_for_path(page, f"/resolve/{TEST_PROJECT}/{version_key}")
 
     logging.info("Locating the resolve vote form on the tabulated votes page")
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]


Reply via email to