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]