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/29 15:54:53 UTC

(tooling-trusted-releases) 01/01: #1470 - Add cycles to catalog

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 e6a1d5641345426ac8e5f5fe7c3eb254ca48527c
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Wed Jul 29 16:05:47 2026 +0100

    #1470 - Add cycles to catalog
---
 atr/catalog_site.py                             |  4 ++
 atr/cycles.py                                   | 21 +++++++++--
 atr/get/catalog.py                              |  5 ++-
 atr/get/projects.py                             | 28 +++++++-------
 atr/shared/catalog.py                           | 49 +++++++++++++++++++++++--
 atr/storage/writers/policy.py                   |  3 ++
 atr/storage/writers/project.py                  |  2 +
 atr/templates/catalog.html                      | 14 +++----
 atr/templates/catalog_site/_release_list.html   | 18 +++++++++
 atr/templates/catalog_site/project.html         |  2 +-
 atr/templates/catalog_site/project_archive.html |  2 +-
 tests/unit/test_policy_writer.py                | 26 +++++++++++++
 12 files changed, 139 insertions(+), 35 deletions(-)

diff --git a/atr/catalog_site.py b/atr/catalog_site.py
index d4960bd0..3773745d 100644
--- a/atr/catalog_site.py
+++ b/atr/catalog_site.py
@@ -387,6 +387,8 @@ async def _write_project(
     assembled = catalog.assemble(project.version_method, artifacts, project_cycles, now)
     current = [v for v in assembled.versions if v.status == "released"]
     archived = [v for v in assembled.versions if v.status == "archived"]
+    current_groups = catalog.cycle_groups(current, project_cycles, now) if assembled.grouped else []
+    archived_groups = catalog.cycle_groups(archived, project_cycles, now) if assembled.grouped else []
 
     # A committee's own project is its index page, so it sits a level up from the
     # rest and everything below it steps back one fewer time.
@@ -411,6 +413,7 @@ async def _write_project(
             subprojects=subprojects,
             archived_subprojects=archived_subprojects,
             versions=current,
+            groups=current_groups,
             has_archive=bool(archived),
             retired=retired,
             keys_url=keys_url,
@@ -423,6 +426,7 @@ async def _write_project(
             committee=committee,
             project=project,
             versions=archived,
+            groups=archived_groups,
             retired=retired,
             keys_url=keys_url,
             root=root,
diff --git a/atr/cycles.py b/atr/cycles.py
index aca623ce..31dcfab6 100644
--- a/atr/cycles.py
+++ b/atr/cycles.py
@@ -39,7 +39,7 @@ if TYPE_CHECKING:
 
     import atr.db as db
 
-_DEFAULT_CYCLE: Final[str] = "default"
+DEFAULT_CYCLE: Final[str] = "default"
 
 
 def cycle_name_for_version(project: sql.Project, version: str) -> str:
@@ -53,7 +53,7 @@ def cycle_name_for_version(project: sql.Project, version: str) -> str:
     capture groups, or capture-group 1 captured the empty string.
     """
     if project.cycle_match is None:
-        return _DEFAULT_CYCLE
+        return DEFAULT_CYCLE
 
     match = re.fullmatch(project.cycle_match, version)
     if match is None:
@@ -69,6 +69,21 @@ def cycle_name_for_version(project: sql.Project, version: str) -> str:
     return cycle
 
 
+def display_name(cycle_name: str) -> str:
+    """Heading for a cycle. The default one is the catch-all, not a lifecycle."""
+    return "No lifecycle information" if cycle_name == DEFAULT_CYCLE else f"Version {cycle_name}"
+
+
+def headings_needed(cycle_names: Iterable[str]) -> bool:
+    """Whether a project's cycles are worth heading at all.
+
+    A project sitting on nothing but the implicit default cycle has no lifecycle
+    to show, so its releases stay in one flat list.
+    """
+    names = list(cycle_names)
+    return (len(names) > 1) or any(name != DEFAULT_CYCLE for name in names)
+
+
 def is_latest_in_cycle(project: sql.Project, release: sql.Release, candidates: Iterable[sql.Release]) -> bool:
     """Whether `release` is the latest full, unarchived release in its cycle.
 
@@ -166,7 +181,7 @@ async def reassign_release_cycles(data: db.Session, project: sql.Project) -> Non
         try:
             cycle_name = cycle_name_for_version(project, release.version)
         except ValueError:
-            cycle_name = _DEFAULT_CYCLE
+            cycle_name = DEFAULT_CYCLE
         new_cycle_key = f"{project.key}-{cycle_name}"
         if release.cycle_key == new_cycle_key:
             continue
diff --git a/atr/get/catalog.py b/atr/get/catalog.py
index 942a3f5e..79b72a6b 100644
--- a/atr/get/catalog.py
+++ b/atr/get/catalog.py
@@ -43,18 +43,19 @@ async def project(_session: web.Public, _catalog: Literal["catalog"], project_ke
         artifacts = await data.artifact(project_key=project_obj.key, _release=True).all()
         project_cycles = await data.project_cycle(project_key=project_obj.key).all()
 
+    now = datetime.datetime.now(datetime.UTC)
     assembled = catalog.assemble(
         project_obj.version_method,
         artifacts,
         project_cycles,
-        datetime.datetime.now(datetime.UTC),
+        now,
     )
 
     return await template.render(
         "catalog.html",
         project=project_obj,
         versions=assembled.versions,
-        cycles=assembled.cycles,
+        groups=catalog.cycle_groups(assembled.versions, project_cycles, now) if assembled.grouped else [],
         grouped=assembled.grouped,
         api=api,
         format_datetime=util.format_datetime,
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 9ac1324d..be0df261 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -28,6 +28,7 @@ import strictyaml
 import atr.blueprints.get as get
 import atr.config as config
 import atr.construct as construct
+import atr.cycles as cycles
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.form as form
@@ -269,7 +270,7 @@ async def view(
             _release_policy=True,
             _releases=True,
         ).demand(base.ASFQuartException(f"Project {project_key} not found", errorcode=404))
-        cycles = list(await data.project_cycle(project_key=str(project_key)).all())
+        project_cycles = list(await data.project_cycle(project_key=str(project_key)).all())
         active_committee_projects = 0
         if project.committee is not None:
             active_committee_projects = len(
@@ -301,7 +302,9 @@ async def view(
 
     released_cycle_keys = {r.cycle_key for r in project.releases_including_embargoed}
     visible_cycle_keys = {r.cycle_key for r in visible_releases}
-    cycles = [c for c in cycles if (c.cycle_key not in released_cycle_keys) or (c.cycle_key in visible_cycle_keys)]
+    project_cycles = [
+        c for c in project_cycles if (c.cycle_key not in released_cycle_keys) or (c.cycle_key in visible_cycle_keys)
+    ]
 
     page = htm.Block()
 
@@ -346,7 +349,7 @@ async def view(
         can_manage_taxonomy,
         can_start_release,
         project,
-        cycles,
+        project_cycles,
         visible_releases,
     )
     page.append(tabs)
@@ -505,11 +508,6 @@ def _asf_yaml_recipient_blocks(project: sql.Project) -> dict[str, object]:
     return policy
 
 
-def _cycle_display_name(cycle: sql.ProjectCycle) -> str:
-    # The "default" cycle is what every project gets when it has no cycle_match
-    return "No lifecycle information" if cycle.cycle == "default" else f"Version {cycle.cycle}"
-
-
 def _cycle_has_dates(cycle: sql.ProjectCycle) -> bool:
     return any(getattr(cycle, attr) is not None for attr in ("start", "begin", "latest", "eod", "eos", "eol"))
 
@@ -578,7 +576,7 @@ async def _generate_tabs(
     can_manage_taxonomy: bool,
     can_start_release: bool,
     project: sql.Project,
-    cycles: list[sql.ProjectCycle],
+    project_cycles: list[sql.ProjectCycle],
     releases: list[sql.Release],
 ) -> htpy.Element:
     tab_items: list[htm.Tab] = [
@@ -587,7 +585,7 @@ async def _generate_tabs(
             label="Releases",
             render=lambda: _render_releases_tab(
                 project,
-                cycles,
+                project_cycles,
                 releases,
                 can_edit_policy=can_edit_policy,
                 can_start_release=can_start_release,
@@ -1277,7 +1275,7 @@ async def _render_releases_sections(
 
 async def _render_releases_tab(
     project: sql.Project,
-    cycles: list[sql.ProjectCycle],
+    project_cycles: list[sql.ProjectCycle],
     releases: list[sql.Release],
     *,
     can_edit_policy: bool,
@@ -1301,12 +1299,12 @@ async def _render_releases_tab(
         # cycle dates set. Once cycles get used or dates get filled in, headings
         # surface automatically. The card / form surfaces whenever policy can be edited, so a
         # PMC of a simple-default project can still set eod/eos/eol/lts.
-        show_cycle_heading = (len(cycles) > 1) or any(c.cycle != "default" for c in cycles)
+        show_cycle_heading = cycles.headings_needed(c.cycle for c in project_cycles)
 
         # Newest first, as the old per-phase queries returned them.
         releases = sorted(releases, key=lambda r: r.created, reverse=True)
 
-        for cycle in cycles:
+        for cycle in project_cycles:
             cycle_drafts = [
                 r
                 for r in releases
@@ -1331,11 +1329,11 @@ async def _render_releases_tab(
                 continue
 
             if show_cycle_heading:
-                body.h2(".mt-4.mb-3")[_cycle_display_name(cycle)]
+                body.h2(".mt-4.mb-3")[cycles.display_name(cycle.cycle)]
 
             # Skip cycle dates UI for the default cycle - it's the catch-all for
             # projects without cycle_match and shouldn't carry lifecycle dates.
-            if cycle.cycle != "default":
+            if cycle.cycle != cycles.DEFAULT_CYCLE:
                 if can_edit_policy:
                     body.append(await _render_cycle_dates_form(project, cycle))
                 elif cycle_has_dates:
diff --git a/atr/shared/catalog.py b/atr/shared/catalog.py
index 3787255b..e6865d29 100644
--- a/atr/shared/catalog.py
+++ b/atr/shared/catalog.py
@@ -21,6 +21,7 @@ import datetime
 from collections.abc import Sequence
 from typing import Final, Literal
 
+import atr.cycles as cycles
 import atr.models as models
 import atr.models.safe as safe
 import atr.models.sql as sql
@@ -36,6 +37,19 @@ class CatalogProject:
     grouped: bool
 
 
+@dataclasses.dataclass
+class CycleGroup:
+    """A cycle and its versions, ready to render.
+
+    `label` is None when the project has nothing worth heading - a lone default
+    cycle - so a template iterates without carrying the rule itself.
+    """
+
+    label: str | None
+    lifecycle: str | None
+    versions: Sequence[models.api.CatalogVersion]
+
+
 def assemble(
     version_method: sql.VersionMethod,
     artifacts: Sequence[sql.Artifact],
@@ -51,10 +65,10 @@ def assemble(
     if grouped:
         # Cycle labels are unique within a project, so they group and look up safely.
         cycles_by_label = {cycle.cycle: cycle for cycle in project_cycles}
-        cycles = _cycles(versions, cycles_by_label, now)
+        catalog_cycles = _cycles(versions, cycles_by_label, now)
     else:
-        cycles = []
-    return CatalogProject(versions=versions, cycles=cycles, grouped=grouped)
+        catalog_cycles = []
+    return CatalogProject(versions=versions, cycles=catalog_cycles, grouped=grouped)
 
 
 def cle_project_url(atr_host: str, project_key: str) -> str:
@@ -65,6 +79,29 @@ def cle_release_url(atr_host: str, project_key: str, version: str) -> str:
     return f"https://{atr_host}/api/cle/release/{project_key}/{version}"
 
 
+def cycle_groups(
+    versions: Sequence[models.api.CatalogVersion],
+    project_cycles: Sequence[sql.ProjectCycle],
+    now: datetime.datetime,
+) -> list[CycleGroup]:
+    """Group versions into their cycles for display, most recently active first.
+
+    `assemble` groups every version of a project at once and returns the API shape.
+    Pages regroup here instead, so that a caller which has split the versions -
+    releases on one page, archives on another - labels each side by the same rule.
+    """
+    grouped = _cycles(versions, {cycle.cycle: cycle for cycle in project_cycles}, now)
+    headed = cycles.headings_needed(entry.cycle for entry in grouped)
+    return [
+        CycleGroup(
+            label=cycles.display_name(entry.cycle) if headed else None,
+            lifecycle=entry.lifecycle,
+            versions=entry.versions,
+        )
+        for entry in grouped
+    ]
+
+
 def _artifact(row: sql.Artifact, downloadable: bool, archived: bool) -> models.api.CatalogArtifact:
     artifact_url: str | None = None
     signature_url: str | None = None
@@ -135,7 +172,11 @@ def _grouped_layout(version_method: sql.VersionMethod) -> bool:
     return version_method in _GROUPED_METHODS
 
 
-def _lifecycle_badge(cycle: sql.ProjectCycle, now: datetime.datetime) -> str:
+def _lifecycle_badge(cycle: sql.ProjectCycle, now: datetime.datetime) -> str | None:
+    # The default cycle is the catch-all for projects without cycle_match. It carries no
+    # lifecycle dates, so it has no state to report either.
+    if cycle.cycle == cycles.DEFAULT_CYCLE:
+        return None
     if cycle.lts:
         return "LTS"
     if (cycle.eol is not None) and (cycle.eol <= now):
diff --git a/atr/storage/writers/policy.py b/atr/storage/writers/policy.py
index 10ef3b3e..f3fa3b36 100644
--- a/atr/storage/writers/policy.py
+++ b/atr/storage/writers/policy.py
@@ -27,6 +27,7 @@ import strictyaml
 import strictyaml.ruamel.error as error
 
 import atr.calver as calver
+import atr.catalog_site as catalog_site
 import atr.construct as construct
 import atr.cycles as cycles
 import atr.db as db
@@ -230,6 +231,7 @@ class ReleaseManager(CommitteeParticipant):
         if cycle.lts != form.lts:
             cycle.lts = form.lts
 
+        await catalog_site.queue_regeneration(self.__data, self.__asf_uid, str(project_key))
         await self.__commit_and_log(str(project_key))
 
     async def edit_policy(
@@ -313,6 +315,7 @@ class ReleaseManager(CommitteeParticipant):
         project.branch_template = form.branch_template.strip() or None
 
         await cycles.reassign_release_cycles(self.__data, project)
+        await catalog_site.queue_regeneration(self.__data, self.__asf_uid, str(project_key))
         await self.__commit_and_log(str(project_key))
 
     async def edit_vote(self, form: shared.projects.VotePolicyForm) -> None:
diff --git a/atr/storage/writers/project.py b/atr/storage/writers/project.py
index 83fa3c6a..6be8008f 100644
--- a/atr/storage/writers/project.py
+++ b/atr/storage/writers/project.py
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING
 
 import sqlalchemy.exc
 
+import atr.catalog_site as catalog_site
 import atr.constants as constants
 import atr.cycles as cycles
 import atr.db as db
@@ -662,6 +663,7 @@ class FoundationAdmin(FoundationCommitter):
 
         if version_scheme_fields & provided:
             await cycles.reassign_release_cycles(self.__data, project)
+            await catalog_site.queue_regeneration(self.__data, self.__asf_uid, project.key)
 
 
 async def _build_and_add_project(
diff --git a/atr/templates/catalog.html b/atr/templates/catalog.html
index 8c843abc..08bff7b7 100644
--- a/atr/templates/catalog.html
+++ b/atr/templates/catalog.html
@@ -20,19 +20,15 @@
       <i class="bi bi-info-circle me-2"></i> No catalogued releases yet for {{ project.display_name }}.
     </div>
   {% elif grouped %}
-    {% for cycle_entry in cycles %}
+    {% for group in groups %}
       <div class="mb-4">
-        {# The default cycle has no real lifecycle, so skip its header when it stands alone and
-           label it the way the project page does when it sits alongside genuine cycles. #}
-        {% if cycle_entry.cycle != "default" %}
+        {% if group.label %}
           <h2 class="fs-4 border-bottom pb-1">
-            Version {{ cycle_entry.cycle }}
-            {% if cycle_entry.lifecycle %}<span class="badge text-bg-primary align-middle">{{ cycle_entry.lifecycle }}</span>{% endif %}
+            {{ group.label }}
+            {% if group.lifecycle %}<span class="badge text-bg-primary align-middle">{{ group.lifecycle }}</span>{% endif %}
           </h2>
-        {% elif cycles | length > 1 %}
-          <h2 class="fs-4 border-bottom pb-1">No lifecycle information</h2>
         {% endif %}
-        {% for version in cycle_entry.versions %}
+        {% for version in group.versions %}
           {% include "_catalog_version.html" %}
         {% endfor %}
       </div>
diff --git a/atr/templates/catalog_site/_release_list.html b/atr/templates/catalog_site/_release_list.html
new file mode 100644
index 00000000..e415a508
--- /dev/null
+++ b/atr/templates/catalog_site/_release_list.html
@@ -0,0 +1,18 @@
+{# Shared by the project and archive pages. Semver and calver projects group their
+   releases by cycle; everything else is one flat list, since a project on nothing but
+   the default cycle has no lifecycle to show #}
+{% if groups %}
+  {% for group in groups %}
+    <div class="mb-4">
+      {% if group.label %}
+        <h3 class="fs-5 border-bottom pb-1">
+          {{ group.label }}
+          {% if group.lifecycle %}<span class="badge text-bg-primary align-middle">{{ group.lifecycle }}</span>{% endif %}
+        </h3>
+      {% endif %}
+      {% for version in group.versions %}{% include "_version.html" %}{% endfor %}
+    </div>
+  {% endfor %}
+{% else %}
+  {% for version in versions %}{% include "_version.html" %}{% endfor %}
+{% endif %}
diff --git a/atr/templates/catalog_site/project.html b/atr/templates/catalog_site/project.html
index e88e8d8f..f79652fa 100644
--- a/atr/templates/catalog_site/project.html
+++ b/atr/templates/catalog_site/project.html
@@ -37,7 +37,7 @@
     {# A retired project can still hold releases that were never archived themselves.
        They belong to nothing else, so list them here rather than drop them #}
     {% if versions %}
-      {% for version in versions %}{% include "_version.html" %}{% endfor %}
+      {% include "_release_list.html" %}
     {% else %}
       <div class="alert alert-info" role="alert">
         No catalogued releases for {{ heading }}.
diff --git a/atr/templates/catalog_site/project_archive.html b/atr/templates/catalog_site/project_archive.html
index 6ec616e7..d62823c5 100644
--- a/atr/templates/catalog_site/project_archive.html
+++ b/atr/templates/catalog_site/project_archive.html
@@ -15,7 +15,7 @@
     </span>
   </div>
   {% if versions %}
-    {% for version in versions %}{% include "_version.html" %}{% endfor %}
+    {% include "_release_list.html" %}
   {% else %}
     <div class="alert alert-info" role="alert">No archived releases.</div>
   {% endif %}
diff --git a/tests/unit/test_policy_writer.py b/tests/unit/test_policy_writer.py
index 2e187847..62154648 100644
--- a/tests/unit/test_policy_writer.py
+++ b/tests/unit/test_policy_writer.py
@@ -80,6 +80,10 @@ class _MockData:
         all_mock = mock.AsyncMock(return_value=list(self._releases))
         return SimpleNamespace(all=all_mock)
 
+    def task(self, **_kwargs):
+        # Nothing queued, so a cycle change adds its own catalog site regeneration.
+        return _Query(None)
+
 
 async def test_edit_cycle_dates_pairs_withdraw_when_changing_existing_date():
     existing = datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC)
@@ -323,6 +327,28 @@ async def test_edit_version_scheme_skips_release_already_in_correct_cycle():
     assert new_cycles == []
 
 
+async def test_edit_version_scheme_queues_catalog_site_regeneration():
+    data = _MockData(project=_project(), releases=[_release("0.1", cycle_key="example-default")])
+    writer = _make_committee_member(data)
+
+    await writer.edit_version_scheme(_version_scheme_form(cycle_match=r"^(\d+)\.\d+$"))
+
+    tasks = [t for t in data.added if isinstance(t, sql.Task)]
+    assert [t.task_type for t in tasks] == [sql.TaskType.CATALOG_SITE_GENERATE]
+    assert tasks[0].project_key == "example"
+
+
+async def test_edit_cycle_dates_queues_catalog_site_regeneration():
+    data = _MockData(project=_project(), cycle=_cycle())
+    writer = _make_committee_member(data)
+
+    await writer.edit_cycle_dates(_cycle_dates_form(eol=datetime.date(2026, 7, 1)))
+
+    tasks = [t for t in data.added if isinstance(t, sql.Task)]
+    assert [t.task_type for t in tasks] == [sql.TaskType.CATALOG_SITE_GENERATE]
+    assert tasks[0].project_key == "example"
+
+
 async def test_edit_policy_rejects_unknown_template_variables():
     project = SimpleNamespace(
         key="example",


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