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