This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new def9548  Copy and move some vote code, mostly into the storage 
interface
def9548 is described below

commit def95483d05b1e589f12dd989a4bef67fd2160b4
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Sep 5 16:22:50 2025 +0100

    Copy and move some vote code, mostly into the storage interface
---
 atr/blueprints/api/api.py   |  12 ++-
 atr/db/interaction.py       |  76 +++++++++++++++-
 atr/routes/resolve.py       | 176 ++++++++++++++++---------------------
 atr/routes/vote.py          |  82 ++---------------
 atr/routes/voting.py        |   2 +-
 atr/storage/writers/vote.py | 208 +++++++++++++++++++++++++++++++++++++++++++-
 atr/util.py                 |  23 +++++
 7 files changed, 391 insertions(+), 188 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index b89e044..81d3149 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -41,9 +41,7 @@ import atr.models.sql as sql
 import atr.revision as revision
 import atr.routes as routes
 import atr.routes.announce as announce
-import atr.routes.resolve as resolve
 import atr.routes.start as start
-import atr.routes.vote as vote
 import atr.storage as storage
 import atr.storage.outcome as outcome
 import atr.storage.types as types
@@ -1109,7 +1107,7 @@ async def vote_resolve(data: models.api.VoteResolveArgs) 
-> DictResponse:
     asf_uid = _jwt_asf_uid()
     async with storage.write(asf_uid) as write:
         wacm = await write.as_project_committee_member(data.project)
-        await wacm.vote.resolve(data.project, data.version, data.resolution)
+        await wacm.vote.resolve_api(data.project, data.version, 
data.resolution)
     return models.api.VoteResolveResults(
         endpoint="/vote/resolve",
         success=True,
@@ -1134,7 +1132,7 @@ async def vote_start(data: models.api.VoteStartArgs) -> 
DictResponse:
     try:
         async with storage.write(asf_uid) as write:
             wacm = await write.as_project_committee_member(data.project)
-            task = await wacm.vote.start(
+            task = await wacm.vote.start_api(
                 data.project,
                 data.version,
                 data.revision,
@@ -1168,11 +1166,11 @@ async def vote_tabulate(data: 
models.api.VoteTabulateArgs) -> DictResponse:
             exceptions.NotFound(f"Release {release_name} not found"),
         )
 
-    latest_vote_task = await resolve.release_latest_vote_task(release)
+    latest_vote_task = await interaction.release_latest_vote_task(release)
     if latest_vote_task is None:
         raise exceptions.NotFound("No vote task found")
-    task_mid = resolve.task_mid_get(latest_vote_task)
-    archive_url = await vote.task_archive_url_cached(task_mid)
+    task_mid = interaction.task_mid_get(latest_vote_task)
+    archive_url = await interaction.task_archive_url_cached(task_mid)
     if archive_url is None:
         raise exceptions.NotFound("No archive URL found")
     thread_id = archive_url.split("/")[-1]
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 9bf9978..4709eb5 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -20,7 +20,7 @@ import datetime
 import enum
 import pathlib
 from collections.abc import AsyncGenerator, Sequence
-from typing import Any
+from typing import Any, Final
 
 import aiofiles.os
 import aioshutil
@@ -33,11 +33,26 @@ import atr.db as db
 import atr.jwtoken as jwtoken
 import atr.ldap as ldap
 import atr.log as log
+import atr.models.results as results
 import atr.models.sql as sql
 import atr.registry as registry
 import atr.user as user
 import atr.util as util
 
+# TEST_MID: Final[str | None] = 
"CAH5JyZo8QnWmg9CwRSwWY=givhxw4nilyenjo71fkdk81j5...@mail.gmail.com"
+TEST_MID: Final[str | None] = None
+_THREAD_URLS_FOR_DEVELOPMENT: Final[dict[str, str]] = {
+    "CAH5JyZo8QnWmg9CwRSwWY=givhxw4nilyenjo71fkdk81j5...@mail.gmail.com": 
"https://lists.apache.org/thread/z0o7xnjnyw2o886rxvvq2ql4rdfn754w";,
+    "[email protected]": 
"https://lists.apache.org/thread/619hn4x796mh3hkk3kxg1xnl48dy2s64";,
+    "CAA9ykM+bMPNk=bof9hj0o+mjn1igppoj+pkdzhcam0ddvi+...@mail.gmail.com": 
"https://lists.apache.org/thread/x0m3p2xqjvflgtkb6oxqysm36cr9l5mg";,
+    "CAFHDsVzgtfboqYF+a3owaNf+55MUiENWd3g53mU4rD=whkx...@mail.gmail.com": 
"https://lists.apache.org/thread/brj0k3g8pq63g8f7xhmfg2rbt1240nts";,
+    "camomwmrvktqk7k2-otztreo0jjxzo2g5ynw3gsoks_pxwpz...@mail.gmail.com": 
"https://lists.apache.org/thread/y5rqp5qk6dmo08wlc3g20n862hznc9m8";,
+    "CANVKqzfLYj6TAVP_Sfsy5vFbreyhKskpRY-vs=f7aled+rl...@mail.gmail.com": 
"https://lists.apache.org/thread/oy969lhh6wlzd51ovckn8fly9rvpopwh";,
+    "cah4123zwgtkwszheu7qnmbyla-yvykz2w+djh_uchpmuzaa...@mail.gmail.com": 
"https://lists.apache.org/thread/7111mqyc25sfqxm6bf4ynwhs0bk0r4ys";,
+    "CADL1oArKFcXvNb1MJfjN=10-yrfkxgpltrurdmm1r7ygatk...@mail.gmail.com": 
"https://lists.apache.org/thread/d7119h2qm7jrd5zsbp8ghkk0lpvnnxnw";,
+    "[email protected]": 
"https://lists.apache.org/thread/gzjd2jv7yod5sk5rgdf4x33g5l3fdf5o";,
+}
+
 
 class ApacheUserMissingError(RuntimeError):
     def __init__(self, message: str, fingerprint: str | None, primary_uid: str 
| None) -> None:
@@ -214,6 +229,24 @@ async def release_delete(
     await _delete_release_data_filesystem(release_dir, release_name)
 
 
+async def release_latest_vote_task(release: sql.Release) -> sql.Task | None:
+    """Find the most recent VOTE_INITIATE task for this release."""
+    via = sql.validate_instrumented_attribute
+    async with db.session() as data:
+        query = (
+            sqlmodel.select(sql.Task)
+            .where(sql.Task.project_name == release.project_name)
+            .where(sql.Task.version_name == release.version)
+            .where(sql.Task.task_type == sql.TaskType.VOTE_INITIATE)
+            .where(via(sql.Task.status).notin_([sql.TaskStatus.QUEUED, 
sql.TaskStatus.ACTIVE]))
+            .where(via(sql.Task.result).is_not(None))
+            .order_by(via(sql.Task.added).desc())
+            .limit(1)
+        )
+        task = (await data.execute(query)).scalar_one_or_none()
+        return task
+
+
 async def releases_by_phase(project: sql.Project, phase: sql.ReleasePhase) -> 
list[sql.Release]:
     """Get the releases for the project by phase."""
 
@@ -246,6 +279,47 @@ async def releases_in_progress(project: sql.Project) -> 
list[sql.Release]:
     return drafts + cands + prevs
 
 
+async def task_archive_url_cached(task_mid: str | None) -> str | None:
+    if task_mid in _THREAD_URLS_FOR_DEVELOPMENT:
+        return _THREAD_URLS_FOR_DEVELOPMENT[task_mid]
+
+    if task_mid is None:
+        return None
+    if "@" not in task_mid:
+        return None
+
+    async with db.session() as data:
+        url = await data.ns_text_get(
+            "mid-url-cache",
+            task_mid,
+        )
+        if url is not None:
+            return url
+
+    url = await util.task_archive_url(task_mid)
+    if url is not None:
+        await data.ns_text_set(
+            "mid-url-cache",
+            task_mid,
+            url,
+        )
+
+    return url
+
+
+def task_mid_get(latest_vote_task: sql.Task) -> str | None:
+    if util.is_dev_environment():
+        import atr.db.interaction as interaction
+
+        return interaction.TEST_MID
+    # TODO: Improve this
+
+    result = latest_vote_task.result
+    if not isinstance(result, results.VoteInitiate):
+        return None
+    return result.mid
+
+
 async def tasks_ongoing(project_name: str, version_name: str, revision_number: 
str | None = None) -> int:
     tasks = sqlmodel.select(sqlalchemy.func.count()).select_from(sql.Task)
     async with db.session() as data:
diff --git a/atr/routes/resolve.py b/atr/routes/resolve.py
index dca2403..2e56625 100644
--- a/atr/routes/resolve.py
+++ b/atr/routes/resolve.py
@@ -17,13 +17,12 @@
 
 
 import quart
-import sqlmodel
 import werkzeug.wrappers.response as response
 
 import atr.construct as construct
 import atr.db as db
+import atr.db.interaction as interaction
 import atr.forms as forms
-import atr.models.results as results
 import atr.models.sql as sql
 import atr.revision as revision
 import atr.routes as routes
@@ -66,24 +65,6 @@ class ResolveVoteManualForm(forms.Typed):
     submit = forms.submit("Resolve vote")
 
 
-async def release_latest_vote_task(release: sql.Release) -> sql.Task | None:
-    """Find the most recent VOTE_INITIATE task for this release."""
-    via = sql.validate_instrumented_attribute
-    async with db.session() as data:
-        query = (
-            sqlmodel.select(sql.Task)
-            .where(sql.Task.project_name == release.project_name)
-            .where(sql.Task.version_name == release.version)
-            .where(sql.Task.task_type == sql.TaskType.VOTE_INITIATE)
-            .where(via(sql.Task.status).notin_([sql.TaskStatus.QUEUED, 
sql.TaskStatus.ACTIVE]))
-            .where(via(sql.Task.result).is_not(None))
-            .order_by(via(sql.Task.added).desc())
-            .limit(1)
-        )
-        task = (await data.execute(query)).scalar_one_or_none()
-        return task
-
-
 @routes.committer("/resolve/manual/<project_name>/<version_name>")
 async def manual_selected(session: routes.CommitterSession, project_name: str, 
version_name: str) -> str:
     """Get the manual vote resolution page."""
@@ -135,19 +116,19 @@ async def manual_selected_post(
     await _committees_check(vote_thread_url, vote_result_url)
 
     async with db.session() as data:
-        async with data.begin():
-            release = await data.merge(release)
-            if vote_result == "passed":
-                release.phase = sql.ReleasePhase.RELEASE_PREVIEW
-                success_message = "Vote marked as passed"
-                description = "Create a preview revision from the last 
candidate draft"
-                async with revision.create_and_manage(
-                    project_name, release.version, session.uid, 
description=description
-                ) as _creating:
-                    pass
-            else:
-                release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
-                success_message = "Vote marked as failed"
+        release = await data.merge(release)
+        if vote_result == "passed":
+            release.phase = sql.ReleasePhase.RELEASE_PREVIEW
+            success_message = "Vote marked as passed"
+            description = "Create a preview revision from the last candidate 
draft"
+            async with revision.create_and_manage(
+                project_name, release.version, session.uid, 
description=description
+            ) as _creating:
+                pass
+        else:
+            release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+            success_message = "Vote marked as failed"
+        await data.commit()
     if vote_result == "passed":
         destination = finish.selected
     else:
@@ -178,7 +159,7 @@ async def submit_selected(
         is_podling = release.project.committee.is_podling
     podling_thread_id = release.podling_thread_id
 
-    latest_vote_task = await release_latest_vote_task(release)
+    latest_vote_task = await interaction.release_latest_vote_task(release)
     if latest_vote_task is None:
         raise RuntimeError("No vote task found, unable to send resolution 
message.")
     resolve_form = await ResolveVoteForm.create_form()
@@ -281,17 +262,6 @@ async def tabulated_selected_post(session: 
routes.CommitterSession, project_name
     )
 
 
-def task_mid_get(latest_vote_task: sql.Task) -> str | None:
-    if util.is_dev_environment():
-        return vote.TEST_MID
-    # TODO: Improve this
-
-    result = latest_vote_task.result
-    if not isinstance(result, results.VoteInitiate):
-        return None
-    return result.mid
-
-
 async def _committees_check(vote_thread_url: str, vote_result_url: str) -> 
None:
     if not vote_thread_url.startswith("https://lists.apache.org/thread/";):
         raise RuntimeError("Vote thread URL is not a valid Apache email thread 
URL")
@@ -338,63 +308,63 @@ async def _resolve_vote(
 
     # Update release status in the database
     async with db.session() as data:
-        async with data.begin():
-            # Attach the existing release to the session
-            release = await data.merge(release)
-            # Update the release phase based on vote result
-            extra_destination = None
-            if (voting_round == 1) and (vote_result == "passed"):
-                # This is the first podling vote, by the PPMC and not the 
Incubator PMC
-                # In this branch, we do not move to RELEASE_PREVIEW but keep 
everything the same
-                # We only set the podling_thread_id to the thread_id of the 
vote thread
-                # Then we automatically start the Incubator PMC vote
-                # TODO: Note on the resolve vote page that resolving the 
Project PPMC vote starts the Incubator PMC vote
-                task_mid = task_mid_get(latest_vote_task)
-                archive_url = await vote.task_archive_url_cached(task_mid)
-                if archive_url is None:
-                    await quart.flash("No archive URL found for podling vote", 
"error")
-                    return release, "Failure"
-                thread_id = archive_url.split("/")[-1]
-                release.podling_thread_id = thread_id
-                # incubator_vote_address = "[email protected]"
-                incubator_vote_address = util.USER_TESTS_ADDRESS
-                if not release.project.committee:
-                    raise ValueError("Project has no committee")
-                revision_number = release.latest_revision_number
-                if revision_number is None:
-                    raise ValueError("Release has no revision number")
-                await voting.start_vote(
-                    email_to=incubator_vote_address,
-                    permitted_recipients=[incubator_vote_address],
-                    project_name=release.project.name,
-                    version_name=release.version,
-                    selected_revision_number=revision_number,
-                    session=session,
-                    
vote_duration_choice=latest_vote_task.task_args["vote_duration"],
-                    subject_data=f"[VOTE] Release 
{release.project.display_name} {release.version}",
-                    body_data=await 
construct.start_vote_default(release.project.name),
-                    data=data,
-                    release=release,
-                    promote=False,
+        # Attach the existing release to the session
+        release = await data.merge(release)
+        # Update the release phase based on vote result
+        extra_destination = None
+        if (voting_round == 1) and (vote_result == "passed"):
+            # This is the first podling vote, by the PPMC and not the 
Incubator PMC
+            # In this branch, we do not move to RELEASE_PREVIEW but keep 
everything the same
+            # We only set the podling_thread_id to the thread_id of the vote 
thread
+            # Then we automatically start the Incubator PMC vote
+            # TODO: Note on the resolve vote page that resolving the Project 
PPMC vote starts the Incubator PMC vote
+            task_mid = interaction.task_mid_get(latest_vote_task)
+            archive_url = await interaction.task_archive_url_cached(task_mid)
+            if archive_url is None:
+                await quart.flash("No archive URL found for podling vote", 
"error")
+                return release, "Failure"
+            thread_id = archive_url.split("/")[-1]
+            release.podling_thread_id = thread_id
+            # incubator_vote_address = "[email protected]"
+            incubator_vote_address = util.USER_TESTS_ADDRESS
+            if not release.project.committee:
+                raise ValueError("Project has no committee")
+            revision_number = release.latest_revision_number
+            if revision_number is None:
+                raise ValueError("Release has no revision number")
+            await voting.start_vote(
+                email_to=incubator_vote_address,
+                permitted_recipients=[incubator_vote_address],
+                project_name=release.project.name,
+                version_name=release.version,
+                selected_revision_number=revision_number,
+                session=session,
+                
vote_duration_choice=latest_vote_task.task_args["vote_duration"],
+                subject_data=f"[VOTE] Release {release.project.display_name} 
{release.version}",
+                body_data=await 
construct.start_vote_default(release.project.name),
+                data=data,
+                release=release,
+                promote=False,
+            )
+            success_message = "Project PPMC vote marked as passed, and 
Incubator PMC vote automatically started"
+        elif vote_result == "passed":
+            release.phase = sql.ReleasePhase.RELEASE_PREVIEW
+            success_message = "Vote marked as passed"
+
+            description = "Create a preview revision from the last candidate 
draft"
+            async with revision.create_and_manage(
+                project_name, release.version, session.uid, 
description=description
+            ) as _creating:
+                pass
+            if (voting_round == 2) and (release.podling_thread_id is not None):
+                round_one_email_address, round_one_message_id = await 
util.email_mid_from_thread_id(
+                    release.podling_thread_id
                 )
-                success_message = "Project PPMC vote marked as passed, and 
Incubator PMC vote automatically started"
-            elif vote_result == "passed":
-                release.phase = sql.ReleasePhase.RELEASE_PREVIEW
-                success_message = "Vote marked as passed"
-
-                description = "Create a preview revision from the last 
candidate draft"
-                async with revision.create_and_manage(
-                    project_name, release.version, session.uid, 
description=description
-                ) as _creating:
-                    pass
-                if (voting_round == 2) and (release.podling_thread_id is not 
None):
-                    round_one_email_address, round_one_message_id = await 
util.email_mid_from_thread_id(
-                        release.podling_thread_id
-                    )
-                    extra_destination = (round_one_email_address, 
round_one_message_id)
-            else:
-                release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
-                success_message = "Vote marked as failed"
+                extra_destination = (round_one_email_address, 
round_one_message_id)
+        else:
+            release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+            success_message = "Vote marked as failed"
+        await data.commit()
 
     error_message = await _send_resolution(
         session, release, vote_result, resolution_body, 
extra_destination=extra_destination
@@ -412,10 +382,10 @@ async def _send_resolution(
     extra_destination: tuple[str, str] | None = None,
 ) -> str | None:
     # Get the email thread
-    latest_vote_task = await release_latest_vote_task(release)
+    latest_vote_task = await interaction.release_latest_vote_task(release)
     if latest_vote_task is None:
         return "No vote task found, unable to send resolution message."
-    vote_thread_mid = task_mid_get(latest_vote_task)
+    vote_thread_mid = interaction.task_mid_get(latest_vote_task)
     if vote_thread_mid is None:
         return "No vote thread found, unable to send resolution message."
 
diff --git a/atr/routes/vote.py b/atr/routes/vote.py
index 82207b4..6c8ac8d 100644
--- a/atr/routes/vote.py
+++ b/atr/routes/vote.py
@@ -15,38 +15,21 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from typing import Final
-
-import aiohttp
 import quart
 import werkzeug.wrappers.response as response
 
 import atr.db as db
+import atr.db.interaction as interaction
 import atr.forms as forms
 import atr.log as log
 import atr.models.results as results
 import atr.models.sql as sql
 import atr.routes as routes
 import atr.routes.compose as compose
-import atr.routes.resolve as resolve
 import atr.storage as storage
 import atr.tasks.message as message
 import atr.util as util
 
-# TEST_MID: Final[str | None] = 
"CAH5JyZo8QnWmg9CwRSwWY=givhxw4nilyenjo71fkdk81j5...@mail.gmail.com"
-TEST_MID: Final[str | None] = None
-_THREAD_URLS_FOR_DEVELOPMENT: Final[dict[str, str]] = {
-    "CAH5JyZo8QnWmg9CwRSwWY=givhxw4nilyenjo71fkdk81j5...@mail.gmail.com": 
"https://lists.apache.org/thread/z0o7xnjnyw2o886rxvvq2ql4rdfn754w";,
-    "[email protected]": 
"https://lists.apache.org/thread/619hn4x796mh3hkk3kxg1xnl48dy2s64";,
-    "CAA9ykM+bMPNk=bof9hj0o+mjn1igppoj+pkdzhcam0ddvi+...@mail.gmail.com": 
"https://lists.apache.org/thread/x0m3p2xqjvflgtkb6oxqysm36cr9l5mg";,
-    "CAFHDsVzgtfboqYF+a3owaNf+55MUiENWd3g53mU4rD=whkx...@mail.gmail.com": 
"https://lists.apache.org/thread/brj0k3g8pq63g8f7xhmfg2rbt1240nts";,
-    "camomwmrvktqk7k2-otztreo0jjxzo2g5ynw3gsoks_pxwpz...@mail.gmail.com": 
"https://lists.apache.org/thread/y5rqp5qk6dmo08wlc3g20n862hznc9m8";,
-    "CANVKqzfLYj6TAVP_Sfsy5vFbreyhKskpRY-vs=f7aled+rl...@mail.gmail.com": 
"https://lists.apache.org/thread/oy969lhh6wlzd51ovckn8fly9rvpopwh";,
-    "cah4123zwgtkwszheu7qnmbyla-yvykz2w+djh_uchpmuzaa...@mail.gmail.com": 
"https://lists.apache.org/thread/7111mqyc25sfqxm6bf4ynwhs0bk0r4ys";,
-    "CADL1oArKFcXvNb1MJfjN=10-yrfkxgpltrurdmm1r7ygatk...@mail.gmail.com": 
"https://lists.apache.org/thread/d7119h2qm7jrd5zsbp8ghkk0lpvnnxnw";,
-    "[email protected]": 
"https://lists.apache.org/thread/gzjd2jv7yod5sk5rgdf4x33g5l3fdf5o";,
-}
-
 
 class CastVoteForm(forms.Typed):
     """Form for casting a vote."""
@@ -68,7 +51,7 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
         phase=sql.ReleasePhase.RELEASE_CANDIDATE,
         with_project_release_policy=True,
     )
-    latest_vote_task = await resolve.release_latest_vote_task(release)
+    latest_vote_task = await interaction.release_latest_vote_task(release)
     archive_url = None
     task_mid = None
 
@@ -82,13 +65,13 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
                 email_to="[email protected]",
                 vote_end="2025-07-01 12:00:00",
                 subject="Test vote",
-                mid=TEST_MID,
+                mid=interaction.TEST_MID,
                 mail_send_warnings=[],
             )
 
         # Move task_mid_get here?
-        task_mid = resolve.task_mid_get(latest_vote_task)
-        archive_url = await task_archive_url_cached(task_mid)
+        task_mid = interaction.task_mid_get(latest_vote_task)
+        archive_url = await interaction.task_archive_url_cached(task_mid)
 
     # Special form for the [ Resolve vote ] button, to make it POST
     hidden_form = await forms.Hidden.create_form()
@@ -163,34 +146,6 @@ async def selected_post(session: routes.CommitterSession, 
project_name: str, ver
         )
 
 
-async def task_archive_url_cached(task_mid: str | None) -> str | None:
-    if task_mid in _THREAD_URLS_FOR_DEVELOPMENT:
-        return _THREAD_URLS_FOR_DEVELOPMENT[task_mid]
-
-    if task_mid is None:
-        return None
-    if "@" not in task_mid:
-        return None
-
-    async with db.session() as data:
-        url = await data.ns_text_get(
-            "mid-url-cache",
-            task_mid,
-        )
-        if url is not None:
-            return url
-
-    url = await _task_archive_url(task_mid)
-    if url is not None:
-        await data.ns_text_set(
-            "mid-url-cache",
-            task_mid,
-            url,
-        )
-
-    return url
-
-
 async def _send_vote(
     session: routes.CommitterSession,
     release: sql.Release,
@@ -198,10 +153,10 @@ async def _send_vote(
     comment: str,
 ) -> tuple[str, str]:
     # Get the email thread
-    latest_vote_task = await resolve.release_latest_vote_task(release)
+    latest_vote_task = await interaction.release_latest_vote_task(release)
     if latest_vote_task is None:
         return "", "No vote task found."
-    vote_thread_mid = resolve.task_mid_get(latest_vote_task)
+    vote_thread_mid = interaction.task_mid_get(latest_vote_task)
     if vote_thread_mid is None:
         return "", "No vote thread found."
 
@@ -240,26 +195,3 @@ async def _send_vote(
         await data.commit()
 
     return email_recipient, ""
-
-
-async def _task_archive_url(task_mid: str) -> str | None:
-    if "@" not in task_mid:
-        return None
-
-    # TODO: This List ID will be dynamic when we allow posting to arbitrary 
lists
-    # lid = "user-tests.tooling.apache.org"
-    lid = util.USER_TESTS_ADDRESS.replace("@", ".")
-    url = 
f"https://lists.apache.org/api/email.lua?id=%3C{task_mid}%3E&listid=%3C{lid}%3E";
-    try:
-        async with aiohttp.ClientSession() as session:
-            async with session.get(url) as response:
-                response.raise_for_status()
-                # TODO: Check whether this blocks from network
-                email_data = await response.json()
-        mid = email_data["mid"]
-        if not isinstance(mid, str):
-            return None
-        return "https://lists.apache.org/thread/"; + mid
-    except Exception:
-        log.exception("Failed to get archive URL for task %s", task_mid)
-        return None
diff --git a/atr/routes/voting.py b/atr/routes/voting.py
index 03ef940..46a4510 100644
--- a/atr/routes/voting.py
+++ b/atr/routes/voting.py
@@ -188,7 +188,7 @@ async def start_vote(
     data: db.Session,
     release: sql.Release,
     promote: bool = True,
-):
+) -> response.Response | str:
     if email_to not in permitted_recipients:
         # This will be checked again by tasks/vote.py for extra safety
         log.info(f"Invalid mailing list choice: {email_to} not in 
{permitted_recipients}")
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index d096c22..8c293cf 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -20,13 +20,17 @@ from __future__ import annotations
 
 from typing import Literal
 
+import atr.construct as construct
 import atr.db as db
 import atr.db.interaction as interaction
+import atr.log as log
 import atr.models.sql as sql
 import atr.revision as revision
 import atr.storage as storage
+import atr.tasks.message as message
 import atr.tasks.vote as tasks_vote
 import atr.user as user
+import atr.util as util
 
 
 class GeneralPublic:
@@ -91,7 +95,86 @@ class CommitteeMember(CommitteeParticipant):
         self.__asf_uid = asf_uid
         self.__committee_name = committee_name
 
-    async def resolve(self, project_name: str, version_name: str, resolution: 
Literal["passed", "failed"]) -> None:
+    async def resolve(
+        self,
+        project_name: str,
+        release: sql.Release,
+        voting_round: int | None,
+        vote_result: Literal["passed", "failed"],
+        latest_vote_task: sql.Task,
+        asf_fullname: str,
+        resolution_body: str,
+    ) -> tuple[sql.Release, str]:
+        # Attach the existing release to the session
+        release = await self.__data.merge(release)
+        # Update the release phase based on vote result
+        extra_destination = None
+        if (voting_round == 1) and (vote_result == "passed"):
+            # This is the first podling vote, by the PPMC and not the 
Incubator PMC
+            # In this branch, we do not move to RELEASE_PREVIEW but keep 
everything the same
+            # We only set the podling_thread_id to the thread_id of the vote 
thread
+            # Then we automatically start the Incubator PMC vote
+            # TODO: Note on the resolve vote page that resolving the Project 
PPMC vote starts the Incubator PMC vote
+            task_mid = interaction.task_mid_get(latest_vote_task)
+            archive_url = await interaction.task_archive_url_cached(task_mid)
+            if archive_url is None:
+                raise ValueError("No archive URL found for podling vote")
+            thread_id = archive_url.split("/")[-1]
+            release.podling_thread_id = thread_id
+            # incubator_vote_address = "[email protected]"
+            incubator_vote_address = util.USER_TESTS_ADDRESS
+            if not release.project.committee:
+                raise ValueError("Project has no committee")
+            revision_number = release.latest_revision_number
+            if revision_number is None:
+                raise ValueError("Release has no revision number")
+            await self.start(
+                email_to=incubator_vote_address,
+                permitted_recipients=[incubator_vote_address],
+                project_name=release.project.name,
+                version_name=release.version,
+                selected_revision_number=revision_number,
+                asf_uid=self.__asf_uid,
+                asf_fullname=asf_fullname,
+                
vote_duration_choice=latest_vote_task.task_args["vote_duration"],
+                subject_data=f"[VOTE] Release {release.project.display_name} 
{release.version}",
+                body_data=await 
construct.start_vote_default(release.project.name),
+                release=release,
+                promote=False,
+            )
+            success_message = "Project PPMC vote marked as passed, and 
Incubator PMC vote automatically started"
+        elif vote_result == "passed":
+            release.phase = sql.ReleasePhase.RELEASE_PREVIEW
+            success_message = "Vote marked as passed"
+
+            description = "Create a preview revision from the last candidate 
draft"
+            async with revision.create_and_manage(
+                project_name, release.version, self.__asf_uid, 
description=description
+            ) as _creating:
+                pass
+            if (voting_round == 2) and (release.podling_thread_id is not None):
+                round_one_email_address, round_one_message_id = await 
util.email_mid_from_thread_id(
+                    release.podling_thread_id
+                )
+                extra_destination = (round_one_email_address, 
round_one_message_id)
+        else:
+            release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+            success_message = "Vote marked as failed"
+        await self.__data.commit()
+
+        error_message = await self.send_resolution(
+            release,
+            vote_result,
+            resolution_body,
+            asf_uid=self.__asf_uid,
+            asf_fullname=asf_fullname,
+            extra_destination=extra_destination,
+        )
+        if error_message is not None:
+            raise storage.AccessError(error_message)
+        return release, success_message
+
+    async def resolve_api(self, project_name: str, version_name: str, 
resolution: Literal["passed", "failed"]) -> None:
         release_name = sql.release_name(project_name, version_name)
         release = await self.__data.release(name=release_name, _project=True, 
_committee=True).demand(
             storage.AccessError("Release not found")
@@ -113,7 +196,130 @@ class CommitteeMember(CommitteeParticipant):
                 release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
         await self.__data.commit()
 
+    async def send_resolution(
+        self,
+        release: sql.Release,
+        resolution: str,
+        body: str,
+        asf_uid: str,
+        asf_fullname: str,
+        extra_destination: tuple[str, str] | None = None,
+    ) -> str | None:
+        # Get the email thread
+        latest_vote_task = await interaction.release_latest_vote_task(release)
+        if latest_vote_task is None:
+            return "No vote task found, unable to send resolution message."
+        vote_thread_mid = interaction.task_mid_get(latest_vote_task)
+        if vote_thread_mid is None:
+            return "No vote thread found, unable to send resolution message."
+
+        # Construct the reply email
+        # original_subject = latest_vote_task.task_args["subject"]
+
+        # Arguments for the task to cast a vote
+        email_recipient = latest_vote_task.task_args["email_to"]
+        email_sender = f"{asf_uid}@apache.org"
+        subject = f"[VOTE] [RESULT] Release {release.project.display_name} 
{release.version} {resolution.upper()}"
+        body = f"{body}\n\n-- \n{asf_fullname} ({asf_uid})"
+        in_reply_to = vote_thread_mid
+
+        task = sql.Task(
+            status=sql.TaskStatus.QUEUED,
+            task_type=sql.TaskType.MESSAGE_SEND,
+            task_args=message.Send(
+                email_sender=email_sender,
+                email_recipient=email_recipient,
+                subject=subject,
+                body=body,
+                in_reply_to=in_reply_to,
+            ).model_dump(),
+            asf_uid=asf_uid,
+            project_name=release.project.name,
+            version_name=release.version,
+        )
+        tasks = [task]
+        if extra_destination is not None:
+            task = sql.Task(
+                status=sql.TaskStatus.QUEUED,
+                task_type=sql.TaskType.MESSAGE_SEND,
+                task_args=message.Send(
+                    email_sender=email_sender,
+                    email_recipient=extra_destination[0],
+                    subject=subject,
+                    body=body,
+                    in_reply_to=extra_destination[1],
+                ).model_dump(),
+                asf_uid=asf_uid,
+                project_name=release.project.name,
+                version_name=release.version,
+            )
+            tasks.append(task)
+        self.__data.add_all(tasks)
+        await self.__data.flush()
+        await self.__data.commit()
+        return None
+
     async def start(
+        self,
+        email_to: str,
+        permitted_recipients: list[str],
+        project_name: str,
+        version_name: str,
+        selected_revision_number: str,
+        asf_uid: str,
+        asf_fullname: str,
+        vote_duration_choice: int,
+        subject_data: str,
+        body_data: str,
+        release: sql.Release,
+        promote: bool = True,
+    ) -> str:
+        if email_to not in permitted_recipients:
+            # This will be checked again by tasks/vote.py for extra safety
+            log.info(f"Invalid mailing list choice: {email_to} not in 
{permitted_recipients}")
+            raise storage.AccessError("Invalid mailing list choice")
+
+        if promote is True:
+            # This verifies the state and sets the phase to RELEASE_CANDIDATE
+            error = await interaction.promote_release(
+                self.__data, release.name, selected_revision_number, 
vote_manual=False
+            )
+            if error:
+                raise storage.AccessError(error)
+
+        # TODO: We also need to store the duration of the vote
+        # We can't allow resolution of the vote until the duration has elapsed
+        # But we allow the user to specify in the form
+        # And yet we also have ReleasePolicy.min_hours
+        # Presumably this sets the default, and the form takes precedence?
+        # ReleasePolicy.min_hours can also be 0, though
+
+        # Create a task for vote initiation
+        task = sql.Task(
+            status=sql.TaskStatus.QUEUED,
+            task_type=sql.TaskType.VOTE_INITIATE,
+            task_args=tasks_vote.Initiate(
+                release_name=release.name,
+                email_to=email_to,
+                vote_duration=vote_duration_choice,
+                initiator_id=asf_uid,
+                initiator_fullname=asf_fullname,
+                subject=subject_data,
+                body=body_data,
+            ).model_dump(),
+            asf_uid=asf_uid,
+            project_name=project_name,
+            version_name=version_name,
+        )
+        self.__data.add(task)
+        await self.__data.commit()
+
+        # TODO: We should log all outgoing email and the session so that users 
can confirm
+        # And can be warned if there was a failure
+        # (The message should be shown on the vote resolution page)
+        return f"The vote announcement email will soon be sent to {email_to}."
+
+    async def start_api(
         self,
         project_name: str,
         version_name: str,
diff --git a/atr/util.py b/atr/util.py
index 4afa1d3..38e6c93 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -772,6 +772,29 @@ def static_path(*args: str) -> str:
     return quart.url_for("static", filename=filename)
 
 
+async def task_archive_url(task_mid: str) -> str | None:
+    if "@" not in task_mid:
+        return None
+
+    # TODO: This List ID will be dynamic when we allow posting to arbitrary 
lists
+    # lid = "user-tests.tooling.apache.org"
+    lid = USER_TESTS_ADDRESS.replace("@", ".")
+    url = 
f"https://lists.apache.org/api/email.lua?id=%3C{task_mid}%3E&listid=%3C{lid}%3E";
+    try:
+        async with aiohttp.ClientSession() as session:
+            async with session.get(url) as response:
+                response.raise_for_status()
+                # TODO: Check whether this blocks from network
+                email_data = await response.json()
+        mid = email_data["mid"]
+        if not isinstance(mid, str):
+            return None
+        return "https://lists.apache.org/thread/"; + mid
+    except Exception:
+        log.exception("Failed to get archive URL for task %s", task_mid)
+        return None
+
+
 async def thread_messages(
     thread_id: str,
 ) -> AsyncGenerator[tuple[str, dict[str, Any]]]:


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


Reply via email to