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]
