asf-tooling commented on issue #507:
URL: 
https://github.com/apache/tooling-trusted-releases/issues/507#issuecomment-4410192252

   <!-- gofannon-issue-triage-bot v2 -->
   
   **Automated triage** — analyzed at `main@2da7807a`
   
   **Type:** `new_feature`  •  **Classification:** `actionable`  •  
**Confidence:** `medium`
   **Application domain(s):** `release_lifecycle`, `admin_operations`
   
   ### Summary
   Issue #507 requests that finished/announced releases can only be archived 
(not deleted), with a clear state representation. @dave2wave clarified: 'Once a 
release is finished and announced it may only be archived.' Currently, the 
`ReleasePhase` enum has no `ARCHIVED` state, and the admin delete functionality 
allows deletion of releases in any phase. This requires adding an ARCHIVED 
phase, restricting deletion of RELEASE-phase releases, and adding an archive 
action.
   
   ### Where this lives in the code today
   
   #### `atr/storage/writers/release.py` — `CommitteeParticipant.delete` (lines 
168-178)
   _needs modification_
   The delete method accepts an optional phase filter but does not enforce the 
rule that RELEASE-phase releases cannot be deleted.
   
   ```python
       async def delete(
           self,
           project_key: safe.ProjectKey,
           version: safe.VersionKey,
           phase: db.Opt[sql.ReleasePhase] = db.NOT_SET,
           include_downloads: bool = True,
       ) -> str | None:
           """Handle the deletion of database records and filesystem data for a 
release."""
           release = await self.__data.release(
               project_key=str(project_key), version=str(version), phase=phase, 
_committee=True
           ).demand(storage.AccessError(f"Release '{project_key!s} {version!s}' 
not found.", status=404))
   ```
   
   #### `atr/post/draft.py` — `delete` (lines 40-60)
   _currently does this_
   The draft delete endpoint already correctly restricts deletion to 
RELEASE_CANDIDATE_DRAFT phase only.
   
   ```python
   @post.typed
   async def delete(
       session: web.Committer,
       _draft_delete: Literal["draft/delete"],
       project_key: safe.ProjectKey,
       version_key: safe.VersionKey,
       _form: form.Empty,
   ) -> web.WerkzeugResponse:
       """
       URL: /draft/delete/<project_key>/<version_key>
       Delete a candidate draft and all its associated files.
       """
       # Delete the metadata from the database
       async with storage.write(session) as write:
           wacp = await write.as_project_committee_participant(project_key)
           error = await wacp.release.delete(
               project_key,
               version_key,
               phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
               include_downloads=False,
           )
   ```
   
   #### `atr/admin/__init__.py` — `delete_release_get` (lines 382-411)
   _needs modification_
   The admin delete form shows all releases regardless of phase - should 
exclude RELEASE-phase releases or mark them as archive-only.
   
   ```python
   async def delete_release_get(
       _session: web.Committer, _delete_release_get: Literal["delete-release"]
   ) -> str | web.WerkzeugResponse:
       """
       URL: GET /delete-release
   
       Display the form to delete releases.
       """
       async with db.session() as data:
           releases = await 
data.release(_project=True).order_by(sql.Release.key).all()
   
       if releases:
           releases_widget = htpy.div[
               [
                   htpy.div(".form-check")[
                       htpy.input(
                           class_="form-check-input",
                           type="checkbox",
                           name="releases_to_delete",
                           value=release.key,
                           id=f"release_{release.key}",
                       ),
                       htpy.label(".form-check-label", 
for_=f"release_{release.key}")[
                           htpy.strong[release.key],
                           f" ({release.project.display_name}, 
{release.phase.value.upper()})",
                       ],
                   ]
                   for release in releases
               ]
           ]
   ```
   
   #### `atr/admin/__init__.py` — `delete_release_post` (lines 430-440)
   _needs modification_
   The admin delete POST handler should guard against deleting RELEASE-phase 
releases.
   
   ```python
   async def delete_release_post(
       session: web.Committer, _delete_release_get: Literal["delete-release"], 
delete_form: DeleteReleaseForm
   ) -> str | web.WerkzeugResponse:
       """
       URL: POST /delete-release
   
       Delete selected releases and their associated data and files.
       """
       await _delete_releases(session, delete_form.releases_to_delete)
   
       return await session.redirect(delete_release_get)
   ```
   
   ### Where new code would go
   - `atr/models/sql.py` — in ReleasePhase enum
     Add ARCHIVED = 'archived' to the ReleasePhase enum to represent archived 
releases.
   - `atr/admin/__init__.py` — after delete_release_post
     Add archive_release_get and archive_release_post endpoints for archiving 
finished releases.
   - `atr/storage/writers/release.py` — after delete method in 
CommitteeParticipant
     Add an archive method that transitions a RELEASE to ARCHIVED phase.
   
   ### Proposed approach
   The implementation requires three main changes:
   
   1. **Add `ARCHIVED` phase**: Add `ARCHIVED = 'archived'` to 
`sql.ReleasePhase` enum in `atr/models/sql.py`. This creates an explicit 
terminal state for releases that have been announced and are no longer active.
   
   2. **Guard against deletion of finished releases**: Modify 
`CommitteeParticipant.delete` in `atr/storage/writers/release.py` to reject 
deletion when the release is in `RELEASE` or `ARCHIVED` phase (unless an 
override flag is provided for exceptional cases). Update the admin delete UI to 
filter out or clearly mark released/archived releases as non-deletable.
   
   3. **Add archive functionality**: Create an `archive` method on 
`CommitteeParticipant` (or a similar writer class) that validates the release 
is in `RELEASE` phase and transitions it to `ARCHIVED`. Add corresponding admin 
routes (`archive_release_get`/`archive_release_post`) with appropriate UI. 
Update the all-releases template to show the new phase badge.
   
   ### Suggested patches
   
   #### `atr/storage/writers/release.py`
   Add phase guard to prevent deletion of finished releases and add archive 
method
   
   ````diff
   --- a/atr/storage/writers/release.py
   +++ b/atr/storage/writers/release.py
   @@ -101,6 +101,10 @@
        async def delete(
            self,
            project_key: safe.ProjectKey,
            version: safe.VersionKey,
            phase: db.Opt[sql.ReleasePhase] = db.NOT_SET,
            include_downloads: bool = True,
        ) -> str | None:
            """Handle the deletion of database records and filesystem data for 
a release."""
            release = await self.__data.release(
                project_key=str(project_key), version=str(version), 
phase=phase, _committee=True
            ).demand(storage.AccessError(f"Release '{project_key!s} 
{version!s}' not found.", status=404))
   +
   +        # Finished or archived releases may not be deleted; they must be 
archived instead
   +        if release.phase in (sql.ReleasePhase.RELEASE, 
sql.ReleasePhase.ARCHIVED):
   +            raise storage.AccessError(
   +                f"Release '{project_key!s} {version!s}' is in phase 
'{release.phase.value}' and cannot be deleted. "
   +                "Finished releases may only be archived.",
   +                status=409,
   +            )
   +
            release_dirs = [
                paths.release_directory_base(release),
                paths.get_attestable_dir() / str(project_key) / str(version),
   ````
   
   #### `atr/storage/writers/release.py`
   Add archive method to transition RELEASE phase to ARCHIVED
   
   ````diff
   --- a/atr/storage/writers/release.py
   +++ b/atr/storage/writers/release.py
   @@ -195,6 +195,33 @@
            self.__write_as.append_to_audit_log(
                asf_uid=self.__asf_uid,
                project_key=str(project_key),
                version=str(version),
                error=error,
            )
            return error
    
   +    async def archive(
   +        self,
   +        project_key: safe.ProjectKey,
   +        version: safe.VersionKey,
   +    ) -> str | None:
   +        """Archive a finished release, transitioning it from RELEASE to 
ARCHIVED phase."""
   +        release = await self.__data.release(
   +            project_key=str(project_key), version=str(version), 
_committee=True
   +        ).demand(storage.AccessError(f"Release '{project_key!s} 
{version!s}' not found.", status=404))
   +
   +        if release.phase != sql.ReleasePhase.RELEASE:
   +            raise storage.AccessError(
   +                f"Only finished releases can be archived. "
   +                f"Release '{project_key!s} {version!s}' is in phase 
'{release.phase.value}'.",
   +                status=409,
   +            )
   +
   +        via = sql.validate_instrumented_attribute
   +        stmt = sqlmodel.update(sql.Release).where(
   +            via(sql.Release.key) == release.key,
   +            via(sql.Release.phase) == sql.ReleasePhase.RELEASE,
   +        ).values(phase=sql.ReleasePhase.ARCHIVED)  # TODO: verify ARCHIVED 
enum value exists
   +        await self.__data.execute(stmt)
   +        await self.__data.commit()
   +
   +        self.__write_as.append_to_audit_log(
   +            asf_uid=self.__asf_uid,
   +            project_key=str(project_key),
   +            version=str(version),
   +            action="archive",
   +        )
   +        return None
   +
        async def delete_empty_directory(
   ````
   
   #### `atr/admin/templates/all-releases.html`
   Add display for the ARCHIVED phase badge
   
   ````diff
   --- a/atr/admin/templates/all-releases.html
   +++ b/atr/admin/templates/all-releases.html
   @@ -42,6 +42,8 @@
                {% elif phase_value == "release" %}
                  <span class="badge bg-success">4. Release</span>
   +            {% elif phase_value == "archived" %}
   +              <span class="badge bg-dark">5. Archived</span>
                {% else %}
                  <span class="badge bg-dark">Unknown</span>
                {% endif %}
   ````
   
   #### `atr/admin/__init__.py`
   Filter admin delete form to exclude finished/archived releases that should 
only be archived
   
   ````diff
   --- a/atr/admin/__init__.py
   +++ b/atr/admin/__init__.py
   @@ -240,7 +240,12 @@
    async def delete_release_get(
        _session: web.Committer, _delete_release_get: Literal["delete-release"]
    ) -> str | web.WerkzeugResponse:
        """
        URL: GET /delete-release
    
        Display the form to delete releases.
        """
        async with db.session() as data:
            releases = await 
data.release(_project=True).order_by(sql.Release.key).all()
    
   +    # Finished and archived releases cannot be deleted, only archived
   +    NON_DELETABLE_PHASES = {sql.ReleasePhase.RELEASE, 
sql.ReleasePhase.ARCHIVED}  # TODO: verify ARCHIVED exists
   +    deletable_releases = [r for r in releases if r.phase not in 
NON_DELETABLE_PHASES]
   +    non_deletable_releases = [r for r in releases if r.phase in 
NON_DELETABLE_PHASES]
   +
   -    if releases:
   +    if deletable_releases:
            releases_widget = htpy.div[
                [
                    htpy.div(".form-check")[
                        htpy.input(
                            class_="form-check-input",
                            type="checkbox",
                            name="releases_to_delete",
                            value=release.key,
                            id=f"release_{release.key}",
                        ),
                        htpy.label(".form-check-label", 
for_=f"release_{release.key}")[
                            htpy.strong[release.key],
                            f" ({release.project.display_name}, 
{release.phase.value.upper()})",
                        ],
                    ]
   -                for release in releases
   +                for release in deletable_releases
                ]
            ]
   ````
   
   ### Open questions
   - The `ReleasePhase` enum in `atr/models/sql.py` was not provided - need to 
confirm the exact enum definition and add `ARCHIVED = 'archived'` there.
   - Should there be a database migration for the new enum value, or does 
SQLite handle this transparently?
   - Should archived releases still be visible in public release listings 
(`atr/get/release.py`), or should they be filtered out?
   - Does the `start` method in `CommitteeParticipant` need to handle the case 
where a previous ARCHIVED release exists for the same version (to allow 
re-releasing)?
   - Should the archive action be restricted to PMC members only 
(CommitteeMember) rather than CommitteeParticipant?
   - The admin `_delete_releases` helper function (not fully visible) may need 
similar phase guards.
   
   ### Files examined
   - `atr/storage/writers/release.py`
   - `atr/admin/templates/delete-release.html`
   - `atr/admin/__init__.py`
   - `atr/blueprints/admin.py`
   - `atr/storage/writers/revision.py`
   - `atr/admin/templates/all-releases.html`
   - `atr/post/draft.py`
   - `atr/get/release.py`
   
   ### Related issues
   This issue appears related to: #510.
   
   _Both address constraints on deletion and archival of releases and projects_
   
   ---
   *Draft from a triage agent. A human reviewer should validate before merging 
any change. The agent did not run tests or verify diffs apply.*


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to