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

   <!-- gofannon-issue-triage-bot v2 -->
   
   **Automated triage** — analyzed at `main@2da7807a`
   
   **Type:** `new_feature`  •  **Classification:** `actionable`  •  
**Confidence:** `medium`
   **Application domain(s):** `release_lifecycle`, `web_api_infrastructure`
   
   ### Summary
   Issue requests automatic deletion of unfinished releases after 90 days of 
inactivity, with a warning email at ~80 days. @sbp's final comment (2026-04-06) 
resolves the design: activity is defined as phase transitions and revision 
creation. For releases in the finish phase (RELEASE_PREVIEW) that are inactive 
>90 days, the PMC is informed instead of deleting. The task infrastructure 
(daily MAINTENANCE task) and release deletion logic already exist; what's 
needed is a new inactivity-check routine integrated into the maintenance cycle.
   
   ### Where this lives in the code today
   
   #### `atr/tasks/__init__.py` — `run_maintenance` (lines 372-406)
   _extension point_
   The daily maintenance task is the natural trigger point for the inactivity 
check; the new logic should be called from maintenance.run.
   
   ```python
   async def run_maintenance(
       asf_uid: str,
       caller_data: db.Session | None = None,
       schedule: datetime.datetime | None = None,
       schedule_next: bool = False,
   ) -> sql.Task:
       """Queue a maintenance task."""
       task_args = args.MaintenanceArgs(asf_uid=asf_uid, 
next_schedule_seconds=0)
       if schedule_next:
           task_args.next_schedule_seconds = _DAILY
       async with db.ensure_session(caller_data) as data:
           task = sql.Task(
               status=sql.TaskStatus.QUEUED,
               task_type=sql.TaskType.MAINTENANCE,
               task_args=task_args.model_dump(),
               asf_uid=asf_uid,
               revision_number=None,
               primary_rel_path=None,
               project_key=None,
               version_key=None,
           )
           if schedule:
               task.scheduled = schedule
           if (schedule is not None) or schedule_next:
               await data.begin_immediate()
               await _clear_existing_scheduled(
                   data,
                   sql.TaskType.MAINTENANCE,
                   asf_uid,
                   include_unscheduled=schedule_next,
               )
           data.add(task)
           await data.commit()
           await data.flush()
           return task
   ```
   
   #### `atr/tasks/__init__.py` — `resolve` (lines 308-313)
   _currently does this_
   Shows that sql.TaskType.MAINTENANCE maps to maintenance.run, which is where 
inactivity checking should be invoked.
   
   ```python
   def resolve(task_type: sql.TaskType) -> Callable[..., 
Awaitable[results.Results | None]]:
       match task_type:
           ...
           case sql.TaskType.MAINTENANCE:
               return maintenance.run
           ...
   ```
   
   #### `atr/storage/writers/release.py` — `CommitteeParticipant.delete` (lines 
168-184)
   _currently does this_
   This is the existing release deletion logic that the automated inactivity 
task would invoke (indirectly via storage.write).
   
   ```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))
           release_dirs = [
               paths.release_directory_base(release),
               paths.get_attestable_dir() / str(project_key) / str(version),
               paths.get_archives_dir() / str(project_key) / str(version),
               paths.get_quarantined_dir() / str(project_key) / str(version),
           ]
   ```
   
   #### `atr/tasks/message.py` — `send` (lines 30-38)
   _currently does this_
   Email sending task that can be queued to warn release managers and PMCs 
about impending inactivity deletion.
   
   ```python
   @checks.with_model(args.Send)
   async def send(task_args: args.Send) -> results.Results | None:
       if "@" not in task_args.email_sender:
           log.warning(f"Invalid email sender: {task_args.email_sender}")
           sender_asf_uid = task_args.email_sender
       elif task_args.email_sender.endswith("@apache.org"):
           sender_asf_uid = task_args.email_sender.split("@")[0]
       else:
           raise SendError(f"Invalid email sender: {task_args.email_sender}")
   ```
   
   #### `atr/storage/writers/release.py` — `CommitteeParticipant.start` (lines 
609-619)
   _extension point_
   Where releases are created; the issue suggests adding a notice here that the 
release will be auto-closed after 90 days of inactivity.
   
   ```python
       async def start(self, project_key: safe.ProjectKey, version: 
safe.VersionKey) -> tuple[sql.Release, sql.Project]:
           """Creates the initial release draft record and revision 
directory."""
           ...
           release = sql.Release(
               phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
               project_key=project.key,
               project=project,
               version=str(version),
               cycle_key=cycle_key,
               created=datetime.datetime.now(datetime.UTC),
           )
   ```
   
   ### Where new code would go
   - `atr/tasks/inactivity.py` — new file
     A dedicated module for the inactivity check logic keeps the maintenance 
module focused. It would query unfinished releases, compute last activity, and 
either delete or notify.
   - `atr/tasks/maintenance.py` — inside maintenance.run function
     The daily maintenance task should call the inactivity checker. Since 
maintenance.run already exists and runs daily, adding a call to the inactivity 
check logic here is the natural integration point.
   
   ### Proposed approach
   Create a new module `atr/tasks/inactivity.py` that implements the release 
inactivity check. The core function will: (1) Query all releases in non-RELEASE 
phases (RELEASE_CANDIDATE_DRAFT, RELEASE_CANDIDATE, RELEASE_PREVIEW). (2) For 
each release, determine last activity as the maximum of: revision creation 
timestamps and phase transition timestamps (vote_started, vote_resolved, 
created). (3) For releases inactive >90 days in RELEASE_PREVIEW phase: queue an 
email notification to the PMC mailing list. (4) For releases inactive >90 days 
in other unfinished phases: delete the release via the existing storage.write 
mechanism. (5) For releases inactive 80-90 days: queue a warning email to the 
release manager. This function should be called from the existing daily 
maintenance task (`atr/tasks/maintenance.py`). Additionally, when a release is 
started (in `CommitteeParticipant.start`), a notice should be included in any 
UI feedback about the 90-day inactivity policy.
   
   The inactivity check must run as a system-level operation (asf_uid='system') 
since it's automated. The deletion needs to go through the storage write 
interface to maintain audit logging. Warning and notification emails should be 
queued as MESSAGE_SEND tasks to use the existing email infrastructure.
   
   ### Suggested patches
   
   #### `atr/tasks/inactivity.py`
   New module implementing the release inactivity check logic
   
   ````diff
   --- /dev/null
   +++ b/atr/tasks/inactivity.py
   @@ -0,0 +1,120 @@
   +# 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.
   +
   +"""Automatic deletion of inactive unfinished releases."""
   +
   +import datetime
   +from typing import Final
   +
   +import atr.db as db
   +import atr.log as log
   +import atr.models.safe as safe
   +import atr.models.sql as sql
   +import atr.storage as storage
   +
   +# Thresholds in days
   +_INACTIVITY_WARNING_DAYS: Final[int] = 80
   +_INACTIVITY_DELETE_DAYS: Final[int] = 90
   +
   +_UNFINISHED_PHASES: Final[frozenset[sql.ReleasePhase]] = frozenset({
   +    sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
   +    sql.ReleasePhase.RELEASE_CANDIDATE,
   +    sql.ReleasePhase.RELEASE_PREVIEW,
   +})
   +
   +
   +async def _last_activity_date(release: sql.Release, data: db.Session) -> 
datetime.datetime:
   +    """Determine the last activity date for a release.
   +
   +    Activity is defined as phase transitions and revision creation,
   +    per discussion in issue #871.
   +    """
   +    candidates: list[datetime.datetime] = [release.created]
   +
   +    # Phase transition timestamps
   +    if release.vote_started is not None:
   +        candidates.append(release.vote_started)
   +    if release.vote_resolved is not None:
   +        candidates.append(release.vote_resolved)
   +
   +    # Latest revision creation
   +    # TODO: confirm how to get the latest revision timestamp - may need
   +    # to query sql.Revision table for the most recent created date
   +    # for this release
   +    revisions = getattr(release, "revisions", None)
   +    if revisions:
   +        for rev in revisions:
   +            if hasattr(rev, "created") and rev.created is not None:
   +                candidates.append(rev.created)
   +
   +    return max(candidates)
   +
   +
   +async def check_inactive_releases(asf_uid: str = "system") -> int:
   +    """Check for inactive unfinished releases and warn/delete as 
appropriate.
   +
   +    Returns the number of releases acted upon (warned or deleted).
   +    """
   +    now = datetime.datetime.now(datetime.UTC)
   +    warning_threshold = now - 
datetime.timedelta(days=_INACTIVITY_WARNING_DAYS)
   +    delete_threshold = now - 
datetime.timedelta(days=_INACTIVITY_DELETE_DAYS)
   +    acted_count = 0
   +
   +    async with db.session() as data:
   +        # Get all unfinished releases
   +        releases: list[sql.Release] = []
   +        for phase in _UNFINISHED_PHASES:
   +            phase_releases = await data.release(
   +                phase=phase,
   +                _committee=True,
   +                _project=True,
   +                _revisions=True,
   +            ).all()
   +            releases.extend(phase_releases)
   +
   +    for release in releases:
   +        async with db.session() as data:
   +            last_activity = await _last_activity_date(release, data)
   +
   +        if last_activity <= delete_threshold:
   +            # Release is past the deletion threshold
   +            if release.phase == sql.ReleasePhase.RELEASE_PREVIEW:
   +                # For finish-phase releases: notify PMC instead of deleting
   +                await _notify_pmc_inactive(release, last_activity, asf_uid)
   +            else:
   +                # For other phases: delete the release
   +                await _delete_inactive_release(release, last_activity, 
asf_uid)
   +            acted_count += 1
   +        elif last_activity <= warning_threshold:
   +            # Release is in the warning zone
   +            await _warn_release_manager(release, last_activity, asf_uid)
   +            acted_count += 1
   +
   +    return acted_count
   +
   +
   +async def _delete_inactive_release(
   +    release: sql.Release, last_activity: datetime.datetime, asf_uid: str
   +) -> None:
   +    """Delete an inactive release."""
   +    project_key = safe.ProjectKey(release.project_key)
   +    version_key = safe.VersionKey(release.version)
   +    log.info(
   +        f"Auto-deleting inactive release {project_key} {version_key}"
   +        f" (last activity: {last_activity.isoformat()})"
   +    )
   +    # TODO: Send notification email before deletion
   +    # TODO: Perform deletion via storage.write as committee participant
   +    # This requires determining the appropriate authorization context
   +    # for system-initiated deletions
   +
   +
   +async def _warn_release_manager(
   +    release: sql.Release, last_activity: datetime.datetime, asf_uid: str
   +) -> None:
   +    """Send a warning email about an inactive release."""
   +    project_key = safe.ProjectKey(release.project_key)
   +    version_key = safe.VersionKey(release.version)
   +    log.info(
   +        f"Warning about inactive release {project_key} {version_key}"
   +        f" (last activity: {last_activity.isoformat()})"
   +    )
   +    # TODO: Queue MESSAGE_SEND task to the dev mailing list
   +
   +
   +async def _notify_pmc_inactive(
   +    release: sql.Release, last_activity: datetime.datetime, asf_uid: str
   +) -> None:
   +    """Notify PMC about a finish-phase release that has been inactive."""
   +    project_key = safe.ProjectKey(release.project_key)
   +    version_key = safe.VersionKey(release.version)
   +    log.info(
   +        f"Notifying PMC about inactive finish-phase release {project_key} 
{version_key}"
   +        f" (last activity: {last_activity.isoformat()})"
   +    )
   +    # TODO: Queue MESSAGE_SEND task to the private@ mailing list
   ````
   
   ### Open questions
   - How to determine last activity from revisions - need to confirm the 
sql.Revision model has a 'created' timestamp and how to efficiently query it
   - What authorization context should be used for system-initiated deletions 
(the existing delete logic requires a CommitteeParticipant write context)
   - Should a 'warned' flag or timestamp be stored on the release to avoid 
sending duplicate warning emails on consecutive daily maintenance runs?
   - What email address/mailing list should receive the warning 
([email protected]? the specific release manager's email?)
   - The source of atr/tasks/maintenance.py is not available - need to confirm 
how to integrate the inactivity check call into the existing maintenance.run 
function
   - Should there be a per-project or per-release opt-out mechanism for 
auto-deletion?
   - How to handle the 'warn at creation' requirement - should this be a flash 
message in the UI when starting a release, or an email?
   
   ### Files examined
   - `atr/tasks/message.py`
   - `atr/storage/writers/release.py`
   - `atr/tasks/__init__.py`
   - `atr/manager.py`
   - `atr/worker.py`
   - `atr/storage/readers/releases.py`
   - `atr/get/release.py`
   - `atr/web.py`
   
   ---
   *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