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 %} · 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