You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/07/30 17:17:34 UTC
(tooling-trusted-releases) 01/02: #1449 - add interstitial page if metadata missing
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit e1799c50cfe1320743590418493eb22cf5cd5854
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Jul 30 17:44:24 2026 +0100
#1449 - add interstitial page if metadata missing
---
atr/get/projects.py | 14 +-----
atr/get/start.py | 60 ++++++++++++++++++++++-
atr/post/start.py | 36 ++++++++++++++
atr/server.py | 12 +++++
atr/shared/projects.py | 37 ++++++++++++++
atr/shared/start.py | 20 ++++++++
tests/unit/test_release_manager_writer_surface.py | 8 +--
7 files changed, 168 insertions(+), 19 deletions(-)
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 2a22dc27..09397074 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -1112,19 +1112,7 @@ async def _render_metadata_form(project: sql.Project) -> htm.Element:
model_cls=shared.projects.EditMetadataForm,
action=_view_action(project, "metadata"),
submit_label="Save",
- defaults={
- "project_key": str(project.key),
- "display_name": (project.name or "").removeprefix("Apache "),
- "description": project.description or "",
- "short_description": project.short_description or "",
- "homepage": project.homepage or "",
- "lifecycle_page": project.lifecycle_page or "",
- "download_page": project.download_page or "",
- "bug_database": project.bug_database or "",
- "mailing_lists": project.mailing_lists or "",
- "repositories": "\n".join(project.repositories),
- "standards": "\n".join(project.standards),
- },
+ defaults=shared.projects.edit_metadata_defaults(project),
)
return card.collect()
diff --git a/atr/get/start.py b/atr/get/start.py
index a4c9550b..d24eb194 100644
--- a/atr/get/start.py
+++ b/atr/get/start.py
@@ -37,16 +37,49 @@ import atr.web as web
@get.typed
-async def selected(session: web.Committer, _start: Literal["start"], project_key: safe.ProjectKey) -> str:
+async def metadata(
+ session: web.Committer,
+ _start: Literal["start"],
+ project_key: safe.ProjectKey,
+ _metadata: Literal["metadata"],
+) -> web.WerkzeugResponse | str:
+ """
+ URL: /start/<project_key>/metadata
+ """
+ await session.prevent_confusing_ui_display(project_key)
+ async with db.session() as data:
+ project = await data.project(key=str(project_key), status=sql.ProjectStatus.ACTIVE, _committee=True).demand(
+ base.ASFQuartException(f"Project {project_key} not found", errorcode=404)
+ )
+
+ missing = shared.start.missing_release_metadata(project)
+ if not missing:
+ return await session.redirect(selected, project_key=str(project_key))
+
+ committee = project.committee
+ can_edit = committee is not None and (
+ user.is_committee_member(committee, session.uid) or user.is_release_manager(committee, session.uid)
+ )
+ content = await _render_metadata_page(project, missing, can_edit=can_edit)
+ return await template.blank(title=f"Complete metadata for {project.display_name}", content=content)
+
+
+@get.typed
+async def selected(
+ session: web.Committer, _start: Literal["start"], project_key: safe.ProjectKey
+) -> web.WerkzeugResponse | str:
"""
URL: /start/<project_key>
"""
await session.prevent_confusing_ui_display(project_key)
async with db.session() as data:
project = await data.project(
- key=str(project_key), status=sql.ProjectStatus.ACTIVE, _release_policy=True
+ key=str(project_key), status=sql.ProjectStatus.ACTIVE, _committee=True, _release_policy=True
).demand(base.ASFQuartException(f"Project {project_key} not found", errorcode=404))
+ if shared.start.missing_release_metadata(project):
+ return await session.redirect(metadata, project_key=str(project_key))
+
releases = await interaction.all_releases(project)
if not user.can_view_embargoed_release(project.committee, session.uid, is_member=session.is_member):
releases = [r for r in releases if (not r.is_embargoed)]
@@ -121,6 +154,29 @@ def _get_phase_symbol(phase: sql.ReleasePhase) -> str:
return "Ⓡ"
+async def _render_metadata_page(project: sql.Project, missing: list[str], *, can_edit: bool) -> htm.Element:
+ page = htm.Block()
+ page.h1[f"Complete metadata for {project.display_name}"]
+ page.div(".alert.alert-danger")[
+ "Before you can start a release, the following project metadata must be set: ",
+ htm.strong[", ".join(missing)],
+ ".",
+ ]
+ if not can_edit:
+ page.p["A PMC member or release manager needs to complete this before a release can be started."]
+ return page.collect()
+
+ await form.render_block(
+ page,
+ model_cls=shared.projects.EditMetadataForm,
+ action=util.as_url(metadata, project_key=str(project.key)),
+ submit_label="Save metadata",
+ cancel_url=util.as_url(root.index),
+ defaults=shared.projects.edit_metadata_defaults(project),
+ )
+ return page.collect()
+
+
async def _render_page(project: sql.Project, releases: list[sql.Release], can_create_expedited: bool) -> htm.Element:
page = htm.Block()
diff --git a/atr/post/start.py b/atr/post/start.py
index c7288b4f..f26db495 100644
--- a/atr/post/start.py
+++ b/atr/post/start.py
@@ -20,6 +20,7 @@ import asfquart.base as base
import quart
import atr.blueprints.post as post
+import atr.db as db
import atr.get as get
import atr.models.safe as safe
import atr.shared as shared
@@ -27,6 +28,34 @@ import atr.storage as storage
import atr.web as web
+@post.typed
+async def metadata(
+ session: web.Committer,
+ _start: Literal["start"],
+ project_key: safe.ProjectKey,
+ _metadata: Literal["metadata"],
+ edit_metadata_form: shared.projects.EditMetadataForm,
+) -> web.WerkzeugResponse:
+ """
+ URL: /start/<project_key>/metadata
+
+ Save the metadata submitted from the start page, then return there so the
+ release can be started.
+ """
+ if str(edit_metadata_form.project_key) != str(project_key):
+ raise ValueError(f"Project key mismatch: {edit_metadata_form.project_key} != {project_key}")
+
+ try:
+ async with storage.write(session) as write:
+ warm = await write.as_project_release_manager(project_key)
+ await warm.project.edit_metadata(edit_metadata_form)
+ except (web.FlashError, base.ASFQuartException, storage.AccessError) as e:
+ await quart.flash(str(e), "error")
+ return await session.redirect(get.start.selected, project_key=str(project_key))
+
+ return await session.redirect(get.start.selected, project_key=str(project_key), success="Metadata saved.")
+
+
@post.typed
async def selected(
session: web.Committer,
@@ -38,6 +67,13 @@ async def selected(
URL: /start/<project_key>
"""
+ async with db.session() as data:
+ project = await data.project(key=str(project_key), _committee=True).demand(
+ base.ASFQuartException(f"Project {project_key} not found", errorcode=404)
+ )
+ if shared.start.missing_release_metadata(project):
+ return await session.redirect(get.start.selected, project_key=str(project_key))
+
try:
version = safe.VersionKey(start_release_form.version_key)
async with storage.write(session) as write:
diff --git a/atr/server.py b/atr/server.py
index c3d36bcc..f6d0b754 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -821,6 +821,10 @@ async def _initialise_test_environment(conf: type[config.AppConfig]) -> None:
name="Apache Test",
status=sql.ProjectStatus.ACTIVE,
committee_key="test",
+ description="Test project for the end-to-end suite.",
+ homepage="https://test.apache.org/",
+ download_page="https://test.apache.org/download",
+ repositories=["https://github.com/apache/test"],
created=datetime.datetime.now(datetime.UTC),
created_by="test",
)
@@ -834,6 +838,10 @@ async def _initialise_test_environment(conf: type[config.AppConfig]) -> None:
name="Apache Test Client",
status=sql.ProjectStatus.ACTIVE,
committee_key="test",
+ description="Test client project for the end-to-end suite.",
+ homepage="https://test.apache.org/",
+ download_page="https://test.apache.org/download",
+ repositories=["https://github.com/apache/test-client"],
created=datetime.datetime.now(datetime.UTC),
created_by="test",
)
@@ -861,6 +869,10 @@ async def _initialise_test_environment(conf: type[config.AppConfig]) -> None:
name="Apache Test Podling",
status=sql.ProjectStatus.ACTIVE,
committee_key="test-podling",
+ description="Test podling project for the end-to-end suite.",
+ homepage="https://test.apache.org/",
+ download_page="https://test.apache.org/download",
+ repositories=["https://github.com/apache/test-podling"],
created=datetime.datetime.now(datetime.UTC),
created_by="test",
)
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index dfb31b06..0690a61c 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -336,6 +336,43 @@ class EditMetadataForm(form.Form):
def validate_display_name(cls, val: str) -> str:
return _validate_display_name(val)
+ @pydantic.field_validator("description", mode="after")
+ @classmethod
+ def validate_description(cls, value: str) -> str:
+ if not value.strip():
+ raise ValueError("A project description is required.")
+ return value
+
+ @pydantic.field_validator("homepage", "download_page", mode="after")
+ @classmethod
+ def validate_required_urls(cls, value: pydantic.HttpUrl | None) -> pydantic.HttpUrl | None:
+ if value is None:
+ raise ValueError("A valid URL is required.")
+ return value
+
+ @pydantic.field_validator("repositories", mode="after")
+ @classmethod
+ def validate_repositories(cls, value: list[str]) -> list[str]:
+ if not value:
+ raise ValueError("At least one repository is required.")
+ return value
+
+
+def edit_metadata_defaults(project: sql.Project) -> dict[str, str]:
+ return {
+ "project_key": str(project.key),
+ "display_name": (project.name or "").removeprefix("Apache "),
+ "description": project.description or "",
+ "short_description": project.short_description or "",
+ "homepage": project.homepage or "",
+ "lifecycle_page": project.lifecycle_page or "",
+ "download_page": project.download_page or "",
+ "bug_database": project.bug_database or "",
+ "mailing_lists": project.mailing_lists or "",
+ "repositories": "\n".join(project.repositories),
+ "standards": "\n".join(project.standards),
+ }
+
class EditVersionSchemeForm(form.Form):
variant: EDIT_VERSION_SCHEME = form.value(EDIT_VERSION_SCHEME)
diff --git a/atr/shared/start.py b/atr/shared/start.py
index ce2fc0ba..ac865e54 100644
--- a/atr/shared/start.py
+++ b/atr/shared/start.py
@@ -15,11 +15,31 @@
# specific language governing permissions and limitations
# under the License.
+from typing import Final
+
import pydantic
import atr.form as form
+import atr.models.sql as sql
import atr.util as util
+_REQUIRED_METADATA_FIELDS: Final[tuple[tuple[str, str], ...]] = (
+ ("name", "Project name"),
+ ("description", "Project description"),
+ ("homepage", "Homepage"),
+ ("download_page", "Download page"),
+ ("repositories", "Repositories"),
+)
+
+
+def missing_release_metadata(project: sql.Project) -> list[str]:
+ """Return the labels of any required project metadata that is not yet set."""
+ missing = []
+ for attribute, label in _REQUIRED_METADATA_FIELDS:
+ if not getattr(project, attribute):
+ missing.append(label)
+ return missing
+
class StartReleaseForm(form.Form):
version_key: str = form.label(
diff --git a/tests/unit/test_release_manager_writer_surface.py b/tests/unit/test_release_manager_writer_surface.py
index 165863fa..97bc249b 100644
--- a/tests/unit/test_release_manager_writer_surface.py
+++ b/tests/unit/test_release_manager_writer_surface.py
@@ -183,14 +183,14 @@ async def test_project_metadata_rejects_foreign_committee() -> None:
csrf_token="test",
project_key=safe.ProjectKey("example"),
display_name="Example",
- description="",
+ description="Example project",
short_description="",
- homepage="",
+ homepage="https://example.apache.org/",
lifecycle_page="",
- download_page="",
+ download_page="https://example.apache.org/download",
bug_database="",
mailing_lists="",
- repositories=[],
+ repositories=["https://github.com/apache/example"],
standards=[],
)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org