atr/routes/resolve.py (140 lines of code) (raw):

# 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 json import os import quart import werkzeug.wrappers.response as response import wtforms import atr.db as db import atr.db.models as models import atr.routes as routes import atr.routes.compose as compose import atr.routes.finish as finish import atr.routes.vote as vote import atr.util as util from atr.tasks import message class ResolveForm(util.QuartFormTyped): """Form for resolving a vote on a release candidate.""" candidate_name = wtforms.StringField( "Candidate name", validators=[wtforms.validators.InputRequired("Candidate name is required")] ) vote_result = wtforms.RadioField( "Vote result", choices=[("passed", "Passed"), ("failed", "Failed")], validators=[wtforms.validators.InputRequired("Vote result is required")], ) resolution_body = wtforms.TextAreaField("Resolution email body", validators=[wtforms.validators.Optional()]) submit = wtforms.SubmitField("Resolve vote") def release_latest_vote_task(release: models.Release) -> models.Task | None: # Find the most recent VOTE_INITIATE task for this release # TODO: Make this a proper query for task in sorted(release.tasks, key=lambda t: t.added, reverse=True): if task.task_type != models.TaskType.VOTE_INITIATE: continue # if task.status != models.TaskStatus.COMPLETED: # continue if (task.status == models.TaskStatus.QUEUED) or (task.status == models.TaskStatus.ACTIVE): continue if task.result is None: continue return task return None @routes.committer("/resolve/<project_name>/<version_name>", methods=["POST"], measure_performance=False) async def selected_post( session: routes.CommitterSession, project_name: str, version_name: str ) -> response.Response | str: """Resolve the vote on a release candidate.""" await session.check_access(project_name) form = await ResolveForm.create_form(data=await quart.request.form) if not await form.validate_on_submit(): for _field, errors in form.errors.items(): for error in errors: await quart.flash(f"{error}", "error") return await session.redirect(vote.selected, project_name=project_name, version_name=version_name) candidate_name = form.candidate_name.data vote_result = form.vote_result.data resolution_body = util.unwrap_type(form.resolution_body.data, str) if not candidate_name: return await session.redirect( vote.selected, error="Missing candidate name", project_name=project_name, version_name=version_name ) # Extract project name try: project_name, version_name = candidate_name.rsplit("-", 1) except ValueError: return await session.redirect( vote.selected, error="Invalid candidate name format", project_name=project_name, version_name=version_name ) # Check that the user has access to the project await session.check_access(project_name) # Update release status in the database async with db.session() as data: async with data.begin(): release = await session.release( project_name, version_name, with_tasks=True, with_project=True, phase=models.ReleasePhase.RELEASE_CANDIDATE, data=data, ) # Update the release phase based on vote result if vote_result == "passed": release.stage = models.ReleaseStage.RELEASE release.phase = models.ReleasePhase.RELEASE_PREVIEW success_message = "Vote marked as passed" destination = finish.selected else: release.phase = models.ReleasePhase.RELEASE_CANDIDATE_DRAFT success_message = "Vote marked as failed" destination = compose.selected error_message = await _send_resolution(session, release, vote_result, resolution_body) if error_message is not None: await quart.flash(error_message, "error") return await session.redirect( destination, success=success_message, project_name=project_name, version_name=release.version ) def task_mid_get(latest_vote_task: models.Task) -> str | None: if "LOCAL_DEBUG" in os.environ: return "818a44a3-6984-4aba-a650-834e86780b43@apache.org" # TODO: Improve this task_mid = None try: for result in latest_vote_task.result or []: if isinstance(result, str): parsed_result = json.loads(result) else: # Shouldn't happen parsed_result = result if isinstance(parsed_result, dict): task_mid = parsed_result.get("mid", "(mid not found in result)") break else: task_mid = "(malformed result)" except (json.JSONDecodeError, TypeError): task_mid = "(malformed result)" return task_mid def _format_artifact_name(project_name: str, version: str, is_podling: bool = False) -> str: """Format an artifact name according to Apache naming conventions. For regular projects: apache-${project}-${version} For podlings: apache-${project}-incubating-${version} """ # TODO: Format this better based on committee and project # Must depend on whether project is a subproject or not if is_podling: return f"apache-{project_name}-incubating-{version}" return f"apache-{project_name}-{version}" async def _send_resolution( session: routes.CommitterSession, release: models.Release, resolution: str, body: str, ) -> str | None: # Get the email thread latest_vote_task = 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) 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"{session.uid}@apache.org" subject = f"[VOTE] [RESULT] Release {release.project.display_name} {release.version} {resolution.upper()}" body = f"{body}\n\n-- \n{session.fullname} ({session.uid})" in_reply_to = vote_thread_mid task = models.Task( status=models.TaskStatus.QUEUED, task_type=models.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(), release_name=release.name, ) async with db.session() as data: data.add(task) await data.flush() await data.commit() return None