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 0a85dd2 Add a page to resolve a vote for a specific release
0a85dd2 is described below
commit 0a85dd24682c6faf77f678542460b652f0f785ab
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Apr 22 19:02:57 2025 +0100
Add a page to resolve a vote for a specific release
---
atr/routes/candidate.py | 63 ++++++++---
atr/static/css/atr.css | 2 +-
atr/templates/candidate-resolve-release.html | 154 +++++++++++++++++++++++++++
atr/templates/index-committer.html | 2 +-
playwright/test.py | 83 ++++++---------
5 files changed, 239 insertions(+), 65 deletions(-)
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index ff34561..0a915df 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -33,6 +33,7 @@ import atr.db.models as models
import atr.revision as revision
import atr.routes as routes
import atr.routes.preview as preview
+import atr.routes.root as root
import atr.util as util
if asfquart.APP is ...:
@@ -69,6 +70,49 @@ async def resolve(session: routes.CommitterSession) ->
response.Response | str:
return await _resolve_post(session)
[email protected]("/resolve/<project_name>/<version_name>")
+async def resolve_release(
+ session: routes.CommitterSession, project_name: str, version_name: str
+) -> response.Response | str:
+ """Resolve the vote on a release candidate."""
+ if not any((p.name == project_name) for p in (await
session.user_projects)):
+ return await session.redirect(root.index, error="You do not have
access to this project")
+
+ async with db.session() as data:
+ release_name = models.release_name(project_name, version_name)
+ release = await data.release(name=release_name, _project=True,
_committee=True, _tasks=True).demand(
+ base.ASFQuartException("Release does not exist", errorcode=404)
+ )
+
+ form = await ResolveForm.create_form()
+
+ # Find the most recent VOTE_INITIATE task for this release
+ # TODO: Make this a proper query
+ latest_vote_task = None
+ task_mid = None
+ archive_url = None
+ for task in sorted(release.tasks, key=lambda t: t.added, reverse=True):
+ if task.task_type == models.TaskType.VOTE_INITIATE:
+ latest_vote_task = task
+ break
+
+ logging.warning(f"Latest vote task 2: {latest_vote_task}")
+ if latest_vote_task and (latest_vote_task.status ==
models.TaskStatus.COMPLETED) and latest_vote_task.result:
+ task_mid = _task_mid(latest_vote_task)
+ archive_url = await _task_archive_url(task_mid)
+
+ return await quart.render_template(
+ "candidate-resolve-release.html",
+ release=release,
+ format_artifact_name=_format_artifact_name,
+ form=form,
+ format_datetime=routes.format_datetime,
+ vote_task=latest_vote_task,
+ task_mid=task_mid,
+ archive_url=archive_url,
+ )
+
+
@routes.committer("/candidate/view/<project_name>/<version_name>")
async def view(session: routes.CommitterSession, project_name: str,
version_name: str) -> response.Response | str:
"""View all the files in the rsync upload directory for a release."""
@@ -176,6 +220,7 @@ async def _resolve_get(session: routes.CommitterSession) ->
str:
latest_vote_task = task
break
+ logging.warning(f"Latest vote task: {latest_vote_task}")
if latest_vote_task and (latest_vote_task.status ==
models.TaskStatus.COMPLETED) and latest_vote_task.result:
task_mid = _task_mid(latest_vote_task)
archive_url = await _task_archive_url(task_mid)
@@ -234,22 +279,10 @@ async def _resolve_post(session: routes.CommitterSession)
-> response.Response:
release.phase = models.ReleasePhase.RELEASE_CANDIDATE_DRAFT
success_message = "Vote marked as failed"
- # # Create a task for vote resolution notification
- # task = models.Task(
- # status=models.TaskStatus.QUEUED,
- # task_type="vote_resolve",
- # task_args=[
- # candidate_name,
- # vote_result,
- # session.uid,
- # ],
- # )
- # data.add(task)
-
- await data.commit()
-
await _resolve_post_files(project_name, release, vote_result, session.uid)
- return await session.redirect(preview.previews, success=success_message)
+ return await session.redirect(
+ preview.announce_release, success=success_message,
project_name=project_name, version_name=release.version
+ )
async def _resolve_post_files(project_name: str, release: models.Release,
vote_result: str, asf_uid: str) -> None:
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index 68588e6..789fc1d 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -408,7 +408,7 @@ img {
}
h1 strong {
- font-weight: 550;
+ font-weight: 525;
}
h1 em {
diff --git a/atr/templates/candidate-resolve-release.html
b/atr/templates/candidate-resolve-release.html
new file mode 100644
index 0000000..c133fa4
--- /dev/null
+++ b/atr/templates/candidate-resolve-release.html
@@ -0,0 +1,154 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+ Resolve vote for {{ release.short_display_name }} ~ ATR
+{% endblock title %}
+
+{% block description %}
+ Resolve the vote for the {{ release.project.display_name }} {{
release.version }} release candidate.
+{% endblock description %}
+
+{% block stylesheets %}
+ {{ super() }}
+ <style>
+ .atr-candidate-meta-item::after {
+ content: "•";
+ margin-left: 1rem;
+ color: #ccc;
+ }
+
+ .atr-candidate-meta-item:last-child::after {
+ content: none;
+ }
+ </style>
+{% endblock stylesheets %}
+
+{% block content %}
+ <p>
+ <a href="{{ as_url(routes.root.index) }}" class="back-link">← Back to
Select a release</a>
+ </p>
+ <h1>
+ Resolve vote for <strong>{{ release.project.short_display_name }}</strong>
<em>{{ release.version }}</em>
+ </h1>
+ <p class="intro">
+ Resolve the vote for the <strong>release candidate</strong> for {{
release.project.display_name }} {{ release.version }}. If you resolve a vote as
passed, the release candidate will be promoted to the next stage, making it a
release preview. If you resolve a vote as failed, the release candidate will be
returned to the draft stage.
+ </p>
+
+ <div class="card mb-4">
+ <div class="card-header d-flex justify-content-between align-items-center">
+ <h5 class="mb-0">Release information</h5>
+ </div>
+ <div class="card-body">
+ <div class="row">
+ <div class="col-md-6">
+ <p>
+ <strong>Project:</strong>
+ <a href="{{ as_url(routes.projects.view,
name=release.project.name) }}">{{ release.project.display_name }}</a>
+ </p>
+ <p>
+ <strong>Version:</strong> {{ release.version }}
+ </p>
+ <p>
+ <strong>Label:</strong> {{ release.name }}
+ </p>
+ </div>
+ <div class="col-md-6">
+ <p>
+ <strong>Phase:</strong> <span>{{ release.phase.value.upper()
}}</span>
+ </p>
+ <p>
+ <strong>Created:</strong> {{ format_datetime(release.created) }}
+ </p>
+ {% if release.vote_started %}
+ <p>
+ <strong>Vote Started:</strong> {{
format_datetime(release.vote_started) }}
+ </p>
+ {% endif %}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="card mb-4">
+ <div class="card-header">
+ <h5 class="mb-0">Vote email details</h5>
+ </div>
+ <div class="card-body">
+ <div class="p-3 border rounded bg-white mb-3">
+ {% if vote_task %}
+ {% if vote_task.status.value == "completed" %}
+ <p class="mb-0 text-success fw-semibold">
+ <i class="bi bi-check-circle-fill me-1"></i> Vote email sent: {{
format_datetime(vote_task.completed) }}
+ </p>
+ {% if task_mid %}
+ <p class="mt-2 mb-0 text-muted ps-4">
+ Message-ID: <code class="user-select-all">{{ task_mid }}</code>
+ </p>
+ {% endif %}
+ {% elif vote_task.status.value == "failed" %}
+ <p class="mb-1 text-danger fw-semibold">
+ <i class="bi bi-x-octagon-fill me-1"></i> Vote email failed: {{
format_datetime(vote_task.completed) }}
+ </p>
+ <div class="alert alert-danger mt-2 mb-0 p-2" role="alert">
+ <p class="mb-0 p-2 text-danger">{{ vote_task.error }}</p>
+ </div>
+ {% else %}
+ <p class="mb-0 text-warning fw-semibold">
+ <i class="bi bi-hourglass-split me-1"></i> Vote email status: {{
vote_task.status.value.upper() }}
+ {% if vote_task.started %}
+ (Started: {{ format_datetime(vote_task.started) }})
+ {% else %}
+ (Added: {{ format_datetime(vote_task.added) }})
+ {% endif %}
+ </p>
+ {% endif %}
+ {% if archive_url %}
+ <p class="mt-2 mb-0 text-muted ps-4">
+ <a href="{{ archive_url }}" target="_blank" rel="noopener
noreferrer">View vote email in the archive <i class="bi bi-box-arrow-up-right
ms-1"></i></a>
+ </p>
+ {% elif task_mid %}
+ <p class="mt-2 mb-0 text-muted ps-4">Could not retrieve archive
URL for this message.</p>
+ {% endif %}
+ {% else %}
+ <p class="mb-0 text-muted">
+ <i class="bi bi-question-circle me-1"></i> Vote email: No vote
initiation task found for this release.
+ </p>
+ {% endif %}
+ </div>
+ </div>
+ </div>
+
+ <div class="card mb-4">
+ <div class="card-header">
+ <h5 class="mb-0">Resolve vote</h5>
+ </div>
+ <div class="card-body">
+ <form method="post"
+ action="{{ as_url(routes.candidate.resolve) }}"
+ class="atr-canary py-4 px-5 needs-validation"
+ novalidate>
+ <input type="hidden" name="candidate_name" value="{{ release.name }}"
/>
+ {{ form.csrf_token }}
+
+ <div class="mb-3 pb-3 row border-bottom">
+ <label class="col-sm-3 col-form-label text-sm-end fw-semibold">{{
form.vote_result.label.text }}:</label>
+ <div class="col-sm-9 pt-2">
+ {% for subfield in form.vote_result %}
+ <div class="form-check form-check-inline">
+ {{ subfield(class="form-check-input" + (" is-invalid" if
form.vote_result.errors else "") , id=subfield.id ~ "_" ~ loop.index) }}
+ <label class="form-check-label" for="{{ subfield.id }}_{{
loop.index }}">{{ subfield.label.text }}</label>
+ </div>
+ {% endfor %}
+ {% if form.vote_result.errors %}
+ <div class="invalid-feedback d-block">{{
form.vote_result.errors[0] }}</div>
+ {% endif %}
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-sm-9 offset-sm-3">{{ form.submit(class_="btn
btn-primary mt-3") }}</div>
+ </div>
+ </form>
+ </div>
+ </div>
+{% endblock content %}
diff --git a/atr/templates/index-committer.html
b/atr/templates/index-committer.html
index c80a689..a361faa 100644
--- a/atr/templates/index-committer.html
+++ b/atr/templates/index-committer.html
@@ -122,7 +122,7 @@
{% if release.phase.value == "release_candidate_draft" %}
{% set release_link = as_url(routes.draft.compose,
project_name=release.project.name, version_name=release.version) %}
{% elif release.phase.value == "release_candidate" %}
- {% set release_link = as_url(routes.candidate.view,
project_name=release.project.name, version_name=release.version) %}
+ {% set release_link = as_url(routes.candidate.resolve_release,
project_name=release.project.name, version_name=release.version) %}
{% elif release.phase.value == "release_preview" %}
{% set release_link = as_url(routes.preview.announce_release,
project_name=release.project.name, version_name=release.version) %}
{% endif %}
diff --git a/playwright/test.py b/playwright/test.py
index 057a5f0..df6ccc5 100644
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -118,8 +118,8 @@ def lifecycle_01_add_draft(page: sync_api.Page,
credentials: Credentials, versio
sync_api.expect(submit_button_locator).to_be_enabled()
submit_button_locator.click()
- logging.info(f"Waiting for navigation to
/draft/content/tooling-test-example/{version_name} after adding draft")
- wait_for_path(page, f"/draft/content/tooling-test-example/{version_name}")
+ logging.info(f"Waiting for navigation to
/compose/tooling-test-example/{version_name} after adding draft")
+ wait_for_path(page, f"/compose/tooling-test-example/{version_name}")
logging.info("Add draft actions completed successfully")
@@ -132,9 +132,9 @@ def lifecycle_02_check_draft_added(page: sync_api.Page,
credentials: Credentials
def lifecycle_03_add_file(page: sync_api.Page, credentials: Credentials,
version_name: str) -> None:
- logging.info(f"Navigating to the add file page for tooling-test-example
{version_name}")
- go_to_path(page, f"/draft/add/tooling-test-example/{version_name}")
- logging.info("Add file page loaded")
+ logging.info(f"Navigating to the upload file page for tooling-test-example
{version_name}")
+ go_to_path(page, f"/upload/tooling-test-example/{version_name}")
+ logging.info("Upload file page loaded")
logging.info("Locating the file input")
file_input_locator = page.locator('input[name="file_data"]')
@@ -208,25 +208,16 @@ def lifecycle_05_resolve_vote(page: sync_api.Page,
credentials: Credentials, ver
sync_api.expect(submit_button_locator).to_be_enabled()
submit_button_locator.click()
- logging.info("Waiting for navigation to /previews after resolving the
vote")
- wait_for_path(page, "/previews")
+ logging.info(
+ f"Waiting for navigation to
/preview/announce/tooling-test-example/{version_name} after resolving the vote"
+ )
+ wait_for_path(page,
f"/preview/announce/tooling-test-example/{version_name}")
logging.info("Vote resolution actions completed successfully")
def lifecycle_06_announce_preview(page: sync_api.Page, credentials:
Credentials, version_name: str) -> None:
- logging.info(f"Locating the link to announce the preview for
tooling-test-example {version_name}")
- announce_link_locator = page.locator(f'a[title="Announce Apache Tooling
Test Example {version_name}"]')
- sync_api.expect(announce_link_locator).to_be_visible()
-
- logging.info("Following the link to announce the preview")
- announce_link_locator.click()
-
- logging.info("Waiting for navigation to /preview/announce")
- wait_for_path(page, "/preview/announce")
- logging.info("Announce preview navigation completed successfully")
-
logging.info(f"Locating the announcement form for tooling-test-example
{version_name}")
- form_locator = page.locator(f'#tooling-test-example-{esc_id(version_name)}
form[action="/preview/announce"]')
+ form_locator = page.locator('form[action="/preview/announce"]')
sync_api.expect(form_locator).to_be_visible()
logging.info("Locating the confirmation checkbox within the form")
@@ -509,7 +500,7 @@ def test_checks_01_hashing_sha512(page: sync_api.Page,
credentials: Credentials)
version_name = "0.2"
filename_sha512 = f"apache-{project_name}-{version_name}.tar.gz.sha512"
evaluate_page_path = f"/draft/evaluate/{project_name}/{version_name}"
- evaluate_file_path = f"{evaluate_page_path}/{filename_sha512}"
+ report_file_path =
f"/report/{project_name}/{version_name}/{filename_sha512}"
logging.info(f"Starting hashing check test for {filename_sha512}")
@@ -525,9 +516,9 @@ def test_checks_01_hashing_sha512(page: sync_api.Page,
credentials: Credentials)
logging.info(f"Clicking 'Evaluate file' link for {filename_sha512}")
evaluate_link_locator.click()
- logging.info(f"Waiting for navigation to {evaluate_file_path}")
- wait_for_path(page, evaluate_file_path)
- logging.info(f"Successfully navigated to {evaluate_file_path}")
+ logging.info(f"Waiting for navigation to {report_file_path}")
+ wait_for_path(page, report_file_path)
+ logging.info(f"Successfully navigated to {report_file_path}")
logging.info("Verifying Hashing Check status")
hashing_check_div_locator =
page.locator("div.border:has(span.fw-bold:text-is('Hashing Check'))")
@@ -544,7 +535,7 @@ def test_checks_02_license_files(page: sync_api.Page,
credentials: Credentials)
version_name = "0.2"
filename_targz = f"apache-{project_name}-{version_name}.tar.gz"
evaluate_page_path = f"/draft/evaluate/{project_name}/{version_name}"
- evaluate_file_path = f"{evaluate_page_path}/{filename_targz}"
+ report_file_path =
f"/report/{project_name}/{version_name}/{filename_targz}"
logging.info(f"Starting License Files check test for {filename_targz}")
@@ -560,9 +551,9 @@ def test_checks_02_license_files(page: sync_api.Page,
credentials: Credentials)
logging.info(f"Clicking 'Evaluate file' link for {filename_targz}")
evaluate_link_locator.click()
- logging.info(f"Waiting for navigation to {evaluate_file_path}")
- wait_for_path(page, evaluate_file_path)
- logging.info(f"Successfully navigated to {evaluate_file_path}")
+ logging.info(f"Waiting for navigation to {report_file_path}")
+ wait_for_path(page, report_file_path)
+ logging.info(f"Successfully navigated to {report_file_path}")
logging.info("Verifying License Files check status")
license_check_div_locator =
page.locator("div.border:has(span.fw-bold:text-is('License Files'))")
@@ -578,15 +569,14 @@ def test_checks_03_license_headers(page: sync_api.Page,
credentials: Credentials
project_name = "tooling-test-example"
version_name = "0.2"
filename_targz = f"apache-{project_name}-{version_name}.tar.gz"
- evaluate_page_path = f"/draft/evaluate/{project_name}/{version_name}"
- evaluate_file_path = f"{evaluate_page_path}/{filename_targz}"
+ report_file_path =
f"/report/{project_name}/{version_name}/{filename_targz}"
logging.info(f"Starting License Headers check test for {filename_targz}")
# Don't repeat the link test, just go straight there
- logging.info(f"Navigating to evaluate file page {evaluate_file_path}")
- go_to_path(page, evaluate_file_path)
- logging.info(f"Successfully navigated to {evaluate_file_path}")
+ logging.info(f"Navigating to report page {report_file_path}")
+ go_to_path(page, report_file_path)
+ logging.info(f"Successfully navigated to {report_file_path}")
logging.info("Verifying License Headers check status")
header_check_div_locator =
page.locator("div.border:has(span.fw-bold:text-is('License Headers'))")
@@ -602,14 +592,13 @@ def test_checks_04_paths(page: sync_api.Page,
credentials: Credentials) -> None:
project_name = "tooling-test-example"
version_name = "0.2"
filename_sha512 = f"apache-{project_name}-{version_name}.tar.gz.sha512"
- evaluate_page_path = f"/draft/evaluate/{project_name}/{version_name}"
- evaluate_file_path = f"{evaluate_page_path}/{filename_sha512}"
+ report_file_path =
f"/report/{project_name}/{version_name}/{filename_sha512}"
logging.info(f"Starting Paths check test for {filename_sha512}")
- logging.info(f"Navigating to evaluate file page {evaluate_file_path}")
- go_to_path(page, evaluate_file_path)
- logging.info(f"Successfully navigated to {evaluate_file_path}")
+ logging.info(f"Navigating to report page {report_file_path}")
+ go_to_path(page, report_file_path)
+ logging.info(f"Successfully navigated to {report_file_path}")
# TODO: It's a bit strange to have the status in the check name
# But we have to do this because we need separate Recorder objects
@@ -627,14 +616,13 @@ def test_checks_05_signature(page: sync_api.Page,
credentials: Credentials) -> N
project_name = "tooling-test-example"
version_name = "0.2"
filename_asc = f"apache-{project_name}-{version_name}.tar.gz.asc"
- evaluate_page_path = f"/draft/evaluate/{project_name}/{version_name}"
- evaluate_file_path = f"{evaluate_page_path}/{filename_asc}"
+ report_file_path = f"/report/{project_name}/{version_name}/{filename_asc}"
logging.info(f"Starting Signature check test for {filename_asc}")
- logging.info(f"Navigating to evaluate file page {evaluate_file_path}")
- go_to_path(page, evaluate_file_path)
- logging.info(f"Successfully navigated to {evaluate_file_path}")
+ logging.info(f"Navigating to report page {report_file_path}")
+ go_to_path(page, report_file_path)
+ logging.info(f"Successfully navigated to {report_file_path}")
logging.info("Verifying Signature Check status")
signature_check_div_locator =
page.locator("div.border:has(span.fw-bold:text-is('Signature Check'))")
@@ -650,14 +638,13 @@ def test_checks_06_targz(page: sync_api.Page,
credentials: Credentials) -> None:
project_name = "tooling-test-example"
version_name = "0.2"
filename_targz = f"apache-{project_name}-{version_name}.tar.gz"
- evaluate_page_path = f"/draft/evaluate/{project_name}/{version_name}"
- evaluate_file_path = f"{evaluate_page_path}/{filename_targz}"
+ report_file_path =
f"/report/{project_name}/{version_name}/{filename_targz}"
logging.info(f"Starting Targz checks for {filename_targz}")
- logging.info(f"Navigating to evaluate file page {evaluate_file_path}")
- go_to_path(page, evaluate_file_path)
- logging.info(f"Successfully navigated to {evaluate_file_path}")
+ logging.info(f"Navigating to report page {report_file_path}")
+ go_to_path(page, report_file_path)
+ logging.info(f"Successfully navigated to {report_file_path}")
logging.info("Verifying Targz Integrity status")
integrity_div_locator =
page.locator("div.border:has(span.fw-bold:text-is('Targz Integrity'))")
@@ -995,7 +982,7 @@ def test_ssh_02_rsync_upload(page: sync_api.Page,
credentials: Credentials) -> N
logging.info("rsync upload test completed successfully")
logging.info(f"Extracting latest revision from {evaluate_path}")
- revision_link_locator =
page.locator(f'a[href^="/draft/revisions/{project_name}/{version_name}#"]')
+ revision_link_locator =
page.locator(f'a[href^="/revisions/{project_name}/{version_name}#"]')
sync_api.expect(revision_link_locator).to_be_visible()
revision_href = revision_link_locator.get_attribute("href")
if not revision_href:
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]