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