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/08/03 17:17:57 UTC
(tooling-trusted-releases) branch arm updated: Allow moving release from catalog admin
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 687c1125 Allow moving release from catalog admin
687c1125 is described below
commit 687c1125e15aea80e4476ea45f76eccf857dc666
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Mon Aug 3 18:17:37 2026 +0100
Allow moving release from catalog admin
---
atr/admin/__init__.py | 169 +++++++++++++++++++++++++++++++
atr/admin/templates/catalog-project.html | 11 ++
atr/storage/writers/catalogue.py | 71 +++++++++++--
3 files changed, 243 insertions(+), 8 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 28333f7d..7b95500f 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -444,6 +444,10 @@ async def catalog_committee_get(
htpy.td[str(release_count)],
htpy.td[str(artifact_count)],
htpy.td[
+ htpy.a(
+ ".btn.btn-sm.btn-outline-secondary.me-1",
+ href=f"/admin/catalog/{committee_key}/project/{project.key}",
+ )["Releases"],
htpy.a(
".btn.btn-sm.btn-outline-secondary.me-1",
href=f"/admin/catalog/{committee_key}/rename/{project.key}",
@@ -514,6 +518,19 @@ async def _catalog_artifact_counts(data: db.Session, project_keys: list[str]) ->
return {row[0]: int(row[1]) for row in result.all()}
+async def _catalog_release_key_counts(
+ data: db.Session, model: type[sqlmodel.SQLModel], attr: str, release_keys: list[str]
+) -> dict[str, int]:
+ if not release_keys:
+ return {}
+ via = sql.validate_instrumented_attribute
+ column = via(getattr(model, attr))
+ result = await data.execute(
+ sqlmodel.select(column, sqlalchemy.func.count()).where(column.in_(release_keys)).group_by(column)
+ )
+ return {row[0]: int(row[1]) for row in result.all()}
+
+
class CatalogMoveForm(form.Form):
destination: str = form.label(
"Destination committee", "The committee to move this project to.", widget=form.Widget.CUSTOM
@@ -726,6 +743,158 @@ async def catalog_remove_post(
return await session.redirect(catalog_committee_get, committee_key=str(committee_key))
+@admin.typed
+async def catalog_project_get(
+ _session: web.Committer,
+ _catalog: Literal["catalog"],
+ committee_key: safe.CommitteeKey,
+ _project: Literal["project"],
+ project_key: safe.ProjectKey,
+) -> str:
+ """
+ URL: GET /catalog/<committee_key>/project/<project_key>
+
+ List a project's releases and offer to move the catalogued ones elsewhere.
+ """
+ async with db.session() as data:
+ await data.project(key=str(project_key)).demand(exceptions.NotFound(f"Project '{project_key}' not found."))
+ releases = await data.release(project_key=str(project_key)).order_by(sql.Release.version).all()
+ release_keys = [str(release.key) for release in releases]
+ artifact_counts = await _catalog_release_key_counts(data, sql.Artifact, "release_key", release_keys)
+ revision_counts = await _catalog_release_key_counts(data, sql.Revision, "release_key", release_keys)
+
+ table = htpy.table(".table")[
+ htpy.thead[
+ htpy.tr[
+ htpy.th["Version"],
+ htpy.th["Phase"],
+ htpy.th["Artifacts"],
+ htpy.th["Managed"],
+ htpy.th["Actions"],
+ ]
+ ],
+ htpy.tbody[
+ [
+ htpy.tr[
+ htpy.td[htpy.strong[release.version]],
+ htpy.td[release.phase.value],
+ htpy.td[str(artifact_counts.get(str(release.key), 0))],
+ htpy.td["Yes" if (revision_counts.get(str(release.key), 0) > 0) else "No"],
+ htpy.td[
+ _catalog_release_move_action(
+ str(committee_key),
+ str(project_key),
+ release.version,
+ managed=revision_counts.get(str(release.key), 0) > 0,
+ )
+ ],
+ ]
+ for release in releases
+ ]
+ ],
+ ]
+ return await template.render(
+ "catalog-project.html",
+ committee_key=str(committee_key),
+ project_key=str(project_key),
+ releases=table,
+ )
+
+
+def _catalog_release_move_action(committee_key: str, project_key: str, version: str, managed: bool) -> htpy.Element:
+ # A managed release keeps its files under the workflow, so its move is greyed out
+ if managed:
+ return htpy.span(".btn.btn-sm.btn-outline-secondary.me-1.disabled", title="has workflow files")["Move"]
+ return htpy.a(
+ ".btn.btn-sm.btn-outline-secondary.me-1",
+ href=f"/admin/catalog/{committee_key}/project/{project_key}/release/{version}/move",
+ )["Move"]
+
+
+class CatalogReleaseMoveForm(form.Form):
+ target_project: str = form.label(
+ "Destination project", "The project to move this release into.", widget=form.Widget.CUSTOM, default=""
+ )
+
+
+@admin.typed
+async def catalog_release_move_get(
+ _session: web.Committer,
+ _catalog: Literal["catalog"],
+ committee_key: safe.CommitteeKey,
+ _project: Literal["project"],
+ project_key: safe.ProjectKey,
+ _release: Literal["release"],
+ version_key: safe.VersionKey,
+ _move: Literal["move"],
+) -> str:
+ """
+ URL: GET /catalog/<committee_key>/project/<project_key>/release/<version_key>/move
+
+ Confirm moving a single catalogued release to another project, across PMCs if need be.
+ """
+ async with db.session() as data:
+ projects = await data.project().order_by(sql.Project.key).all()
+ targets = [p for p in projects if str(p.key) != str(project_key)]
+ rendered_form = await form.render(
+ model_cls=CatalogReleaseMoveForm,
+ submit_label="Move release",
+ submit_classes="btn-primary",
+ custom={
+ "target_project": _catalog_select(
+ "target_project",
+ [(str(p.key), p.name or str(p.key)) for p in targets],
+ "Choose a project",
+ )
+ },
+ )
+ intro = htpy.div[
+ htpy.p[f"Move {version_key} out of {project_key} and into another project."],
+ htpy.p(".text-muted")["Only the catalogue attribution moves; the files stay where they are in dist."],
+ ]
+ return await template.render(
+ "catalog-confirm.html",
+ heading=f"Move {project_key}-{version_key}",
+ committee_key=str(committee_key),
+ intro=intro,
+ form=rendered_form,
+ )
+
+
+@admin.typed
+async def catalog_release_move_post(
+ session: web.Committer,
+ _catalog: Literal["catalog"],
+ committee_key: safe.CommitteeKey,
+ _project: Literal["project"],
+ project_key: safe.ProjectKey,
+ _release: Literal["release"],
+ version_key: safe.VersionKey,
+ _move: Literal["move"],
+ move_form: CatalogReleaseMoveForm,
+) -> web.WerkzeugResponse:
+ """
+ URL: POST /catalog/<committee_key>/project/<project_key>/release/<version_key>/move
+
+ Apply the release move.
+ """
+ raw = move_form.target_project.strip()
+ if not raw:
+ raise exceptions.BadRequest("Choose a destination project.")
+ try:
+ target = safe.ProjectKey(raw)
+ except ValueError as error:
+ raise exceptions.BadRequest(f"Invalid project key: {error}") from error
+ try:
+ release_key = safe.ReleaseKey(f"{project_key}-{version_key}")
+ except ValueError as error:
+ raise exceptions.BadRequest(f"Invalid release key: {error}") from error
+ async with storage.write(session) as write:
+ wafa = write.as_foundation_admin()
+ await wafa.catalogue.move_release(release_key, target)
+ return await session.redirect(catalog_project_get, committee_key=str(committee_key), project_key=str(project_key))
+
+
_CATALOG_PREVIEW_LIMIT: Final[int] = 50
diff --git a/atr/admin/templates/catalog-project.html b/atr/admin/templates/catalog-project.html
new file mode 100644
index 00000000..6cdc122e
--- /dev/null
+++ b/atr/admin/templates/catalog-project.html
@@ -0,0 +1,11 @@
+{% extends "layouts/base-admin.html" %}
+
+{%- block title -%}Releases: {{ project_key }} ~ ATR{%- endblock title -%}
+
+{%- block description -%}Move catalogued releases for {{ project_key }}.{%- endblock description -%}
+
+{% block content %}
+ <h1>Releases: {{ project_key }}</h1>
+ <p><a href="/admin/catalog/{{ committee_key }}">Back to {{ committee_key }}</a></p>
+ {{ releases }}
+{% endblock content %}
diff --git a/atr/storage/writers/catalogue.py b/atr/storage/writers/catalogue.py
index e7c114f0..84059035 100644
--- a/atr/storage/writers/catalogue.py
+++ b/atr/storage/writers/catalogue.py
@@ -69,6 +69,39 @@ class FoundationAdmin:
moved_to=str(dest_committee_key),
)
+ async def move_release(self, release_key: safe.ReleaseKey, target_project_key: safe.ProjectKey) -> None:
+ old_key = str(release_key)
+ to_project = str(target_project_key)
+ await self.__data.begin_immediate()
+ self.__data.expire_all()
+ try:
+ release = await self.__data.get(sql.Release, old_key)
+ if release is None:
+ raise storage.AccessError(f"Release '{old_key}' not found.", status=404)
+ # Capture the source project and version before the re-point expires the row
+ from_project = release.project_key
+ version = release.version
+ await self._ensure_release_catalog_only(old_key)
+ if await self.__data.project(key=to_project).get() is None:
+ raise storage.AccessError(f"Target project '{to_project}' not found.", status=404)
+ if from_project == to_project:
+ raise storage.AccessError("The release is already in that project.", status=400)
+ new_key = f"{to_project}-{version}"
+ if await self.__data.get(sql.Release, new_key) is not None:
+ raise storage.AccessError(f"Target already has release {new_key}.", status=409)
+ await self.__data.execute(sqlalchemy.text("PRAGMA defer_foreign_keys=ON"))
+ await self._repoint_release_rows(old_key, from_project, version, to_project)
+ await self._assert_fk_integrity()
+ await self.__data.commit()
+ except Exception:
+ await self.__data.rollback()
+ raise
+ self.__write_as.append_to_audit_log(
+ asf_uid=self.__asf_uid,
+ moved_release=old_key,
+ moved_to_project=to_project,
+ )
+
async def rename_project(self, old_key: safe.ProjectKey, new_key: safe.ProjectKey, new_name: str | None) -> None:
old = str(old_key)
new = str(new_key)
@@ -384,20 +417,26 @@ class FoundationAdmin:
)
async def _repoint_one_release(self, release_repoint: catalogue_diff.ReleaseRepoint) -> None:
+ await self._repoint_release_rows(
+ release_repoint.key,
+ release_repoint.from_project,
+ str(release_repoint.row.version),
+ release_repoint.to_project,
+ )
+
+ async def _repoint_release_rows(self, old_key: str, from_project: str, version: str, to_project: str) -> None:
via = sql.validate_instrumented_attribute
- old_key = release_repoint.key
- version = str(release_repoint.row.version)
- new_key = f"{release_repoint.to_project}-{version}"
+ new_key = f"{to_project}-{version}"
await self.__data.execute(
sqlmodel.update(sql.Artifact)
- .where(via(sql.Artifact.project_key) == release_repoint.from_project)
+ .where(via(sql.Artifact.project_key) == from_project)
.where(via(sql.Artifact.version) == version)
- .values(release_key=new_key, project_key=release_repoint.to_project)
+ .values(release_key=new_key, project_key=to_project)
)
await self.__data.execute(
sqlmodel.update(sql.LifecycleEvent)
.where(via(sql.LifecycleEvent.version_key) == old_key)
- .values(version_key=new_key, project_key=release_repoint.to_project)
+ .values(version_key=new_key, project_key=to_project)
)
# Other release-key children re-point to the new key
for model, attr in repoint.RELEASE_KEY_REFS:
@@ -408,11 +447,11 @@ class FoundationAdmin:
await self.__data.execute(
sqlmodel.update(sql.Release)
.where(via(sql.Release.key) == old_key)
- .values(key=new_key, project_key=release_repoint.to_project)
+ .values(key=new_key, project_key=to_project)
)
# Resolve the repointed release's cycle against the target's scheme, then align its events
self.__data.expire_all()
- target_project = await self.__data.project(key=release_repoint.to_project).get()
+ target_project = await self.__data.project(key=to_project).get()
if target_project is None:
raise storage.AccessError("The repoint target could not be reloaded.", status=500)
await cycles.reassign_release_cycles(self.__data, target_project)
@@ -531,6 +570,22 @@ class FoundationAdmin:
status=409,
)
+ async def _ensure_release_catalog_only(self, release_key: str) -> None:
+ # A live release also carries revisions and file-state, whose keys this page does not
+ # re-point, so we only move a release that has none
+ via = sql.validate_instrumented_attribute
+ result = await self.__data.execute(
+ sqlmodel.select(sqlalchemy.func.count())
+ .select_from(sql.Revision)
+ .where(via(sql.Revision.release_key) == release_key)
+ )
+ if int(result.scalar() or 0) > 0:
+ raise storage.AccessError(
+ f"Release '{release_key}' has live release-workflow data (revisions); "
+ "this page moves catalogued releases only.",
+ status=409,
+ )
+
async def _assert_fk_integrity(self) -> None:
# Flush pending ORM work, then check for dangling references before commit
await self.__data.flush()
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org