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/04/20 09:48:19 UTC

(tooling-trusted-releases) branch project_archival updated: #510 - allow projects to be archived

This is an automated email from the ASF dual-hosted git repository.

alitheg pushed a commit to branch project_archival
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/project_archival by this push:
     new 9ba6a595 #510 - allow projects to be archived
9ba6a595 is described below

commit 9ba6a5953832949dcd61eb58d78ad7f4d810e6ba
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Mon Apr 20 10:48:04 2026 +0100

    #510 - allow projects to be archived
---
 atr/get/projects.py            | 14 +++++++++---
 atr/post/projects.py           | 22 +++++++++++++++++++
 atr/storage/writers/project.py | 50 +++++++++++++++++++++++++++++++++++++-----
 3 files changed, 77 insertions(+), 9 deletions(-)

diff --git a/atr/get/projects.py b/atr/get/projects.py
index a717fcc2..fb288726 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -17,6 +17,7 @@
 
 from __future__ import annotations
 
+from collections import Counter
 from typing import Literal
 
 import asfquart.base as base
@@ -102,21 +103,28 @@ async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
     async with db.session() as data:
         projects = await data.project(_committee=True, _releases=True).order_by(sql.Project.name).all()
 
+    committee_project_counts: Counter[str] = Counter(str(p.committee.key) for p in projects if p.committee)
+
     action_forms: dict[str, htm.Element] = {}
     if session is not None:
         for project in projects:
+            if project.status != sql.ProjectStatus.ACTIVE:
+                continue
             if not (user.is_committee_member(project.committee, session.uid) or session.is_admin):
                 continue
-            if project.releases:
+            if project.committee and committee_project_counts[str(project.committee.key)] <= 1:
+                continue
+            _finished = {sql.ReleasePhase.RELEASE_PREVIEW, sql.ReleasePhase.RELEASE}
+            if any(r.phase in _finished for r in project.releases):
                 action_forms[str(project.key)] = await form.render(
                     model_cls=shared.projects.ArchiveSelectedProject,
-                    action="#",
+                    action=util.as_url(post.projects.archive),
                     form_classes=".d-inline-block.m-0",
                     submit_classes="btn-sm btn-outline-secondary",
                     submit_label="Archive project",
-                    submit_disabled=True,
                     empty=True,
                     defaults={"project_key": str(project.key)},
+                    confirm="Are you sure you want to archive this project? It will be marked as retired.",
                 )
             else:
                 action_forms[str(project.key)] = await form.render(
diff --git a/atr/post/projects.py b/atr/post/projects.py
index 6cd041a5..7474bd82 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -63,6 +63,28 @@ async def add_project(
     )
 
 
+@post.typed
+async def archive(
+    session: web.Committer,
+    _project_archive: Literal["project/archive"],
+    archive_selected_project_form: shared.projects.ArchiveSelectedProject,
+) -> web.WerkzeugResponse:
+    """
+    URL: /project/archive
+    Archive a project as a PMC member.
+    """
+    project_key = archive_selected_project_form.project_key
+
+    async with storage.write(session) as write:
+        wacm = await write.as_project_committee_member(project_key)
+        try:
+            await wacm.project.archive(project_key)
+        except storage.AccessError as e:
+            return await session.redirect(get.projects.projects, error=f"Error archiving project: {e}")
+
+    return await session.redirect(get.projects.projects, success=f"Project '{project_key}' archived successfully.")
+
+
 @post.typed
 async def delete(
     session: web.Committer,
diff --git a/atr/storage/writers/project.py b/atr/storage/writers/project.py
index 92781615..731121f4 100644
--- a/atr/storage/writers/project.py
+++ b/atr/storage/writers/project.py
@@ -186,17 +186,55 @@ class CommitteeMember(CommitteeParticipant):
             else:
                 raise
 
+    async def archive(self, project_key: safe.ProjectKey) -> None:
+        project = await self.__data.project(key=str(project_key), status=sql.ProjectStatus.ACTIVE).get()
+
+        if not project:
+            raise storage.AccessError(f"Project '{project_key}' not found.")
+
+        if project.committee_key:
+            committee_projects = await self.__data.project(
+                committee_key=project.committee_key, status=sql.ProjectStatus.ACTIVE
+            ).all()
+            if len(committee_projects) <= 1:
+                raise storage.AccessError(
+                    f"Cannot archive project '{project_key}' because it is the only project in its committee."
+                )
+
+        project = await self.__data.merge(project)
+        project.status = sql.ProjectStatus.RETIRED
+        await self.__data.commit()
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            project_key=str(project_key),
+        )
+
     async def delete(self, project_key: safe.ProjectKey) -> None:
-        project = await self.__data.project(
-            key=str(project_key), status=sql.ProjectStatus.ACTIVE, _releases=True, _distribution_channels=True
-        ).get()
+        project = await self.__data.project(key=str(project_key), status=sql.ProjectStatus.ACTIVE, _releases=True).get()
 
         if not project:
             raise storage.AccessError(f"Project '{project_key}' not found.")
 
-        # Prevent deletion if there are associated releases or channels
-        if project.releases:
-            raise storage.AccessError(f"Cannot delete project '{project_key}' because it has associated releases.")
+        if project.committee_key:
+            committee_projects = await self.__data.project(
+                committee_key=project.committee_key, status=sql.ProjectStatus.ACTIVE
+            ).all()
+            if len(committee_projects) <= 1:
+                raise storage.AccessError(
+                    f"Cannot delete project '{project_key}' because it is the only project in its committee."
+                )
+
+        finished_phases = {sql.ReleasePhase.RELEASE_PREVIEW, sql.ReleasePhase.RELEASE}
+        if any(r.phase in finished_phases for r in project.releases):
+            raise storage.AccessError(
+                f"Cannot delete project '{project_key}' because it has current or archived releases."
+            )
+
+        # Candidate releases (draft or under vote) are removed along with the project
+        for release in list(project.releases):
+            error = await self.__write_as.release.delete(project_key, safe.VersionKey(release.version))
+            if error is not None:
+                raise storage.AccessError(f"Could not delete candidate release '{release.version}': {error}")
 
         await self.__data.delete(project)
         await self.__data.commit()


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