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