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

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new 839dde1  Move the code to start a new release to the storage interface
839dde1 is described below

commit 839dde12262761404a6557e04ec386c17e10ab3f
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Sep 10 18:57:11 2025 +0100

    Move the code to start a new release to the storage interface
---
 atr/blueprints/api/api.py       |  14 ++--
 atr/routes/start.py             |  74 +++------------------
 atr/storage/__init__.py         |  27 ++++++++
 atr/storage/writers/__init__.py |   2 +
 atr/storage/writers/release.py  | 140 ++++++++++++++++++++++++++++++++++++++++
 5 files changed, 184 insertions(+), 73 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 68cb927..ea54451 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -38,8 +38,6 @@ import atr.jwtoken as jwtoken
 import atr.models as models
 import atr.models.sql as sql
 import atr.revision as revision
-import atr.routes as routes
-import atr.routes.start as start
 import atr.storage as storage
 import atr.storage.outcome as outcome
 import atr.storage.types as types
@@ -773,13 +771,11 @@ async def release_create(data: 
models.api.ReleaseCreateArgs) -> DictResponse:
     asf_uid = _jwt_asf_uid()
 
     try:
-        release, _project = await start.create_release_draft(
-            project_name=data.project,
-            version=data.version,
-            asf_uid=asf_uid,
-        )
-    except routes.FlashError as exc:
-        raise exceptions.BadRequest(str(exc))
+        async with storage.write(asf_uid) as write:
+            wacp = await write.as_project_committee_participant(data.project)
+            release, _project = await wacp.release.start(data.project, 
data.version)
+    except storage.AccessError as e:
+        raise exceptions.BadRequest(str(e))
 
     return models.api.ReleaseCreateResults(
         endpoint="/release/create",
diff --git a/atr/routes/start.py b/atr/routes/start.py
index 70e452a..a794a14 100644
--- a/atr/routes/start.py
+++ b/atr/routes/start.py
@@ -15,7 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import datetime
 
 import asfquart.base as base
 import quart
@@ -25,11 +24,10 @@ import atr.db as db
 import atr.db.interaction as interaction
 import atr.forms as forms
 import atr.models.sql as sql
-import atr.revision as revision
 import atr.routes as routes
 import atr.routes.compose as compose
+import atr.storage as storage
 import atr.template as template
-import atr.util as util
 
 
 class StartReleaseForm(forms.Typed):
@@ -42,59 +40,6 @@ class StartReleaseForm(forms.Typed):
     submit = forms.submit("Start new release")
 
 
-async def create_release_draft(project_name: str, version: str, asf_uid: str) 
-> tuple[sql.Release, sql.Project]:
-    """Creates the initial release draft record and revision directory."""
-    # Get the project from the project name
-    async with db.session() as data:
-        async with data.begin():
-            project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE, _committee=True).get()
-            if not project:
-                raise routes.FlashError(f"Project {project_name} not found")
-
-            # TODO: Temporarily allow committers to start drafts
-            if project.committee is None or (
-                asf_uid not in project.committee.committee_members and asf_uid 
not in project.committee.committers
-            ):
-                raise base.ASFQuartException(
-                    f"You must be a member or committer for the 
{project.display_name}"
-                    " committee to start a release draft.",
-                    errorcode=403,
-                )
-
-    # TODO: Consider using Release.revision instead of ./latest
-    async with db.session() as data:
-        async with data.begin():
-            # Check whether the release already exists
-            if release := await data.release(project_name=project.name, 
version=version).get():
-                if release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
-                    raise routes.FlashError(f"A draft for {project_name} 
{version} already exists.")
-                else:
-                    raise routes.FlashError(
-                        f"A release ({release.phase.value}) for {project_name} 
{version} already exists."
-                    )
-
-            # Validate the version name
-            # TODO: We should check that it's bigger than the current version
-            if version_name_error := util.version_name_error(version):
-                raise routes.FlashError(f'Invalid version name "{version}": 
{version_name_error}')
-
-            release = sql.Release(
-                phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
-                project_name=project.name,
-                project=project,
-                version=version,
-                created=datetime.datetime.now(datetime.UTC),
-            )
-            data.add(release)
-
-        await data.refresh(release)
-
-    description = "Creation of empty release candidate draft through web 
interface"
-    async with revision.create_and_manage(project_name, version, asf_uid, 
description=description) as _creating:
-        pass
-    return release, project
-
-
 @routes.committer("/start/<project_name>", methods=["GET", "POST"])
 async def selected(session: routes.CommitterSession, project_name: str) -> 
response.Response | str:
     """Allow the user to start a new release draft, or handle its 
submission."""
@@ -105,19 +50,20 @@ async def selected(session: routes.CommitterSession, 
project_name: str) -> respo
             base.ASFQuartException(f"Project {project_name} not found", 
errorcode=404)
         )
 
-    form = await StartReleaseForm.create_form(data=await quart.request.form if 
quart.request.method == "POST" else None)
+    form = await StartReleaseForm.create_form(
+        data=await quart.request.form if (quart.request.method == "POST") else 
None
+    )
     if (quart.request.method == "GET") or (not form.project_name.data):
         form.project_name.data = project_name
 
     if (quart.request.method == "POST") and (await form.validate_on_submit()):
         try:
-            # TODO: Move the helper somewhere else
-            # We already have the project, so we only need to get [0]
-            new_release = (
-                await create_release_draft(
-                    project_name=str(form.project_name.data), 
version=str(form.version_name.data), asf_uid=session.uid
-                )
-            )[0]
+            project_name = str(form.project_name.data)
+            version = str(form.version_name.data)
+            # We already have the project, so we only need to get the new 
release
+            async with storage.write(session.uid) as write:
+                wacp = await 
write.as_project_committee_participant(project_name)
+                new_release, _project = await wacp.release.start(project_name, 
version)
             # Redirect to the new draft's overview page on success
             return await session.redirect(
                 compose.selected,
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index db5b052..cd3ad0e 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -138,6 +138,7 @@ class WriteAsGeneralPublic(WriteAs):
         self.announce = writers.announce.GeneralPublic(write, self, data)
         self.checks = writers.checks.GeneralPublic(write, self, data)
         self.keys = writers.keys.GeneralPublic(write, self, data)
+        self.release = writers.release.GeneralPublic(write, self, data)
         self.ssh = writers.ssh.GeneralPublic(write, self, data)
         self.tokens = writers.tokens.GeneralPublic(write, self, data)
         self.vote = writers.vote.GeneralPublic(write, self, data)
@@ -150,6 +151,7 @@ class WriteAsFoundationCommitter(WriteAsGeneralPublic):
         self.announce = writers.announce.FoundationCommitter(write, self, data)
         self.checks = writers.checks.FoundationCommitter(write, self, data)
         self.keys = writers.keys.FoundationCommitter(write, self, data)
+        self.release = writers.release.FoundationCommitter(write, self, data)
         self.ssh = writers.ssh.FoundationCommitter(write, self, data)
         self.tokens = writers.tokens.FoundationCommitter(write, self, data)
         self.vote = writers.vote.FoundationCommitter(write, self, data)
@@ -168,6 +170,7 @@ class 
WriteAsCommitteeParticipant(WriteAsFoundationCommitter):
         self.announce = writers.announce.CommitteeParticipant(write, self, 
data, committee_name)
         self.checks = writers.checks.CommitteeParticipant(write, self, data, 
committee_name)
         self.keys = writers.keys.CommitteeParticipant(write, self, data, 
committee_name)
+        self.release = writers.release.CommitteeParticipant(write, self, data, 
committee_name)
         self.ssh = writers.ssh.CommitteeParticipant(write, self, data, 
committee_name)
         self.tokens = writers.tokens.CommitteeParticipant(write, self, data, 
committee_name)
         self.vote = writers.vote.CommitteeParticipant(write, self, data, 
committee_name)
@@ -191,6 +194,7 @@ class WriteAsCommitteeMember(WriteAsCommitteeParticipant):
         self.checks = writers.checks.CommitteeMember(write, self, data, 
committee_name)
         self.distributions = writers.distributions.CommitteeMember(write, 
self, data, committee_name)
         self.keys = writers.keys.CommitteeMember(write, self, data, 
committee_name)
+        self.release = writers.release.CommitteeMember(write, self, data, 
committee_name)
         self.ssh = writers.ssh.CommitteeMember(write, self, data, 
committee_name)
         self.tokens = writers.tokens.CommitteeMember(write, self, data, 
committee_name)
         self.vote = writers.vote.CommitteeMember(write, self, data, 
committee_name)
@@ -213,6 +217,7 @@ class WriteAsFoundationAdmin(WriteAsCommitteeMember):
         # self.announce = writers.announce.FoundationAdmin(write, self, data, 
committee_name)
         # self.checks = writers.checks.FoundationAdmin(write, self, data, 
committee_name)
         self.keys = writers.keys.FoundationAdmin(write, self, data, 
committee_name)
+        # self.release = writers.release.FoundationAdmin(write, self, data, 
committee_name)
         # self.ssh = writers.ssh.FoundationAdmin(write, self, data, 
committee_name)
         # self.tokens = writers.tokens.FoundationAdmin(write, self, data, 
committee_name)
         # self.vote = writers.vote.FoundationAdmin(write, self, data, 
committee_name)
@@ -319,6 +324,28 @@ class Write:
             return outcome.Error(e)
         return outcome.Result(wacm)
 
+    async def as_project_committee_participant(self, project_name: str) -> 
WriteAsCommitteeParticipant:
+        write_as_outcome = await 
self.as_project_committee_participant_outcome(project_name)
+        return write_as_outcome.result_or_raise()
+
+    async def as_project_committee_participant_outcome(
+        self, project_name: str
+    ) -> outcome.Outcome[WriteAsCommitteeParticipant]:
+        project = await self.__data.project(project_name, 
_committee=True).demand(
+            AccessError(f"Project not found: {project_name}")
+        )
+        if project.committee is None:
+            return outcome.Error(AccessError("No committee found for project"))
+        if self.__authorisation.asf_uid is None:
+            return outcome.Error(AccessError("No ASF UID"))
+        if not self.__authorisation.is_participant_of(project.committee.name):
+            return outcome.Error(AccessError(f"Not a participant of 
{project.committee.name}"))
+        try:
+            wacp = WriteAsCommitteeParticipant(self, self.__data, 
project.committee.name)
+        except Exception as e:
+            return outcome.Error(e)
+        return outcome.Result(wacp)
+
     @property
     def member_of(self) -> frozenset[str]:
         return self.__authorisation.member_of()
diff --git a/atr/storage/writers/__init__.py b/atr/storage/writers/__init__.py
index 3ec7225..0d8b5b1 100644
--- a/atr/storage/writers/__init__.py
+++ b/atr/storage/writers/__init__.py
@@ -19,6 +19,7 @@ import atr.storage.writers.announce as announce
 import atr.storage.writers.checks as checks
 import atr.storage.writers.distributions as distributions
 import atr.storage.writers.keys as keys
+import atr.storage.writers.release as release
 import atr.storage.writers.ssh as ssh
 import atr.storage.writers.tokens as tokens
 import atr.storage.writers.vote as vote
@@ -28,6 +29,7 @@ __all__ = [
     "checks",
     "distributions",
     "keys",
+    "release",
     "ssh",
     "tokens",
     "vote",
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
new file mode 100644
index 0000000..1145484
--- /dev/null
+++ b/atr/storage/writers/release.py
@@ -0,0 +1,140 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Removing this will cause circular imports
+from __future__ import annotations
+
+import datetime
+
+import atr.db as db
+import atr.models.sql as sql
+import atr.revision as revision
+import atr.storage as storage
+import atr.util as util
+
+
+class GeneralPublic:
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsGeneralPublic,
+        data: db.Session,
+    ) -> None:
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        self.__asf_uid = write.authorisation.asf_uid
+
+
+class FoundationCommitter(GeneralPublic):
+    def __init__(self, write: storage.Write, write_as: 
storage.WriteAsFoundationCommitter, data: db.Session) -> None:
+        super().__init__(write, write_as, data)
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        asf_uid = write.authorisation.asf_uid
+        if asf_uid is None:
+            raise storage.AccessError("No ASF UID")
+        self.__asf_uid = asf_uid
+
+
+class CommitteeParticipant(FoundationCommitter):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsCommitteeParticipant,
+        data: db.Session,
+        committee_name: str,
+    ) -> None:
+        super().__init__(write, write_as, data)
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        asf_uid = write.authorisation.asf_uid
+        if asf_uid is None:
+            raise storage.AccessError("No ASF UID")
+        self.__asf_uid = asf_uid
+        self.__committee_name = committee_name
+
+    async def start(self, project_name: str, version: str) -> 
tuple[sql.Release, sql.Project]:
+        """Creates the initial release draft record and revision directory."""
+        # Get the project from the project name
+        project = await self.__data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE, _committee=True).get()
+        if not project:
+            raise storage.AccessError(f"Project {project_name} not found")
+
+        # TODO: Temporarily allow committers to start drafts
+        if project.committee is None or (
+            self.__asf_uid not in project.committee.committee_members
+            and self.__asf_uid not in project.committee.committers
+        ):
+            raise storage.AccessError(
+                f"You must be a member or committer for the 
{project.display_name} committee to start a release draft."
+            )
+
+        # TODO: Consider using Release.revision instead of ./latest
+        # Check whether the release already exists
+        if release := await self.__data.release(project_name=project.name, 
version=version).get():
+            if release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+                raise storage.AccessError(f"A draft for {project_name} 
{version} already exists.")
+            else:
+                raise storage.AccessError(
+                    f"A release ({release.phase.value}) for {project_name} 
{version} already exists."
+                )
+
+        # Validate the version name
+        # TODO: We should check that it's bigger than the current version
+        # We have the packaging library as a dependency, but it is Python 
specific
+        if version_name_error := util.version_name_error(version):
+            raise storage.AccessError(f'Invalid version name "{version}": 
{version_name_error}')
+
+        release = sql.Release(
+            phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+            project_name=project.name,
+            project=project,
+            version=version,
+            created=datetime.datetime.now(datetime.UTC),
+        )
+        self.__data.add(release)
+        await self.__data.commit()
+        await self.__data.refresh(release)
+
+        description = "Creation of empty release candidate draft through web 
interface"
+        async with revision.create_and_manage(
+            project_name, version, self.__asf_uid, description=description
+        ) as _creating:
+            pass
+        return release, project
+
+
+class CommitteeMember(CommitteeParticipant):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsCommitteeMember,
+        data: db.Session,
+        committee_name: str,
+    ) -> None:
+        super().__init__(write, write_as, data, committee_name)
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        asf_uid = write.authorisation.asf_uid
+        if asf_uid is None:
+            raise storage.AccessError("No ASF UID")
+        self.__asf_uid = asf_uid
+        self.__committee_name = committee_name


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to