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/31 13:54:26 UTC

(tooling-trusted-releases) branch arm updated: #1477 - catalog/release list changes

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


The following commit(s) were added to refs/heads/arm by this push:
     new 51605deb #1477 - catalog/release list changes
51605deb is described below

commit 51605deb00d8cf8744231a7c3b2f91626a49a497
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Fri Jul 31 14:54:02 2026 +0100

    #1477 - catalog/release list changes
---
 atr/catalog_site.py                               | 71 ++++++++++++++----
 atr/config.py                                     |  1 +
 atr/get/committees.py                             |  4 +-
 atr/get/projects.py                               | 89 +++++++++++------------
 atr/get/release.py                                |  3 +-
 atr/server.py                                     |  1 +
 atr/templates/catalog_site/catalog_site_base.html |  3 +-
 atr/templates/catalog_site/index.html             | 36 ++++++++-
 atr/templates/includes/topnav.html                | 14 ++--
 9 files changed, 149 insertions(+), 73 deletions(-)

diff --git a/atr/catalog_site.py b/atr/catalog_site.py
index efe842e8..e2355f39 100644
--- a/atr/catalog_site.py
+++ b/atr/catalog_site.py
@@ -26,6 +26,7 @@ only adds the file layout and the rendering.
 """
 
 import asyncio
+import dataclasses
 import datetime
 import json
 import pathlib
@@ -82,6 +83,8 @@ _ASSETS: Final[tuple[str, ...]] = (
     "svg/atr_logo.svg",
     "svg/apache_incubator.svg",
     "svg/ASF_short-horizontal-color.svg",
+    # The front page reuses the app's card filter, so the same script comes along
+    "js/src/card-grid.js",
 )
 
 # A committee counts as "current" while it still has a project that hasn't
@@ -113,7 +116,7 @@ async def generate_all(data: db.Session) -> None:
     # need its projects alongside it.
     committees = await data.committee(_projects=True).all()
     written = await _write_committee_pages(data, committees, site_dir)
-    await _write_index_pages(site_dir, written)
+    await _write_index_pages(data, site_dir, written)
     # Every committee and every project holds a directory here. Keyed off all of them
     # rather than the ones written, so a page that failed to render keeps what it had
     keep = {committee.key for committee in committees}
@@ -173,7 +176,46 @@ async def regenerate_project(data: db.Session, project_key: str) -> None:
     # A first release, or a last one archived, moves a committee on or off the front page
     # and between the Incubator's two columns, so the indexes are rebuilt every time too.
     # The assets are not: they only change with a deploy, so the full rebuild owns them.
-    await _write_index_pages(site_dir, committees)
+    await _write_index_pages(data, site_dir, committees)
+
+
+@dataclasses.dataclass
+class _CommitteeSummary:
+    release_count: int
+    latest_date: datetime.datetime | None
+    project_names: list[str]
+
+
+async def _committee_summaries(data: db.Session, committees: Sequence[sql.Committee]) -> dict[str, _CommitteeSummary]:
+    """Fold the released artifacts onto the committee card that shows them on the front page."""
+    releases = await data.release(phase=sql.ReleasePhase.RELEASE, _committee=True, _project=True).all()
+    counts: dict[str, int] = {}
+    latest: dict[str, datetime.datetime] = {}
+    for release in releases:
+        if release.is_archived:
+            continue
+        committee = release.project.committee
+        if committee is None:
+            continue
+        key = _INCUBATOR_COMMITTEE_KEY if committee.is_podling else committee.key
+        counts[key] = counts.get(key, 0) + 1
+        released = release.released
+        if (released is not None) and ((key not in latest) or (released > latest[key])):
+            latest[key] = released
+    # The search matches project names as well as committee names, so each card lists its
+    # projects. The Incubator lists its podlings, which are committees rather than projects.
+    names: dict[str, list[str]] = {
+        committee.key: sorted(project.display_name for project in committee.projects) for committee in committees
+    }
+    names[_INCUBATOR_COMMITTEE_KEY] = sorted(committee.display_name for committee in committees if committee.is_podling)
+    return {
+        key: _CommitteeSummary(
+            release_count=counts.get(key, 0),
+            latest_date=latest.get(key),
+            project_names=names.get(key, []),
+        )
+        for key in (set(counts) | set(names))
+    }
 
 
 def _last_updated() -> str:
@@ -228,15 +270,13 @@ async def _write_attic_index(
     projects: Sequence[sql.Project],
     committees: Sequence[sql.Committee],
 ) -> None:
-    # A retired PMC is filed either under the Attic itself or as a committee that kept
-    # its own key, but a reader wants one list, so the two are merged by name here.
-    # Nothing here still releases, so each entry opens straight at its archive.
-    entries = [(project.display_name, f"../{project.key}/archive.html") for project in projects]
+    # A retired PMC is filed either under the Attic itself or as a committee that kept its own
+    # key. Either way it's the projects a reader wants, so both are flattened into one list by
+    # their "Apache ..." name, each opening at its archive.
+    retired_projects = list(projects)
     for committee in committees:
-        path = f"{committee.key}/index.html"
-        if len(committee.projects) == 1:
-            path = f"{committee.projects[0].key}/archive.html"
-        entries.append((committee.display_name, f"../{path}"))
+        retired_projects.extend(committee.projects)
+    entries = [(project.display_name, f"../{project.key}/archive.html") for project in retired_projects]
     entries.sort(key=lambda entry: entry[0].lower())
     html = _ENVIRONMENT.get_template("attic.html").render(committee=attic, entries=entries, root="../")
     await _write(site_dir / attic.key / "index.html", html)
@@ -305,7 +345,7 @@ async def _write_committee_pages(
     return written
 
 
-async def _write_index_pages(site_dir: safe.StatePath, committees: Sequence[sql.Committee]) -> None:
+async def _write_index_pages(data: db.Session, site_dir: safe.StatePath, committees: Sequence[sql.Committee]) -> None:
     """Write the three pages that index the others: the front page, Incubator and Attic."""
     current: list[sql.Committee] = []
     current_podlings: list[sql.Committee] = []
@@ -333,7 +373,8 @@ async def _write_index_pages(site_dir: safe.StatePath, committees: Sequence[sql.
         current.append(attic)
     # A committee whose projects have all retired keeps its pages, but drops off the
     # front page, so the index stays a list of where releases are still coming from.
-    await _write_root_index(site_dir, current)
+    summaries = await _committee_summaries(data, committees)
+    await _write_root_index(site_dir, current, summaries)
 
 
 async def _write_incubator_index(
@@ -443,9 +484,11 @@ async def _write_release(
     await _write(release_dir / "artifacts.json", version.model_dump_json(indent=2))
 
 
-async def _write_root_index(site_dir: safe.StatePath, committees: Sequence[sql.Committee]) -> None:
+async def _write_root_index(
+    site_dir: safe.StatePath, committees: Sequence[sql.Committee], summaries: dict[str, _CommitteeSummary]
+) -> None:
     ordered = sorted(committees, key=lambda committee: committee.display_name.lower())
     await _write(
         site_dir / "index.html",
-        _ENVIRONMENT.get_template("index.html").render(committees=ordered, root=""),
+        _ENVIRONMENT.get_template("index.html").render(committees=ordered, summaries=summaries, root=""),
     )
diff --git a/atr/config.py b/atr/config.py
index 6eb6db0f..8d7b2db0 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -102,6 +102,7 @@ class AppConfig:
     GITHUB_TOKEN = _config_secrets("GITHUB_TOKEN", STATE_DIR, default=None, cast=str)
     CAP_API_BASE_URL = decouple.config("CAP_API_BASE_URL", default="https://cap-test.apache.org")
     CAP_ROLE_ACCOUNT_TOKEN = _config_secrets("CAP_ROLE_ACCOUNT_TOKEN", STATE_DIR, default=None, cast=str)
+    RELEASE_CATALOG_URL = decouple.config("RELEASE_CATALOG_URL", default="https://catalog.apache.org/")
 
     DEBUG = False
     TEMPLATES_AUTO_RELOAD = False
diff --git a/atr/get/committees.py b/atr/get/committees.py
index 832d45da..e654b5f2 100644
--- a/atr/get/committees.py
+++ b/atr/get/committees.py
@@ -37,7 +37,7 @@ import atr.web as web
 
 
 @get.typed
-async def directory(_session: web.Public, _committees: Literal["committees"]) -> str:
+async def directory(_session: web.Committer, _committees: Literal["committees"]) -> str:
     """
     URL: /committees
     Main committee directory page.
@@ -62,7 +62,7 @@ async def directory(_session: web.Public, _committees: Literal["committees"]) ->
 
 
 @get.typed
-async def view(session: web.Public, _committees: Literal["committees"], name: safe.CommitteeKey) -> str:
+async def view(session: web.Committer, _committees: Literal["committees"], name: safe.CommitteeKey) -> str:
     """
     URL: /committees/<name>
     """
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 09397074..a0267b70 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -157,7 +157,7 @@ async def project_yaml(
 
 
 @get.typed
-async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
+async def projects(session: web.Committer, _projects: Literal["projects"]) -> str:
     """
     URL: /projects
     Main project directory page.
@@ -167,9 +167,7 @@ async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
         approvals = await data.approval_request(
             status_in=[sql.ApprovalStatus.PENDING, sql.ApprovalStatus.APPROVED]
         ).all()
-        release_counts: dict[str, tuple[int, int]] = {}
-        if session is not None:
-            release_counts = await interaction.project_release_counts(data)
+        release_counts = await interaction.project_release_counts(data)
 
     # Release archival requests surface on the release's own file page, not here
     approvals_by_project = {a.project_key: a for a in approvals if a.action != sql.ApprovalAction.ARCHIVE_RELEASE}
@@ -179,48 +177,47 @@ async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
     )
 
     action_forms: dict[str, htm.Element] = {}
-    if session is not None:
-        for project in projects:
-            if not project.is_active:
-                continue
-            if not (user.is_committee_member(project.committee, session.uid) or session.is_admin):
-                continue
-            approval = approvals_by_project.get(str(project.key))
-            if approval is not None:
-                action_forms[str(project.key)] = await _approval_request_element(approval)
-                continue
-            if project.committee and committee_project_counts[str(project.committee.key)] <= 1:
-                continue
-            total_releases, non_draft_releases = release_counts.get(str(project.key), (0, 0))
-            if total_releases == 0:
-                action_forms[str(project.key)] = await form.render(
-                    model_cls=shared.projects.DeleteSelectedProject,
-                    action=util.as_url(post.projects.delete),
-                    form_classes=".d-inline-block.m-0",
-                    submit_classes="btn-sm btn-outline-danger",
-                    submit_label="Request deletion",
-                    empty=True,
-                    defaults={"project_key": str(project.key)},
-                    confirm=(
-                        "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
-                        " ready to delete only if the vote passes, and you must then return to complete it."
-                    ),
-                )
-            elif non_draft_releases == 0:
-                action_forms[str(project.key)] = await form.render(
-                    model_cls=shared.projects.ArchiveSelectedProject,
-                    action=util.as_url(post.projects.archive),
-                    form_classes=".d-inline-block.m-0",
-                    submit_classes="btn-sm btn-outline-secondary",
-                    submit_label="Request archival",
-                    empty=True,
-                    defaults={"project_key": str(project.key)},
-                    confirm=(
-                        "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
-                        " ready to archive only if the vote passes, and you must then return to complete it, which"
-                        " deletes the draft releases and retires the project."
-                    ),
-                )
+    for project in projects:
+        if not project.is_active:
+            continue
+        if not (user.is_committee_member(project.committee, session.uid) or session.is_admin):
+            continue
+        approval = approvals_by_project.get(str(project.key))
+        if approval is not None:
+            action_forms[str(project.key)] = await _approval_request_element(approval)
+            continue
+        if project.committee and committee_project_counts[str(project.committee.key)] <= 1:
+            continue
+        total_releases, non_draft_releases = release_counts.get(str(project.key), (0, 0))
+        if total_releases == 0:
+            action_forms[str(project.key)] = await form.render(
+                model_cls=shared.projects.DeleteSelectedProject,
+                action=util.as_url(post.projects.delete),
+                form_classes=".d-inline-block.m-0",
+                submit_classes="btn-sm btn-outline-danger",
+                submit_label="Request deletion",
+                empty=True,
+                defaults={"project_key": str(project.key)},
+                confirm=(
+                    "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
+                    " ready to delete only if the vote passes, and you must then return to complete it."
+                ),
+            )
+        elif non_draft_releases == 0:
+            action_forms[str(project.key)] = await form.render(
+                model_cls=shared.projects.ArchiveSelectedProject,
+                action=util.as_url(post.projects.archive),
+                form_classes=".d-inline-block.m-0",
+                submit_classes="btn-sm btn-outline-secondary",
+                submit_label="Request archival",
+                empty=True,
+                defaults={"project_key": str(project.key)},
+                confirm=(
+                    "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
+                    " ready to archive only if the vote passes, and you must then return to complete it, which"
+                    " deletes the draft releases and retires the project."
+                ),
+            )
 
     return await template.render("projects.html", projects=projects, action_forms=action_forms)
 
diff --git a/atr/get/release.py b/atr/get/release.py
index 8fd0016b..56c4738b 100644
--- a/atr/get/release.py
+++ b/atr/get/release.py
@@ -62,12 +62,11 @@ async def finished(
 
 
 @get.typed
-async def releases(_session: web.Public, _releases: Literal["releases"]) -> str:
+async def releases(_session: web.Committer, _releases: Literal["releases"]) -> str:
     """
     URL: /releases
     View all releases.
     """
-    # Releases are public, so we don't need to filter by user
     async with db.session() as data:
         releases = await data.release(
             phase=sql.ReleasePhase.RELEASE,
diff --git a/atr/server.py b/atr/server.py
index f6d0b754..c3bbb38b 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -312,6 +312,7 @@ def _app_setup_context(app: base.QuartApp) -> None:
             "is_cyclonedx_xml_fn": analysis.is_cyclonedx_xml,
             "is_test_mode": config.is_test_mode(),
             "post": post,
+            "release_catalog_url": config.get().RELEASE_CATALOG_URL,
             "static_url": util.static_url,
             "topnav_unfinished_releases": topnav_unfinished_releases,
             "topnav_user_projects": topnav_user_projects,
diff --git a/atr/templates/catalog_site/catalog_site_base.html b/atr/templates/catalog_site/catalog_site_base.html
index 2dc973a0..374b1a43 100644
--- a/atr/templates/catalog_site/catalog_site_base.html
+++ b/atr/templates/catalog_site/catalog_site_base.html
@@ -22,7 +22,7 @@
                alt="The Apache Software Foundation"
                class="asf-logo" />
         </a>
-        <span class="navbar-text ms-3 me-auto fw-semibold">Release catalog</span>
+        <span class="navbar-text ms-3 me-auto fw-semibold">ASF Release catalog</span>
       </div>
     </nav>
 
@@ -32,5 +32,6 @@
     </main>
 
     {% include "includes/footer.html" %}
+    {% block scripts %}{% endblock scripts %}
   </body>
 </html>
diff --git a/atr/templates/catalog_site/index.html b/atr/templates/catalog_site/index.html
index c879dc05..74f622a1 100644
--- a/atr/templates/catalog_site/index.html
+++ b/atr/templates/catalog_site/index.html
@@ -1,6 +1,6 @@
 {% extends "catalog_site_base.html" %}
 
-{%- block title -%}Apache Release Catalog{%- endblock title -%}
+{%- block title -%}ASF Release Catalog{%- endblock title -%}
 
 {%- block description -%}Released and archived artifacts for Apache Software Foundation projects.{%- endblock description -%}
 
@@ -8,10 +8,23 @@
   <h1 class="mb-3">Release catalog</h1>
   <p class="text-muted">Browse released and archived artifacts by committee.</p>
   {% if committees %}
+    <div class="mb-3">
+      <input type="text"
+             id="project-filter"
+             aria-label="Committee or project name filter"
+             class="form-control d-inline-block w-auto" />
+      <button type="button" class="btn btn-primary" id="filter-button">Filter</button>
+    </div>
+    <div class="mb-3">
+      <p>
+        Total count: <span id="committee-count">{{ committees|length }}</span>
+      </p>
+    </div>
     <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4">
       {% for committee in committees %}
+        {% set summary = summaries.get(committee.key) %}
         <div class="col">
-          <div class="card h-100 shadow-sm">
+          <div class="card h-100 shadow-sm page-committee-card">
             <div class="card-body">
               <div class="row align-items-center">
                 <div class="col">
@@ -26,6 +39,16 @@
                     <a href="{{ root }}{{ target }}"
                        class="text-decoration-none stretched-link">{{ committee.display_name }}</a>
                   </h2>
+                  {% if summary %}
+                    <p class="text-muted small mb-0 mt-1">
+                      {{ summary.release_count }} {{ "release" if summary.release_count == 1 else "releases" }}
+                      {%- if summary.latest_date %} &middot; latest {{ summary.latest_date.strftime("%Y-%m-%d") }}{% endif %}
+                    </p>
+                    {# The projects ride along hidden so a search matches on their names too #}
+                    {% for name in summary.project_names %}
+                      <span class="d-none" data-project-name="{{ name }}"></span>
+                    {% endfor %}
+                  {% endif %}
                 </div>
                 <div class="col-5 col-lg-4 text-center">
                   <div class="atr-catalog-logo-container">
@@ -47,3 +70,12 @@
     <p class="text-muted">No committees catalogued yet.</p>
   {% endif %}
 {% endblock content %}
+
+{% block scripts %}
+  <script src="{{ root }}assets/js/src/card-grid.js"></script>
+  <script>
+    document.addEventListener("DOMContentLoaded", () => {
+      initCardGrid({ cardSelector: ".page-committee-card", countId: "committee-count", noun: "committees" });
+    });
+  </script>
+{% endblock scripts %}
diff --git a/atr/templates/includes/topnav.html b/atr/templates/includes/topnav.html
index b128649d..450758a8 100644
--- a/atr/templates/includes/topnav.html
+++ b/atr/templates/includes/topnav.html
@@ -84,16 +84,18 @@
           <ul class="dropdown-menu"
               id="menuCatalog"
               aria-labelledby="dropdownCatalog">
+            {% if current_user %}
+              <li>
+                <a class="dropdown-item" href="{{ as_url(get.release.releases) }}"><i class="bi bi-view-list"></i>
+                  Current</a>
+              </li>
+            {% endif %}
             <li>
-              <a class="dropdown-item" href="{{ as_url(get.release.releases) }}"><i class="bi bi-view-list"></i>
+              <a class="dropdown-item" href="{{ release_catalog_url }}" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i>
                 Catalog</a>
             </li>
             <li>
-              <a class="dropdown-item" href="{{ as_url(get.committees.directory) }}"><i class="bi bi-collection"></i>
-                Committees</a>
-            </li>
-            <li>
-              <a class="dropdown-item" href="https://downloads.apache.org/"><i class="bi bi-download"></i>
+              <a class="dropdown-item" href="https://downloads.apache.org/" target="_blank" rel="noopener"><i class="bi bi-download"></i>
                 Downloads</a>
             </li>
           </ul>


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org