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-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new 597f41b  Make the form to start a release more type safe
597f41b is described below

commit 597f41b633ea00934f99ab46819f6fd9c84c4ce5
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Nov 10 20:01:01 2025 +0000

    Make the form to start a release more type safe
---
 atr/get/start.py                  | 90 +++++++++++++++++++++++++++++++++++++--
 atr/post/start.py                 | 30 +++++++++++--
 atr/shared/start.py               | 69 ++++++------------------------
 atr/templates/start-selected.html | 79 ----------------------------------
 playwright/test.py                |  2 +-
 5 files changed, 127 insertions(+), 143 deletions(-)

diff --git a/atr/get/start.py b/atr/get/start.py
index 69b7f68..8238eb6 100644
--- a/atr/get/start.py
+++ b/atr/get/start.py
@@ -15,13 +15,97 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import asfquart.base as base
 
 import atr.blueprints.get as get
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.form as form
+import atr.get.root as root
+import atr.htm as htm
+import atr.models.sql as sql
 import atr.shared as shared
+import atr.template as template
+import atr.util as util
 import atr.web as web
 
 
 @get.committer("/start/<project_name>")
-async def selected(session: web.Committer, project_name: str) -> 
web.WerkzeugResponse | str:
-    """Allow the user to start a new release draft, or handle its 
submission."""
-    return await shared.start.selected(session, project_name)
+async def selected(session: web.Committer, project_name: str) -> str:
+    await session.check_access(project_name)
+
+    async with db.session() as data:
+        project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE).demand(
+            base.ASFQuartException(f"Project {project_name} not found", 
errorcode=404)
+        )
+
+    releases = await interaction.all_releases(project)
+    content = await _render_page(project, releases)
+    return await template.blank(
+        title=f"Start a new release for {project.display_name}",
+        content=content,
+    )
+
+
+async def _render_page(project: sql.Project, releases: list[sql.Release]) -> 
htm.Element:
+    page = htm.Block()
+
+    page.h1[f"Start a new release for {project.display_name}"]
+    page.p[
+        "Starting a new release creates a ",
+        htm.strong["release candidate draft"],
+        ". You can then add files to this draft before promoting it for 
voting.",
+    ]
+    form.render_block(
+        page,
+        model_cls=shared.start.StartReleaseForm,
+        form_classes=".atr-canary.py-4.px-5.border.rounded",
+        submit_classes="btn-primary btn-lg",
+        submit_label="Start new release",
+        cancel_url=util.as_url(root.index),
+        defaults={"project_name": project.name},
+    )
+    if releases:
+        page.h2(".mt-5")["Existing releases"]
+        with page.block(htm.div, classes=".row") as row:
+            with row.block(htm.div, classes=".col-12") as col:
+                with col.block(htm.ul, classes=".list-unstyled.row.g-3") as ul:
+                    _existing_releases(ul, releases)
+
+    return page.collect()
+
+
+def _existing_releases(ul: htm.Block, releases: list[sql.Release], 
max_revisions: int = 18) -> None:
+    for i, release in enumerate(releases):
+        if i >= max_revisions:
+            break
+
+        phase_symbol = _get_phase_symbol(release.phase)
+        ul.li(".col-6.col-sm-4.col-md-3.col-lg-2")[
+            htm.div(".text-nowrap")[
+                htm.span(class_="atr-phase-symbol fs-6")[phase_symbol],
+                " ",
+                release.version,
+            ]
+        ]
+
+    if len(releases) > max_revisions:
+        ul.li(".col-6.col-sm-4.col-md-3.col-lg-2")[
+            htm.div(".text-center")[
+                htm.strong["..."],
+                " ",
+                htm.span(".text-muted.ms-1")[f"{len(releases) - max_revisions} 
more"],
+            ]
+        ]
+
+
+def _get_phase_symbol(phase: sql.ReleasePhase) -> str:
+    match phase:
+        case sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+            return "①"
+        case sql.ReleasePhase.RELEASE_CANDIDATE:
+            return "②"
+        case sql.ReleasePhase.RELEASE_PREVIEW:
+            return "③"
+        case sql.ReleasePhase.RELEASE:
+            return "Ⓡ"
diff --git a/atr/post/start.py b/atr/post/start.py
index bb3aeb8..ea3e52c 100644
--- a/atr/post/start.py
+++ b/atr/post/start.py
@@ -15,13 +15,37 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import asfquart.base as base
+import quart
 
 import atr.blueprints.post as post
+import atr.get as get
 import atr.shared as shared
+import atr.storage as storage
 import atr.web as web
 
 
 @post.committer("/start/<project_name>")
-async def selected(session: web.Committer, project_name: str) -> 
web.WerkzeugResponse | str:
-    """Allow the user to start a new release draft, or handle its 
submission."""
-    return await shared.start.selected(session, project_name)
[email protected](shared.start.StartReleaseForm)
+async def selected(
+    session: web.Committer, start_release_form: shared.start.StartReleaseForm, 
project_name: str
+) -> web.WerkzeugResponse:
+    await session.check_access(project_name)
+
+    try:
+        async with storage.write(session) as write:
+            wacp = await write.as_project_committee_participant(project_name)
+            new_release, _project = await wacp.release.start(
+                project_name,
+                start_release_form.version_name,
+            )
+
+        return await session.redirect(
+            get.compose.selected,
+            project_name=project_name,
+            version_name=new_release.version,
+            success="Release candidate draft created successfully",
+        )
+    except (web.FlashError, base.ASFQuartException) as e:
+        await quart.flash(str(e), "error")
+        return await session.redirect(get.start.selected, 
project_name=project_name)
diff --git a/atr/shared/start.py b/atr/shared/start.py
index 8566444..061a0d6 100644
--- a/atr/shared/start.py
+++ b/atr/shared/start.py
@@ -15,66 +15,21 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import pydantic
 
-import asfquart.base as base
-import quart
+import atr.form as form
+import atr.util as util
 
-import atr.db as db
-import atr.db.interaction as interaction
-import atr.forms as forms
-import atr.get as get
-import atr.models.sql as sql
-import atr.storage as storage
-import atr.template as template
-import atr.web as web
 
-
-class StartReleaseForm(forms.Typed):
-    project_name = forms.hidden()
-    version_name = forms.string(
+class StartReleaseForm(form.Form):
+    version_name: str = form.label(
         "Version",
-        placeholder="Examples: 1.2.3 or 2.5-M1",
-        description="Enter the version string for this new release.",
-    )
-    submit = forms.submit("Start new release")
-
-
-async def selected(session: web.Committer, project_name: str) -> 
web.WerkzeugResponse | str:
-    """Allow the user to start a new release draft, or handle its 
submission."""
-    await session.check_access(project_name)
-
-    async with db.session() as data:
-        project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE).demand(
-            base.ASFQuartException(f"Project {project_name} not found", 
errorcode=404)
-        )
-
-    form = await StartReleaseForm.create_form(
-        data=await quart.request.form if (quart.request.method == "POST") else 
None
+        "Enter the version string for this new release. Examples: 1.2.3 or 
2.5-M1",
     )
-    if (quart.request.method == "GET") or (not form.project_name.data):
-        form.project_name.data = project_name
-
-    if (quart.request.method == "POST") and (await form.validate_on_submit()):
-        try:
-            project_name = str(form.project_name.data)
-            version = str(form.version_name.data)
-            # We already have the project, so we only need to get the new 
release
-            async with storage.write(session) as write:
-                wacp = await 
write.as_project_committee_participant(project_name)
-                new_release, _project = await wacp.release.start(project_name, 
version)
-            # Redirect to the new draft's overview page on success
-            return await session.redirect(
-                get.compose.selected,
-                project_name=project.name,
-                version_name=new_release.version,
-                success="Release candidate draft created successfully",
-            )
-        except (web.FlashError, base.ASFQuartException) as e:
-            # Flash the error and let the code fall through to render the 
template below
-            await quart.flash(str(e), "error")
-
-    # Get all releases for the project
-    releases = await interaction.all_releases(project)
 
-    # Render the template for GET requests or POST requests with validation 
errors
-    return await template.render("start-selected.html", project=project, 
form=form, releases=releases)
+    @pydantic.field_validator("version_name", mode="after")
+    @classmethod
+    def validate_version_name(cls, value: str) -> str:
+        if error := util.version_name_error(value):
+            raise ValueError(error)
+        return value
diff --git a/atr/templates/start-selected.html 
b/atr/templates/start-selected.html
deleted file mode 100644
index 9b0d0af..0000000
--- a/atr/templates/start-selected.html
+++ /dev/null
@@ -1,79 +0,0 @@
-{% extends "layouts/base.html" %}
-
-{% block title %}
-  Start a new release for {{ project.display_name }} ~ ATR
-{% endblock title %}
-
-{% block content %}
-  <h1>Start a new release</h1>
-  <p>
-    Starting a new release creates a <strong>release candidate draft</strong>. 
You can then add files to this draft before promoting it for voting.
-  </p>
-
-  {{ forms.errors_summary(form) }}
-
-  <form method="post"
-        action="{{ as_url(post.start.selected, project_name=project.name) }}"
-        enctype="multipart/form-data"
-        class="atr-canary py-4 px-5 border rounded"
-        novalidate>
-    {{ form.hidden_tag() }}
-
-    <div class="mb-4">
-      <p class="fs-5">
-        <label class="form-label fs-5">Project:</label>
-        <span class="fw-semibold">{{ project.display_name }}</span>
-      </p>
-    </div>
-
-    <div class="mb-3">
-      {{ forms.label(form.version_name) }}
-      {{ forms.widget(form.version_name, classes="form-control 
form-control-lg", id=form.version_name.id) }}
-      {{ forms.errors(form.version_name) }}
-      {{ forms.description(form.version_name) }}
-    </div>
-
-    <div class="mt-4">
-      {{ form.submit(class_="btn btn-primary btn-lg") }}
-      <a href="{{ as_url(get.root.index) }}" class="btn btn-link 
ms-2">Cancel</a>
-    </div>
-  </form>
-
-  {% if releases %}
-    {% set max_revisions = 18 %}
-    <div class="mt-5">
-      <h2>Existing releases</h2>
-      <div class="row">
-        <div class="col-12">
-          <ul class="list-unstyled row g-3">
-            {% for release in releases[:max_revisions] %}
-              <li class="col-6 col-sm-4 col-md-3 col-lg-2">
-                <div class="text-nowrap">
-                  {% if release.phase.value == "release_candidate_draft" %}
-                    <span class="atr-phase-one atr-phase-symbol fs-6">{{ "①" 
}}</span>
-                  {% elif release.phase.value == "release_candidate" %}
-                    <span class="atr-phase-two atr-phase-symbol fs-6">{{ "②" 
}}</span>
-                  {% elif release.phase.value == "release_preview" %}
-                    <span class="atr-phase-three atr-phase-symbol fs-6">{{ "③" 
}}</span>
-                  {% elif release.phase.value == "release" %}
-                    <span class="atr-phase-symbol fs-6">{{ "Ⓡ" }}</span>
-                  {% endif %}
-                  {{ release.version }}
-                </div>
-              </li>
-            {% endfor %}
-            {% if releases|length > max_revisions %}
-              <li class="col-6 col-sm-4 col-md-3 col-lg-2">
-                <div class="text-center">
-                  <strong>...</strong>
-                  <span class="text-muted ms-1">{{ releases|length - 
max_revisions }} more</span>
-                </div>
-              </li>
-            {% endif %}
-          </ul>
-        </div>
-      </div>
-    </div>
-  {% endif %}
-
-{% endblock content %}
diff --git a/playwright/test.py b/playwright/test.py
index 1443630..26c6734 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -114,7 +114,7 @@ def lifecycle_01_add_draft(page: sync_api.Page, 
credentials: Credentials, versio
     version_name_locator.fill(version_name)
 
     logging.info("Submitting the start new release form")
-    submit_button_locator = page.locator('input[type="submit"][value="Start 
new release"]')
+    submit_button_locator = page.get_by_role("button", name="Start new 
release")
     sync_api.expect(submit_button_locator).to_be_enabled()
     submit_button_locator.click()
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to