You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/05/29 18:25:07 UTC
(tooling-trusted-releases) branch main updated: Warn about and then delete inactive unfinished releases
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-releases.git
The following commit(s) were added to refs/heads/main by this push:
new 6ad5c0fa Warn about and then delete inactive unfinished releases
6ad5c0fa is described below
commit 6ad5c0fa80973f9ba4d62f76077eb1d3c2f51cd3
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Fri May 29 19:08:36 2026 +0100
Warn about and then delete inactive unfinished releases
---
atr/constants.py | 7 +
atr/db/interaction.py | 4 +-
atr/get/compose.py | 17 +
atr/get/finish.py | 16 +
atr/models/sql.py | 6 +
atr/post/__init__.py | 2 +
atr/post/release.py | 58 +++
atr/server.py | 9 +-
atr/shared/activity.py | 32 ++
atr/shared/start.py | 4 +-
atr/storage/__init__.py | 13 +-
atr/storage/writers/release.py | 389 ++++++++++++++++---
atr/storage/writers/revision.py | 6 +-
atr/storage/writers/vote.py | 13 +
atr/tasks/inactivity.py | 388 +++++++++++++++++++
atr/tasks/maintenance.py | 17 +
atr/tasks/quarantine.py | 3 +-
atr/templates/check-selected.html | 3 +
atr/user.py | 12 +
atr/worker.py | 5 +-
migrations/versions/0090_2026.05.29_a13b8c92.py | 96 +++++
tests/unit/test_archived_project_writer_guards.py | 305 +++++++++++++++
tests/unit/test_create_revision.py | 6 +
tests/unit/test_inactivity.py | 443 ++++++++++++++++++++++
tests/unit/test_post_release.py | 83 ++++
25 files changed, 1873 insertions(+), 64 deletions(-)
diff --git a/atr/constants.py b/atr/constants.py
index d24ac8ba..50780127 100644
--- a/atr/constants.py
+++ b/atr/constants.py
@@ -235,3 +235,10 @@ GENERATED_FILE_SUFFIXES: Final[tuple[str, ...]] = (
".min.js",
".min.map",
)
+
+INACTIVITY_DELETE_DAYS: Final[int] = 90
+INACTIVITY_WARNING_DAYS: Final[int] = 80
+
+# Fixed sub claim on JWTs minted from system PATs, and the UID we log for
+# their actions. The minting admin is recorded separately in PAT.created_by.
+SYSTEM_SERVICE_UID: Final[str] = "system"
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 0350af15..52a74f44 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -39,6 +39,7 @@ import atr.models.github as github
import atr.models.results as results
import atr.models.safe as safe
import atr.models.sql as sql
+import atr.principal as principal
import atr.user as user
import atr.util as util
import atr.web as web
@@ -679,7 +680,8 @@ async def release_ready_to_start_vote(
if await pending_quarantine_count(release.key, caller_data=data) > 0:
return PENDING_QUARANTINE_VOTE_BLOCK_MESSAGE
- if not (user.is_committee_member(committee, session.uid) or session.is_admin):
+ authorisation = await principal.Authorisation(session)
+ if not (authorisation.is_member_of(committee.key) or session.is_admin):
return "You must be on the PMC of this project to start a vote"
has_files = await util.has_files(release)
diff --git a/atr/get/compose.py b/atr/get/compose.py
index 2f8fdf3d..4dac26f0 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -39,9 +39,11 @@ import atr.models.sql as sql
import atr.paths as paths
import atr.post as post
import atr.render as render
+import atr.shared.activity as activity
import atr.shared.draft as draft
import atr.storage as storage
import atr.template as template
+import atr.user as user
import atr.util as util
import atr.web as web
@@ -157,6 +159,7 @@ async def selected(
submit_classes="btn btn-primary",
submit_disabled=release.check_cache_key is None,
)
+ activity_form_html = await _activity_form_html(release, session)
has_files = await util.has_files(release)
@@ -234,6 +237,7 @@ async def selected(
models=sql,
recheck_form=recheck_form,
cache_reset_form=cache_reset_form,
+ activity_form=activity_form_html,
csrf_input=str(form.csrf_input()),
has_files=has_files,
blocker_errors=blocker_errors,
@@ -262,6 +266,19 @@ async def status_selected(
return _status_error_response(None, default_status=500, expose_message=False)
+async def _activity_form_html(release: sql.Release, session: web.Committer) -> str:
+ if not user.is_participant_for_committee(release.committee, session.projects):
+ return ""
+ activity_form = await form.render(
+ model_cls=form.Empty,
+ action=util.as_url(post.release.activity, project_key=release.project.key, version_key=release.version),
+ submit_label="Reset inactivity clock",
+ submit_classes="btn-outline-primary",
+ pre_submit=activity.inactivity_form_intro(release),
+ )
+ return str(activity_form)
+
+
def _banner_html(quarantine_pending: int, ongoing: int) -> str:
is_are = "is" if (ongoing == 1) else "are"
task_word = "task" if (ongoing == 1) else "tasks"
diff --git a/atr/get/finish.py b/atr/get/finish.py
index 8348c065..1e653c67 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -45,9 +45,12 @@ import atr.models.args as args
import atr.models.safe as safe
import atr.models.sql as sql
import atr.paths as paths
+import atr.post as post
import atr.render as render
import atr.shared as shared
+import atr.shared.activity as activity
import atr.template as template
+import atr.user as user
import atr.util as util
import atr.web as web
@@ -103,6 +106,7 @@ async def selected(
}"
return await _render_page(
+ session=session,
release=release,
deletable_dirs=deletable_dirs,
rc_analysis=rc_analysis,
@@ -288,6 +292,7 @@ def _render_distribution_tasks(release: sql.Release, tasks: Sequence[sql.Task])
async def _render_page(
+ session: web.Committer,
release: sql.Release,
deletable_dirs: list[tuple[str, str]],
rc_analysis: RCTagAnalysisResult,
@@ -358,6 +363,17 @@ async def _render_page(
# Remove RC tags section
page.append(await _render_rc_tags_section(rc_analysis))
+ if user.is_participant_for_committee(release.committee, session.projects):
+ page.h2["Inactivity"]
+ activity_form = await form.render(
+ model_cls=form.Empty,
+ action=util.as_url(post.release.activity, project_key=release.project.key, version_key=release.version),
+ submit_label="Reset inactivity clock",
+ submit_classes="btn-outline-primary",
+ pre_submit=activity.inactivity_form_intro(release, action="flagged"),
+ )
+ page.div(".mb-4")[activity_form]
+
# Custom styles
page_styles = """
.page-file-select-text {
diff --git a/atr/models/sql.py b/atr/models/sql.py
index eeba5404..c95add4b 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -1236,6 +1236,12 @@ class Release(sqlmodel.SQLModel, table=True):
sa_column=sqlalchemy.Column(UTCDateTime),
**example(datetime.datetime(2026, 1, 15, 1, 2, 3, tzinfo=datetime.UTC)),
)
+ activity_at: datetime.datetime = sqlmodel.Field(
+ default_factory=lambda: datetime.datetime.now(datetime.UTC),
+ sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
+ **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
+ )
+ inactivity_notice_key: str | None = sqlmodel.Field(default=None)
# Set at start time when the user opts in to archiving the prior release
# in this cycle when this release is announced.
archive_prior_release: bool = sqlmodel.Field(default=False)
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index c76d4f61..478d3356 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -26,6 +26,7 @@ import atr.post.keys as keys
import atr.post.manual as manual
import atr.post.notifications as notifications
import atr.post.projects as projects
+import atr.post.release as release
import atr.post.resolve as resolve
import atr.post.revisions as revisions
import atr.post.sbom as sbom
@@ -49,6 +50,7 @@ __all__ = [
"manual",
"notifications",
"projects",
+ "release",
"resolve",
"revisions",
"sbom",
diff --git a/atr/post/release.py b/atr/post/release.py
new file mode 100644
index 00000000..89bbd3d3
--- /dev/null
+++ b/atr/post/release.py
@@ -0,0 +1,58 @@
+# 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.
+
+from typing import Literal
+
+import quart
+
+import atr.blueprints.post as post
+import atr.form as form
+import atr.get as get
+import atr.mapping as mapping
+import atr.models.safe as safe
+import atr.storage as storage
+import atr.web as web
+
+
+@post.typed
+async def activity(
+ session: web.Committer,
+ _release: Literal["release"],
+ _activity: Literal["activity"],
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ _form: form.Empty,
+) -> web.WerkzeugResponse:
+ """
+ URL: /release/activity/<project_key>/<version_key>
+ """
+ return await _bump_activity(session, project_key, version_key)
+
+
+async def _bump_activity(
+ session: web.Committer,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+) -> web.WerkzeugResponse:
+ try:
+ async with storage.write(session) as write:
+ wacp = await write.as_project_committee_participant(project_key)
+ release = await wacp.release.bump_activity(project_key, version_key)
+ except storage.AccessError as e:
+ return await session.redirect(get.root.index, error=str(e))
+ await quart.flash("Inactivity clock reset", "success")
+ return await mapping.release_as_redirect(session, release)
diff --git a/atr/server.py b/atr/server.py
index b0cd2e47..ce55436a 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -51,6 +51,7 @@ import atr
import atr.blueprints as blueprints
import atr.cache as cache
import atr.config as config
+import atr.constants as constants
import atr.db as db
import atr.db.interaction as interaction
import atr.errors as errors
@@ -992,19 +993,19 @@ async def _register_recurrent_tasks() -> None:
"""Schedule recurring tasks"""
await tasks.clear_scheduled()
# Run maintenance task immediately on server startup
- maintenance = await tasks.run_maintenance(asf_uid="system", schedule_next=True)
+ maintenance = await tasks.run_maintenance(asf_uid=constants.SYSTEM_SERVICE_UID, schedule_next=True)
log.info(f"Scheduled maintenance with ID {maintenance.id}")
# Start other tasks 5 min after server start
await asyncio.sleep(300)
try:
await asyncio.sleep(60)
- metadata = await tasks.metadata_update(asf_uid="system", schedule_next=True)
+ metadata = await tasks.metadata_update(asf_uid=constants.SYSTEM_SERVICE_UID, schedule_next=True)
log.info(f"Scheduled remote metadata update with ID {metadata.id}")
await asyncio.sleep(60)
- workflow = await tasks.workflow_update(asf_uid="system", schedule_next=True)
+ workflow = await tasks.workflow_update(asf_uid=constants.SYSTEM_SERVICE_UID, schedule_next=True)
log.info(f"Scheduled workflow status update with ID {workflow.id}")
await asyncio.sleep(60)
- dist_check = await tasks.distribution_status_check(asf_uid="system", schedule_next=True)
+ dist_check = await tasks.distribution_status_check(asf_uid=constants.SYSTEM_SERVICE_UID, schedule_next=True)
log.info(f"Scheduled distribution status update with ID {dist_check.id}")
except Exception as e:
diff --git a/atr/shared/activity.py b/atr/shared/activity.py
new file mode 100644
index 00000000..de6c6a53
--- /dev/null
+++ b/atr/shared/activity.py
@@ -0,0 +1,32 @@
+# 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.
+
+import datetime
+
+import atr.htm as htm
+import atr.models.sql as sql
+import atr.util as util
+
+
+def inactivity_form_intro(release: sql.Release, action: str = "deleted") -> htm.Element:
+ days = max(0, (datetime.datetime.now(datetime.UTC) - release.activity_at).days)
+ return htm.div[
+ htm.div(".mb-2")[
+ f"This release has been inactive for {util.plural(days, 'day')}. "
+ f"After 90 days of inactivity this project will be {action}."
+ ],
+ ]
diff --git a/atr/shared/start.py b/atr/shared/start.py
index 88799660..d72bea7f 100644
--- a/atr/shared/start.py
+++ b/atr/shared/start.py
@@ -27,7 +27,9 @@ class StartReleaseForm(form.Form):
"Enter the version string for this new release."
" This cannot be changed later, and must be the version of the finished release."
" ATR generates a unique revision serial number for each voting round,"
- " and you can also set your own tag before a vote starts.",
+ " and you can also set your own tag before a vote starts."
+ " Release candidate drafts inactive for 90 days are cleaned up automatically,"
+ " with a warning email sent at 80 days.",
)
auto_archive_prior: form.Bool = form.label(
"Auto archive prior release",
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index 69ad9a37..c67731e7 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -21,11 +21,13 @@ import contextlib
import datetime
import json
import logging
-from typing import TYPE_CHECKING, Final
+import types
+from typing import TYPE_CHECKING, Any, Final
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
+import atr.constants as constants
import atr.db as db
import atr.log as log
import atr.models.basic as basic
@@ -594,6 +596,15 @@ async def write_as_project_release_manager(
yield await w.as_project_release_manager(project_key)
+@contextlib.asynccontextmanager
+async def write_as_system() -> AsyncGenerator[writers.release.FoundationAdmin]:
+ async with db.session() as data:
+ system_authorisation = types.SimpleNamespace(asf_uid=constants.SYSTEM_SERVICE_UID)
+ system_write: Any = types.SimpleNamespace(authorisation=system_authorisation)
+ system_write_as: Any = WriteAs()
+ yield writers.release.FoundationAdmin(system_write, system_write_as, data)
+
+
@contextlib.asynccontextmanager
async def write_as_user_service(asf_uid: str) -> AsyncGenerator[WriteAsUserService]:
async with db.session() as data:
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 3d6e6bc9..978dd9e3 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -33,6 +33,7 @@ import sqlmodel
import atr.analysis as analysis
import atr.config as config
+import atr.constants as constants
import atr.cycles as cycles
import atr.db as db
import atr.db.interaction as interaction
@@ -60,6 +61,13 @@ if TYPE_CHECKING:
SPECIAL_SUFFIXES: Final[frozenset[str]] = frozenset({".asc", ".sha256", ".sha512"})
+_ACTIVITY_BUMP_PHASES: Final[frozenset[sql.ReleasePhase]] = frozenset(
+ {
+ sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ sql.ReleasePhase.RELEASE_CANDIDATE,
+ sql.ReleasePhase.RELEASE_PREVIEW,
+ }
+)
_SIGNATURE_CHECKER_KEY: Final[str] = checks.function_key(signature.check)
@@ -160,15 +168,55 @@ class CommitteeParticipant(FoundationCommitter):
committee_key: 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("Not authorized", status=403)
+ self.__write = write
+ self.__write_as = write_as
+ self.__data = data
self.__asf_uid = asf_uid
self.__committee_key = committee_key
+ async def bump_activity(self, project_key: safe.ProjectKey, version_key: safe.VersionKey) -> sql.Release:
+ release = await self.__data.release(
+ project_key=str(project_key), version=str(version_key), _committee=True
+ ).demand(storage.AccessError(f"Release '{project_key!s} {version_key!s}' not found.", status=404))
+ storage.ensure_project_active(release.project)
+ ineligible_phase = release.phase not in _ACTIVITY_BUMP_PHASES
+ if ineligible_phase:
+ raise storage.AccessError(
+ f"Release phase {release.phase.value} is not eligible for activity reset", status=409
+ )
+ now = datetime.datetime.now(datetime.UTC)
+ previous_activity_at = release.activity_at
+ via = sql.validate_instrumented_attribute
+ release_activity_at = via(sql.Release.activity_at)
+ activity_at_value = sqlalchemy.case(
+ (release_activity_at > now, release_activity_at),
+ else_=now,
+ )
+ result = await self.__data.execute(
+ sqlmodel.update(sql.Release)
+ .where(
+ via(sql.Release.key) == release.key,
+ via(sql.Release.phase).in_(_ACTIVITY_BUMP_PHASES),
+ )
+ .values(activity_at=activity_at_value, inactivity_notice_key=None)
+ )
+ if getattr(result, "rowcount", 0) != 1:
+ await self.__data.rollback()
+ raise storage.AccessError("The release state has changed, please refresh and try again", status=409)
+ await self.__data.refresh(release)
+ await self.__data.commit()
+ self.__write_as.append_to_audit_log(
+ asf_uid=self.__asf_uid,
+ project_key=str(project_key),
+ version=str(version_key),
+ previous_activity_at=previous_activity_at.isoformat(),
+ activity_at=release.activity_at.isoformat(),
+ )
+ return release
+
async def delete(
self,
project_key: safe.ProjectKey,
@@ -181,6 +229,15 @@ class CommitteeParticipant(FoundationCommitter):
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))
storage.ensure_project_active(release.project)
+ return await self.__delete_body(release, project_key, version, include_downloads)
+
+ async def __delete_body(
+ self,
+ release: sql.Release,
+ project_key: safe.ProjectKey,
+ version: safe.VersionKey,
+ include_downloads: bool,
+ ) -> str | None:
release_dirs = [
paths.release_directory_base(release),
paths.get_attestable_dir() / str(project_key) / str(version),
@@ -247,6 +304,53 @@ class CommitteeParticipant(FoundationCommitter):
)
return error
+ async def __delete_release_data_downloads(self, release: sql.Release) -> None:
+ finished_dir = paths.release_directory(release)
+ if await aiofiles.os.path.isdir(finished_dir):
+ release_inodes = set()
+ async for file_path in util.paths_recursive(finished_dir):
+ try:
+ stat_result = await aiofiles.os.stat(finished_dir / file_path)
+ release_inodes.add(stat_result.st_ino)
+ except FileNotFoundError:
+ continue
+
+ if release_inodes:
+ downloads_dir = paths.get_downloads_dir()
+ async for link_path in util.paths_recursive(downloads_dir):
+ full_link_path = downloads_dir / link_path
+ try:
+ link_stat = await aiofiles.os.stat(full_link_path)
+ if link_stat.st_ino in release_inodes:
+ await aiofiles.os.remove(full_link_path)
+ log.info(f"Deleted hard link: {full_link_path}")
+ except FileNotFoundError:
+ continue
+
+ async def __delete_release_data_filesystem(
+ self, release_dirs: Sequence[safe.StatePath], project_key: safe.ProjectKey, version: safe.VersionKey
+ ) -> str | None:
+ delete_errors: list[str] = []
+ for release_dir in release_dirs:
+ if await aiofiles.os.path.isdir(release_dir):
+ try:
+ log.info(f"Deleting filesystem directory: {release_dir}")
+ await util.delete_immutable_directory(
+ release_dir, reason=f"user {self.__asf_uid} is deleting release {project_key!s} {version!s}"
+ )
+ log.info(f"Successfully deleted directory: {release_dir}")
+ except Exception as e:
+ log.exception(f"Error deleting filesystem directory {release_dir}:")
+ delete_errors.append(f"{release_dir}: {e!s}")
+ else:
+ log.warning(f"Filesystem directory not found, skipping deletion: {release_dir}")
+ if delete_errors:
+ return (
+ f"Database records for '{project_key!s} {version!s}' deleted,"
+ f" but failed to delete filesystem directories: {', '.join(delete_errors)}"
+ )
+ return None
+
async def delete_empty_directory(
self, project_key: safe.ProjectKey, version_key: safe.VersionKey, dir_to_delete_rel: pathlib.Path
) -> str | None:
@@ -703,10 +807,22 @@ class CommitteeParticipant(FoundationCommitter):
# Promote it to RELEASE_CANDIDATE
via = sql.validate_instrumented_attribute
vote_seq = await self.__vote_seq_allocate(release_for_pre_checks.key)
+ now = datetime.datetime.now(datetime.UTC)
+ release_activity_at = via(sql.Release.activity_at)
+ activity_at_value = sqlalchemy.case(
+ (release_activity_at > now, release_activity_at),
+ else_=now,
+ )
+ notice_key_value = sqlalchemy.case(
+ (release_activity_at > now, via(sql.Release.inactivity_notice_key)),
+ else_=None,
+ )
values: dict[str, object] = {
- "vote_started": datetime.datetime.now(datetime.UTC),
+ "vote_started": now,
"vote_resolved": None,
"current_vote_seq": vote_seq,
+ "activity_at": activity_at_value,
+ "inactivity_notice_key": notice_key_value,
}
if promote:
values["phase"] = sql.ReleasePhase.RELEASE_CANDIDATE
@@ -794,6 +910,7 @@ class CommitteeParticipant(FoundationCommitter):
self.__validate_version(project, version)
cycle_key = await self.__ensure_project_cycle(project, version)
+ now = datetime.datetime.now(datetime.UTC)
release = sql.Release(
phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
project_key=project.key,
@@ -801,7 +918,8 @@ class CommitteeParticipant(FoundationCommitter):
version=str(version),
cycle_key=cycle_key,
archive_prior_release=auto_archive,
- created=datetime.datetime.now(datetime.UTC),
+ created=now,
+ activity_at=now,
)
self.__data.add(release)
await self.__data.commit()
@@ -955,54 +1073,6 @@ class CommitteeParticipant(FoundationCommitter):
all_current_paths_interim.sort(key=lambda p: (-len(p.parts), str(p)))
return all_current_paths_interim
- async def __delete_release_data_downloads(self, release: sql.Release) -> None:
- # Delete hard links from the downloads directory
- finished_dir = paths.release_directory(release)
- if await aiofiles.os.path.isdir(finished_dir):
- release_inodes = set()
- async for file_path in util.paths_recursive(finished_dir):
- try:
- stat_result = await aiofiles.os.stat(finished_dir / file_path)
- release_inodes.add(stat_result.st_ino)
- except FileNotFoundError:
- continue
-
- if release_inodes:
- downloads_dir = paths.get_downloads_dir()
- async for link_path in util.paths_recursive(downloads_dir):
- full_link_path = downloads_dir / link_path
- try:
- link_stat = await aiofiles.os.stat(full_link_path)
- if link_stat.st_ino in release_inodes:
- await aiofiles.os.remove(full_link_path)
- log.info(f"Deleted hard link: {full_link_path}")
- except FileNotFoundError:
- continue
-
- async def __delete_release_data_filesystem(
- self, release_dirs: Sequence[safe.StatePath], project_key: safe.ProjectKey, version: safe.VersionKey
- ) -> str | None:
- delete_errors: list[str] = []
- for release_dir in release_dirs:
- if await aiofiles.os.path.isdir(release_dir):
- try:
- log.info(f"Deleting filesystem directory: {release_dir}")
- await util.delete_immutable_directory(
- release_dir, reason=f"user {self.__asf_uid} is deleting release {project_key!s} {version!s}"
- )
- log.info(f"Successfully deleted directory: {release_dir}")
- except Exception as e:
- log.exception(f"Error deleting filesystem directory {release_dir}:")
- delete_errors.append(f"{release_dir}: {e!s}")
- else:
- log.warning(f"Filesystem directory not found, skipping deletion: {release_dir}")
- if delete_errors:
- return (
- f"Database records for '{project_key!s} {version!s}' deleted,"
- f" but failed to delete filesystem directories: {', '.join(delete_errors)}"
- )
- return None
-
@contextlib.asynccontextmanager
async def __open_for_replace(self, target_path: pathlib.Path) -> AsyncIterator[Any]:
# Unlink first - the target could be a hardlink from a prior revision, locked 0o444.
@@ -1301,6 +1371,19 @@ class CommitteeMember(CommitteeParticipant):
class FoundationAdmin(FoundationCommitter):
+ BLOCKING_QUARANTINE_STATUSES: Final[frozenset[sql.QuarantineStatus]] = frozenset(
+ {sql.QuarantineStatus.STAGING, sql.QuarantineStatus.PENDING}
+ )
+
+ ELIGIBLE_PHASES: Final[frozenset[sql.ReleasePhase]] = frozenset(
+ {
+ sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ sql.ReleasePhase.RELEASE_CANDIDATE,
+ }
+ )
+
+ BLOCKING_TASK_STATUSES: Final[frozenset[sql.TaskStatus]] = frozenset({sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE})
+
def __init__(self, write: storage.Write, write_as: storage.WriteAsFoundationAdmin, data: db.Session) -> None:
super().__init__(write, write_as, data)
self.__write = write
@@ -1310,3 +1393,203 @@ class FoundationAdmin(FoundationCommitter):
if asf_uid is None:
raise storage.AccessError("Not authorized", status=403)
self.__asf_uid = asf_uid
+
+ async def delete_inactive(
+ self,
+ project_key: safe.ProjectKey,
+ version: safe.VersionKey,
+ dry_run: bool = False,
+ ) -> str | None:
+ error = await self.__check_eligibility(project_key, version)
+ if error is not None:
+ return error
+ if dry_run:
+ return None
+ await self.__data.begin_immediate()
+ self.__data.expire_all()
+ final_error = await self.__check_eligibility(project_key, version)
+ if final_error is not None:
+ await self.__data.rollback()
+ return final_error
+ return await self.__delete(project_key, version)
+
+ async def __delete(
+ self,
+ project_key: safe.ProjectKey,
+ version: safe.VersionKey,
+ include_downloads: bool = True,
+ ) -> str | None:
+ 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)
+ )
+ storage.ensure_project_active(release.project)
+ return await self.__delete_body(release, project_key, version, include_downloads)
+
+ async def __delete_body(
+ self,
+ release: sql.Release,
+ project_key: safe.ProjectKey,
+ version: safe.VersionKey,
+ include_downloads: bool,
+ ) -> str | None:
+ 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),
+ ]
+
+ # Delete from the database using bulk SQL DELETE for efficiency
+ log.info(f"Deleting database records for release: {project_key!s} {version!s}")
+
+ # Bulk delete tasks
+ # These is no cascade, so we must delete explicitly
+ via = sql.validate_instrumented_attribute
+ task_delete_stmt = sqlmodel.delete(sql.Task).where(
+ via(sql.Task.project_key) == release.project.key,
+ via(sql.Task.version_key) == release.version,
+ )
+ task_result = await self.__data.execute(task_delete_stmt)
+ task_count = task_result.rowcount if isinstance(task_result, engine.CursorResult) else 0
+ log.debug(f"Deleted {util.plural(task_count, 'task')} for {project_key!s} {version!s}")
+
+ release_key = release.key
+
+ # These deletes would also be performed by database cascade
+ # We do them here before the commit instead to be explicit
+ rfs_delete_stmt = sqlmodel.delete(sql.ReleaseFileState).where(
+ via(sql.ReleaseFileState.release_key) == release_key,
+ )
+ rfs_result = await self.__data.execute(rfs_delete_stmt)
+ rfs_count = rfs_result.rowcount if isinstance(rfs_result, engine.CursorResult) else 0
+ log.debug(f"Deleted {util.plural(rfs_count, 'file state row')} for {project_key!s} {version!s}")
+
+ await self.__data.delete(release)
+ log.info(f"Deleted release record: {project_key!s} {version!s}")
+
+ # In test mode, delete the counter for test committee releases
+ # This allows revision numbers to be reused in testing
+ committee = release.project.committee
+ is_test_release = config.is_test_mode() and (committee is not None) and (committee.key == "test")
+ if is_test_release:
+ counter_delete_stmt = sqlmodel.delete(sql.RevisionCounter).where(
+ via(sql.RevisionCounter.release_key) == release_key
+ )
+ await self.__data.execute(counter_delete_stmt)
+ vote_counter_delete_stmt = sqlmodel.delete(sql.VoteCounter).where(
+ via(sql.VoteCounter.release_key) == release_key
+ )
+ await self.__data.execute(vote_counter_delete_stmt)
+ log.info(f"Deleted revision and vote counters for test release: {release_key}")
+
+ # Filesystem deletions are more likely to have permission errors than database deletions
+ # Therefore we do filesystem deletions first
+ if include_downloads:
+ await self.__delete_release_data_downloads(release)
+ error = await self.__delete_release_data_filesystem(release_dirs, project_key, version)
+
+ await self.__data.commit()
+
+ 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 __delete_release_data_downloads(self, release: sql.Release) -> None:
+ finished_dir = paths.release_directory(release)
+ if await aiofiles.os.path.isdir(finished_dir):
+ release_inodes = set()
+ async for file_path in util.paths_recursive(finished_dir):
+ try:
+ stat_result = await aiofiles.os.stat(finished_dir / file_path)
+ release_inodes.add(stat_result.st_ino)
+ except FileNotFoundError:
+ continue
+
+ if release_inodes:
+ downloads_dir = paths.get_downloads_dir()
+ async for link_path in util.paths_recursive(downloads_dir):
+ full_link_path = downloads_dir / link_path
+ try:
+ link_stat = await aiofiles.os.stat(full_link_path)
+ if link_stat.st_ino in release_inodes:
+ await aiofiles.os.remove(full_link_path)
+ log.info(f"Deleted hard link: {full_link_path}")
+ except FileNotFoundError:
+ continue
+
+ async def __delete_release_data_filesystem(
+ self, release_dirs: Sequence[safe.StatePath], project_key: safe.ProjectKey, version: safe.VersionKey
+ ) -> str | None:
+ delete_errors: list[str] = []
+ for release_dir in release_dirs:
+ if await aiofiles.os.path.isdir(release_dir):
+ try:
+ log.info(f"Deleting filesystem directory: {release_dir}")
+ await util.delete_immutable_directory(
+ release_dir, reason=f"user {self.__asf_uid} is deleting release {project_key!s} {version!s}"
+ )
+ log.info(f"Successfully deleted directory: {release_dir}")
+ except Exception as e:
+ log.exception(f"Error deleting filesystem directory {release_dir}:")
+ delete_errors.append(f"{release_dir}: {e!s}")
+ else:
+ log.warning(f"Filesystem directory not found, skipping deletion: {release_dir}")
+ if delete_errors:
+ return (
+ f"Database records for '{project_key!s} {version!s}' deleted,"
+ f" but failed to delete filesystem directories: {', '.join(delete_errors)}"
+ )
+ return None
+
+ async def __check_eligibility(
+ self,
+ project_key: safe.ProjectKey,
+ version: safe.VersionKey,
+ ) -> str | None:
+ release = await self.__data.release(project_key=str(project_key), version=str(version), _committee=True).get()
+ if release is None:
+ return "release no longer exists"
+ if release.project.status != sql.ProjectStatus.ACTIVE:
+ return f"release project '{release.project_key}' is not active"
+ if release.phase not in FoundationAdmin.ELIGIBLE_PHASES:
+ return f"release phase {release.phase.value} is not eligible for system deletion"
+ now = datetime.datetime.now(datetime.UTC)
+ age_days = (now - release.activity_at).days
+ if age_days < constants.INACTIVITY_DELETE_DAYS:
+ return f"release activity is only {age_days} days old; threshold is {constants.INACTIVITY_DELETE_DAYS}"
+ if await self.__has_blocking_tasks(release):
+ return "release has queued or active tasks"
+ if await self.__has_blocking_quarantine(release):
+ return "release has staging or pending quarantine rows"
+ return None
+
+ async def __has_blocking_quarantine(self, release: sql.Release) -> bool:
+ via = sql.validate_instrumented_attribute
+ stmt = (
+ sqlmodel.select(sqlalchemy.func.count())
+ .select_from(sql.Quarantined)
+ .where(
+ via(sql.Quarantined.release_key) == release.key,
+ via(sql.Quarantined.status).in_(list(FoundationAdmin.BLOCKING_QUARANTINE_STATUSES)),
+ )
+ )
+ result = await self.__data.execute(stmt)
+ return int(result.scalar_one()) > 0
+
+ async def __has_blocking_tasks(self, release: sql.Release) -> bool:
+ via = sql.validate_instrumented_attribute
+ stmt = (
+ sqlmodel.select(sqlalchemy.func.count())
+ .select_from(sql.Task)
+ .where(
+ via(sql.Task.project_key) == release.project_key,
+ via(sql.Task.version_key) == release.version,
+ via(sql.Task.status).in_(list(FoundationAdmin.BLOCKING_TASK_STATUSES)),
+ )
+ )
+ result = await self.__data.execute(stmt)
+ return int(result.scalar_one()) > 0
diff --git a/atr/storage/writers/revision.py b/atr/storage/writers/revision.py
index 5be7185a..4f1858c1 100644
--- a/atr/storage/writers/revision.py
+++ b/atr/storage/writers/revision.py
@@ -155,17 +155,21 @@ async def _commit_new_revision(
# That makes models.populate_revision_sequence_and_name safe against races
# Because that event is called when data.add is called below
# And we have a write lock at that point through the use of data.begin_immediate
+ now = datetime.datetime.now(datetime.UTC)
new_revision = sql.Revision(
release_key=release_key,
release=release,
asfuid=asf_uid,
- created=datetime.datetime.now(datetime.UTC),
+ created=now,
phase=release.phase,
description=description,
merge_base_revision_key=merge_base_revision_key,
was_quarantined=was_quarantined,
)
data.add(new_revision)
+ if release.activity_at <= now:
+ release.activity_at = now
+ release.inactivity_notice_key = None
# Flush but do not commit the new revision row to get its name and number
# The row will still be invisible to other sessions after flushing
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index b13dbc07..7d2dd0ba 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -21,6 +21,7 @@ from __future__ import annotations
import datetime
from typing import Literal
+import sqlalchemy
import sqlmodel
import atr.config as config
@@ -1210,12 +1211,24 @@ class ReleaseManager(CommitteeParticipant):
stmt = stmt.where(via(sql.Release.podling_thread_id).is_(None))
else:
stmt = stmt.where(via(sql.Release.podling_thread_id) == expected_podling_thread_id)
+ now = datetime.datetime.now(datetime.UTC)
+ release_activity_at = via(sql.Release.activity_at)
+ activity_at_value = sqlalchemy.case(
+ (release_activity_at > now, release_activity_at),
+ else_=now,
+ )
+ notice_key_value = sqlalchemy.case(
+ (release_activity_at > now, via(sql.Release.inactivity_notice_key)),
+ else_=None,
+ )
result = await self.__data.execute(
stmt.values(
phase=new_phase,
vote_mode=new_vote_mode,
vote_resolved=new_vote_resolved,
podling_thread_id=new_podling_thread_id,
+ activity_at=activity_at_value,
+ inactivity_notice_key=notice_key_value,
)
)
if getattr(result, "rowcount", 0) != 1:
diff --git a/atr/tasks/inactivity.py b/atr/tasks/inactivity.py
new file mode 100644
index 00000000..bd428813
--- /dev/null
+++ b/atr/tasks/inactivity.py
@@ -0,0 +1,388 @@
+# 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.
+
+from __future__ import annotations
+
+import dataclasses
+import datetime
+import enum
+from typing import TYPE_CHECKING, Final
+
+import sqlmodel
+
+import atr.config as config
+import atr.constants as constants
+import atr.db as db
+import atr.log as log
+import atr.mail as mail
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage as storage
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+NOTICE_KIND_PREVIEW_ESCALATION: Final[str] = "preview_escalation"
+NOTICE_KIND_WARNING: Final[str] = "warning"
+
+_DELETION_ENABLED: Final[bool] = True
+_UNFINISHED_PHASES: Final[frozenset[sql.ReleasePhase]] = frozenset(
+ {
+ sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ sql.ReleasePhase.RELEASE_CANDIDATE,
+ sql.ReleasePhase.RELEASE_PREVIEW,
+ }
+)
+
+
+class Decision(enum.StrEnum):
+ DELETE_CANDIDATE = "delete_candidate"
+ PREVIEW_ESCALATE = "preview_escalate"
+ SKIP = "skip"
+ WARN = "warn"
+
+
+@dataclasses.dataclass(frozen=True)
+class Plan:
+ release_key: str
+ project_key: str
+ version: str
+ phase: sql.ReleasePhase
+ activity_at: datetime.datetime
+ decision: Decision
+
+
+async def apply_plan(plan: Plan) -> None:
+ match plan.decision:
+ case Decision.SKIP:
+ return
+ case Decision.WARN:
+ await _send_warning(plan)
+ case Decision.PREVIEW_ESCALATE:
+ await _send_preview_escalation(plan)
+ case Decision.DELETE_CANDIDATE:
+ await _delete_or_dry_run(plan)
+
+
+def classify(
+ release: sql.Release,
+ *,
+ now: datetime.datetime,
+) -> Plan:
+ activity_at = release.activity_at
+ age_days = (now - activity_at).days
+
+ if release.phase not in _UNFINISHED_PHASES:
+ return _plan(release, activity_at, Decision.SKIP)
+
+ if age_days < constants.INACTIVITY_WARNING_DAYS:
+ return _plan(release, activity_at, Decision.SKIP)
+
+ if age_days < constants.INACTIVITY_DELETE_DAYS:
+ return _plan(release, activity_at, Decision.WARN)
+
+ if release.phase == sql.ReleasePhase.RELEASE_PREVIEW:
+ return _plan(release, activity_at, Decision.PREVIEW_ESCALATE)
+ return _plan(release, activity_at, Decision.DELETE_CANDIDATE)
+
+
+def deletion_enabled() -> bool:
+ return _DELETION_ENABLED
+
+
+def private_committee_list(committee_key: str) -> str:
+ return f"private@{committee_key}.apache.org"
+
+
+async def run_scan() -> Sequence[Plan]:
+ now = datetime.datetime.now(datetime.UTC)
+ plans: list[Plan] = []
+ via = sql.validate_instrumented_attribute
+
+ async with db.session() as data:
+ query = (
+ sqlmodel.select(sql.Release)
+ .join(sql.Project, via(sql.Release.project_key) == via(sql.Project.key))
+ .where(via(sql.Release.phase).in_(list(_UNFINISHED_PHASES)))
+ .where(via(sql.Project.status) == sql.ProjectStatus.ACTIVE)
+ .options(db.joined_load_nested(sql.Release.project, sql.Project.committee))
+ )
+ result = await data.execute(query)
+ releases: Sequence[sql.Release] = list(result.scalars().all())
+
+ for release in releases:
+ try:
+ plan = classify(release, now=now)
+ except Exception:
+ log.exception(f"Inactivity classification failed for release {release.key!r}")
+ continue
+ plans.append(plan)
+ if plan.decision != Decision.SKIP:
+ log.info(
+ "Inactivity scan decision"
+ f" release={plan.release_key!r}"
+ f" phase={plan.phase.value}"
+ f" decision={plan.decision.value}"
+ f" activity_at={plan.activity_at.isoformat()}"
+ )
+
+ counts: dict[str, int] = {}
+ for plan in plans:
+ counts[plan.decision.value] = counts.get(plan.decision.value, 0) + 1
+ log.info(f"Inactivity scan summary total={len(plans)} deletion_enabled={_DELETION_ENABLED} counts={counts}")
+ return plans
+
+
+def thresholds() -> tuple[int, int]:
+ return constants.INACTIVITY_WARNING_DAYS, constants.INACTIVITY_DELETE_DAYS
+
+
+async def warning_recipients_for(release: sql.Release, data: db.Session) -> list[str]:
+ via = sql.validate_instrumented_attribute
+ query = (
+ sqlmodel.select(via(sql.Revision.asfuid))
+ .where(sql.Revision.release_key == release.key)
+ .where(via(sql.Revision.asfuid) != constants.SYSTEM_SERVICE_UID)
+ .distinct()
+ )
+ result = await data.execute(query)
+ uids: list[str] = sorted({row for row in result.scalars().all() if row})
+ if uids:
+ return [f"{uid}@apache.org" for uid in uids]
+ return [private_committee_list(_committee_key_for(release))]
+
+
+def _committee_key_for(release: sql.Release) -> str:
+ project = release.project
+ if project.committee is not None:
+ return project.committee.key
+ return project.committee_key or release.project_key
+
+
+async def _delete_or_dry_run(plan: Plan) -> None:
+ async with storage.write_as_system() as release_admin:
+ error = await release_admin.delete_inactive(
+ project_key=safe.ProjectKey(plan.project_key),
+ version=safe.VersionKey(plan.version),
+ dry_run=not _DELETION_ENABLED,
+ )
+ if error:
+ log.info(f"Inactivity deletion skipped for {plan.release_key!r}: {error}")
+ return
+ if _DELETION_ENABLED:
+ log.info(f"Inactivity deletion succeeded for {plan.release_key!r}")
+ else:
+ log.info(
+ "Inactivity dry-run would_delete"
+ f" release={plan.release_key!r}"
+ f" phase={plan.phase.value}"
+ f" activity_at={plan.activity_at.isoformat()}"
+ )
+
+
+async def _load_release(data: db.Session, release_key: str) -> sql.Release | None:
+ return await data.release(
+ key=release_key,
+ _committee=True,
+ ).get()
+
+
+def _notice_already_recorded(stored: str | None, expected: str) -> bool:
+ return stored == expected
+
+
+def _notice_key(kind: str, activity_at: datetime.datetime) -> str:
+ return f"{kind}:{activity_at.isoformat()}"
+
+
+def _plan(release: sql.Release, activity_at: datetime.datetime, decision: Decision) -> Plan:
+ return Plan(
+ release_key=release.key,
+ project_key=release.project_key,
+ version=release.version,
+ phase=release.phase,
+ activity_at=activity_at,
+ decision=decision,
+ )
+
+
+def _plan_still_current(plan: Plan, release: sql.Release, *, expected_kind: str) -> bool:
+ project_status = release.project.status
+ if project_status != sql.ProjectStatus.ACTIVE:
+ log.info(
+ f"Inactivity {expected_kind} skipped for {plan.release_key!r}:"
+ f" project {release.project_key!r} status changed to {project_status.value}"
+ )
+ return False
+ if expected_kind == NOTICE_KIND_PREVIEW_ESCALATION:
+ expected_phase_ok = release.phase == sql.ReleasePhase.RELEASE_PREVIEW
+ activity_ok = release.activity_at == plan.activity_at
+ else:
+ expected_phase_ok = release.phase in _UNFINISHED_PHASES
+ activity_ok = release.activity_at == plan.activity_at
+ if not expected_phase_ok:
+ log.info(f"Inactivity {expected_kind} skipped for {plan.release_key!r}: phase changed to {release.phase.value}")
+ return False
+ if not activity_ok:
+ log.info(
+ f"Inactivity {expected_kind} skipped for {plan.release_key!r}:"
+ f" activity_at advanced to {release.activity_at.isoformat()}"
+ )
+ return False
+ return True
+
+
+def _preview_escalation_body(plan: Plan) -> str:
+ _warning_days, delete_days = thresholds()
+ return (
+ f"As of the end of May 2026, ATR is enacting a new policy where release\n"
+ f"previews inactive for more than {delete_days} days are flagged. This release\n"
+ f"preview has now been inactive for at least {delete_days} days, and is being\n"
+ f"flagged. Release previews are not automatically deleted. The Finish\n"
+ f"phase has a form to reset the inactivity clock. Please complete the\n"
+ f"finish step or cancel the release.\n\n"
+ f"Project: {plan.project_key}\n"
+ f"Version: {plan.version}\n"
+ f"Phase: {plan.phase.value}\n"
+ )
+
+
+async def _record_notice_sent(release_key: str, kind: str, activity_at: datetime.datetime) -> None:
+ async with db.session() as data:
+ release = await data.release(key=release_key).get()
+ if release is None:
+ return
+ if release.activity_at != activity_at:
+ return
+ release.inactivity_notice_key = _notice_key(kind, activity_at)
+ await data.commit()
+
+
+async def _send_email(*, recipient: str, subject: str, body: str) -> bool:
+ message = mail.Message(
+ email_sender=mail.NOREPLY_EMAIL_ADDRESS,
+ email_to=recipient,
+ subject=subject,
+ body=body,
+ )
+ try:
+ if config.is_dev_environment():
+ log.info(f"Dev environment detected, not sending inactivity email to {recipient!r}")
+ mid: str | None = message.message_id
+ errors: list[str] = []
+ else:
+ mid, errors = await mail.send(message, mail.MailFooterCategory.AUTO)
+ except Exception:
+ log.exception(f"Inactivity mail send failed for {recipient!r} subject={subject!r}")
+ return False
+ storage.audit(
+ asf_uid=constants.SYSTEM_SERVICE_UID,
+ email_sender=message.email_sender,
+ email_to=message.email_to,
+ subject=message.subject,
+ mid=mid,
+ errors=", ".join(errors) if errors else "",
+ )
+ if errors:
+ log.warning(
+ f"Inactivity mail to {recipient!r} reported SMTP errors {errors};"
+ f" treating as unsuccessful so the notice marker is not recorded"
+ )
+ return False
+ return True
+
+
+async def _send_preview_escalation(plan: Plan) -> None:
+ expected = _notice_key(NOTICE_KIND_PREVIEW_ESCALATION, plan.activity_at)
+ async with db.session() as data:
+ release = await _load_release(data, plan.release_key)
+ if release is None:
+ log.warning(f"Inactivity escalation: release {plan.release_key!r} no longer exists")
+ return
+ if not _plan_still_current(plan, release, expected_kind=NOTICE_KIND_PREVIEW_ESCALATION):
+ return
+ if _notice_already_recorded(release.inactivity_notice_key, expected):
+ log.info(
+ f"Inactivity preview escalation already sent for {plan.release_key!r} at {plan.activity_at.isoformat()}"
+ )
+ return
+ recipient = private_committee_list(_committee_key_for(release))
+
+ body = _preview_escalation_body(plan)
+ sent = await _send_email(
+ recipient=recipient,
+ subject=f"[NOTICE] Release preview inactive: {plan.project_key} {plan.version}",
+ body=body,
+ )
+ if sent:
+ await _record_notice_sent(plan.release_key, NOTICE_KIND_PREVIEW_ESCALATION, plan.activity_at)
+
+
+async def _send_warning(plan: Plan) -> None:
+ expected = _notice_key(NOTICE_KIND_WARNING, plan.activity_at)
+ async with db.session() as data:
+ release = await _load_release(data, plan.release_key)
+ if release is None:
+ log.warning(f"Inactivity warning: release {plan.release_key!r} no longer exists")
+ return
+ if not _plan_still_current(plan, release, expected_kind=NOTICE_KIND_WARNING):
+ return
+ if _notice_already_recorded(release.inactivity_notice_key, expected):
+ log.info(f"Inactivity warning already sent for {plan.release_key!r} at {plan.activity_at.isoformat()}")
+ return
+ recipients = await warning_recipients_for(release, data)
+
+ body = _warning_body(plan)
+ subject = f"[WARNING] Release inactive: {plan.project_key} {plan.version}"
+ all_sent = True
+ for recipient in recipients:
+ if not await _send_email(recipient=recipient, subject=subject, body=body):
+ all_sent = False
+ if all_sent and recipients:
+ await _record_notice_sent(plan.release_key, NOTICE_KIND_WARNING, plan.activity_at)
+ elif recipients:
+ log.warning(
+ f"Inactivity warning for {plan.release_key!r} had at least one recipient failure;"
+ f" notice not recorded so the next maintenance run retries the full set"
+ )
+
+
+def _warning_body(plan: Plan) -> str:
+ warn_days, delete_days = thresholds()
+ remaining_days = delete_days - warn_days
+ if plan.phase == sql.ReleasePhase.RELEASE_PREVIEW:
+ return (
+ f"As of the end of May 2026, ATR is enacting a new policy where release\n"
+ f"previews inactive for more than {delete_days} days are flagged. Release\n"
+ f"previews are not automatically deleted. This message is being sent at\n"
+ f"{warn_days} days (or more) to warn you of this. The Finish phase has a form\n"
+ f"to reset the inactivity clock to avoid this release being flagged in\n"
+ f"{remaining_days} days.\n\n"
+ f"Project: {plan.project_key}\n"
+ f"Version: {plan.version}\n"
+ f"Phase: {plan.phase.value}\n"
+ )
+ return (
+ f"As of the end of May 2026, ATR is enacting a new policy where releases\n"
+ f"inactive for more than {delete_days} days are deleted. This message is being\n"
+ f"sent at {warn_days} days (or more) to warn you of this. The Compose and Finish\n"
+ f"phases have a form to reset the inactivity clock if you want to keep\n"
+ f"this release. Otherwise it will be deleted in {remaining_days} days.\n\n"
+ f"Project: {plan.project_key}\n"
+ f"Version: {plan.version}\n"
+ f"Phase: {plan.phase.value}\n"
+ )
diff --git a/atr/tasks/maintenance.py b/atr/tasks/maintenance.py
index 50b23d28..afa3fecd 100644
--- a/atr/tasks/maintenance.py
+++ b/atr/tasks/maintenance.py
@@ -28,6 +28,7 @@ import atr.models.results as results
import atr.models.sql as sql
import atr.tasks as tasks
import atr.tasks.checks as checks
+import atr.tasks.inactivity as inactivity
_EXPIRED_TOKEN_RETENTION_DAYS: Final[int] = 30
_FORM_ERROR_TTL_SECONDS: Final[int] = 24 * 60 * 60
@@ -51,6 +52,7 @@ async def run(task_args: args.MaintenanceArgs) -> results.Results | None:
await _session_data_maintenance()
await _storage_maintenance()
await _workflow_ssh_keys_maintenance()
+ await _inactivity_maintenance()
log.info(
"Storage maintenance completed successfully",
@@ -77,6 +79,21 @@ async def _expired_pats_maintenance() -> None:
log.info(f"Purged {deleted} expired personal access tokens")
+async def _inactivity_maintenance() -> None:
+ try:
+ plans = await inactivity.run_scan()
+ except Exception:
+ log.exception("Inactivity scan failed")
+ return
+ for plan in plans:
+ if plan.decision == inactivity.Decision.SKIP:
+ continue
+ try:
+ await inactivity.apply_plan(plan)
+ except Exception:
+ log.exception(f"Inactivity action failed for release {plan.release_key!r}")
+
+
async def _session_data_maintenance() -> None:
via = sql.validate_instrumented_attribute
cutoff = time.time() - _FORM_ERROR_TTL_SECONDS
diff --git a/atr/tasks/quarantine.py b/atr/tasks/quarantine.py
index 6eda498a..06b16ed1 100644
--- a/atr/tasks/quarantine.py
+++ b/atr/tasks/quarantine.py
@@ -33,6 +33,7 @@ import exarch
import atr.archives as archives
import atr.attestable as attestable
import atr.config as config
+import atr.constants as constants
import atr.db as db
import atr.detection as detection
import atr.hashes as hashes
@@ -302,7 +303,7 @@ async def _mark_failed(
log.error(f"Quarantine {quarantined.id} failed: {message}")
else:
log.error(f"Quarantine {quarantined.id} failed safety checks")
- if (not asf_uid) or (asf_uid == "system"):
+ if (not asf_uid) or (asf_uid == constants.SYSTEM_SERVICE_UID):
return
reason = message or "safety checks failed"
location_parts = [part for part in (project_key, version_key) if part]
diff --git a/atr/templates/check-selected.html b/atr/templates/check-selected.html
index 703eaa0a..ca6fb8c8 100644
--- a/atr/templates/check-selected.html
+++ b/atr/templates/check-selected.html
@@ -144,6 +144,9 @@
{% endif %}
</div>
<h2 id="more-actions">More actions</h2>
+ {% if activity_form %}
+ <div class="mb-2">{{ activity_form|safe }}</div>
+ {% endif %}
{% if is_viewing_as_admin_fn(current_user.uid) %}
<h3 id="cache-control" class="mt-4">Check cache control</h3>
<p>Each button creates a new revision and restarts all checks.</p>
diff --git a/atr/user.py b/atr/user.py
index 771fd093..8b519916 100644
--- a/atr/user.py
+++ b/atr/user.py
@@ -20,6 +20,7 @@
from __future__ import annotations
import functools
+from typing import TYPE_CHECKING
import atr.cache as cache
import atr.config as config
@@ -27,6 +28,9 @@ import atr.db as db
import atr.models.sql as sql
import atr.util as util
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
def binding_terminology(vote_round: int | None) -> tuple[str, str]:
if vote_round == 1:
@@ -106,6 +110,14 @@ def is_committer(committee: sql.Committee | None, uid: str) -> bool:
return any((committer_uid == uid) for committer_uid in committee.committers)
+def is_participant_for_committee(committee: sql.Committee | None, project_keys: Iterable[str]) -> bool:
+ if committee is None:
+ return False
+ if config.is_test_mode() and (committee.key == "test"):
+ return True
+ return committee.key in project_keys
+
+
async def projects(uid: str, committee_only: bool = False, super_project: bool = False) -> list[sql.Project]:
user_projects: list[sql.Project] = []
async with db.session() as data:
diff --git a/atr/worker.py b/atr/worker.py
index de9ec27a..29d0931d 100644
--- a/atr/worker.py
+++ b/atr/worker.py
@@ -34,6 +34,7 @@ from typing import Any, Final
import sqlmodel
+import atr.constants as constants
import atr.db as db
import atr.ldap as ldap
import atr.log as log
@@ -157,7 +158,7 @@ async def _notify_task_failure(
primary_rel_path: str | None,
error: str,
) -> None:
- if (not asf_uid) or (asf_uid == "system"):
+ if (not asf_uid) or (asf_uid == constants.SYSTEM_SERVICE_UID):
return
message = _task_failure_message(task_type, project_key, version_key, revision_number, primary_rel_path, error)
try:
@@ -284,7 +285,7 @@ async def _task_process(task_id: int, task_type: str, task_args: list[str] | dic
task_results: results.Results | None
try:
if (
- asf_uid != "system"
+ asf_uid != constants.SYSTEM_SERVICE_UID
and not (config.is_test_mode() and asf_uid == "test")
and (config.is_production_mode() or config.is_ldap_configured())
):
diff --git a/migrations/versions/0090_2026.05.29_a13b8c92.py b/migrations/versions/0090_2026.05.29_a13b8c92.py
new file mode 100644
index 00000000..2f431dcd
--- /dev/null
+++ b/migrations/versions/0090_2026.05.29_a13b8c92.py
@@ -0,0 +1,96 @@
+# 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.
+
+"""Release inactivity tracking
+
+Revision ID: 0090_2026.05.29_a13b8c92
+Revises: 0089_2026.05.28_c1b6a816
+Create Date: 2026-05-29 16:38:10.011772+00:00
+"""
+
+import datetime
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+import atr.models.sql as sql
+
+# Revision identifiers, used by Alembic
+revision: str = "0090_2026.05.29_a13b8c92"
+down_revision: str | None = "0089_2026.05.28_c1b6a816"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ backfill = datetime.datetime.now(datetime.UTC)
+ initial_warning = backfill - datetime.timedelta(days=80)
+ utc_datetime = sql.UTCDateTime(timezone=True)
+
+ with op.batch_alter_table("release", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column(
+ "activity_at",
+ sql.UTCDateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("CURRENT_TIMESTAMP"),
+ )
+ )
+ batch_op.add_column(sa.Column("inactivity_notice_key", sa.String(), nullable=True))
+
+ op.execute(
+ sa.text("UPDATE release SET activity_at = :ts").bindparams(
+ sa.bindparam("ts", value=backfill, type_=utc_datetime)
+ )
+ )
+ op.execute(
+ sa.text(
+ """
+ UPDATE release
+ SET activity_at = :initial_warning
+ WHERE phase IN ('RELEASE_CANDIDATE_DRAFT', 'RELEASE_CANDIDATE', 'RELEASE_PREVIEW')
+ AND (
+ SELECT max(activity_at)
+ FROM (
+ SELECT release.created AS activity_at
+ UNION ALL
+ SELECT max(revision.created) AS activity_at
+ FROM revision
+ WHERE revision.release_key = release.key
+ UNION ALL
+ SELECT release.vote_started AS activity_at
+ WHERE release.vote_started IS NOT NULL
+ UNION ALL
+ SELECT release.vote_resolved AS activity_at
+ WHERE release.vote_resolved IS NOT NULL
+ )
+ ) <= :initial_warning
+ """
+ ).bindparams(
+ sa.bindparam("initial_warning", value=initial_warning, type_=utc_datetime),
+ )
+ )
+
+ with op.batch_alter_table("release", schema=None) as batch_op:
+ batch_op.alter_column("activity_at", server_default=None)
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("release", schema=None) as batch_op:
+ batch_op.drop_column("inactivity_notice_key")
+ batch_op.drop_column("activity_at")
diff --git a/tests/unit/test_archived_project_writer_guards.py b/tests/unit/test_archived_project_writer_guards.py
index b7b2aafc..52043e2f 100644
--- a/tests/unit/test_archived_project_writer_guards.py
+++ b/tests/unit/test_archived_project_writer_guards.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import datetime
import unittest.mock as mock
from types import SimpleNamespace
@@ -121,6 +122,276 @@ async def test_distributions_record_blocks_retired_via_release_lookup() -> None:
)
+@pytest.mark.asyncio
+async def test_foundation_admin_delete_inactive_acquires_lock_and_rechecks_before_delete() -> None:
+ import datetime as _dt
+
+ active_project = SimpleNamespace(
+ key="project",
+ status=sql.ProjectStatus.ACTIVE,
+ committee_key="project",
+ display_name="Project",
+ short_display_name="Project",
+ )
+ eligible_release = SimpleNamespace(
+ key=sql.release_key("project", "1.0.0"),
+ project=active_project,
+ project_key="project",
+ version="1.0.0",
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_at=_dt.datetime.now(_dt.UTC) - _dt.timedelta(days=120),
+ )
+ data = mock.MagicMock()
+ data.release = mock.MagicMock(return_value=_query_returning(eligible_release))
+ data.begin_immediate = mock.AsyncMock()
+ data.rollback = mock.AsyncMock()
+ data.expire_all = mock.MagicMock()
+ data.execute = mock.AsyncMock(return_value=mock.MagicMock(scalar_one=mock.MagicMock(return_value=0)))
+
+ writer = object.__new__(release.FoundationAdmin)
+ writer._FoundationAdmin__data = data
+ writer._FoundationAdmin__write_as = mock.MagicMock()
+ delete = mock.AsyncMock(return_value=None)
+ object.__setattr__(writer, "_FoundationAdmin__delete", delete)
+
+ result = await writer.delete_inactive(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ dry_run=False,
+ )
+ assert result is None
+ data.begin_immediate.assert_awaited_once()
+ data.expire_all.assert_called_once()
+ delete.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_foundation_admin_delete_inactive_refresh_catches_concurrent_phase_change() -> None:
+ import datetime as _dt
+
+ active_project = SimpleNamespace(
+ key="project",
+ status=sql.ProjectStatus.ACTIVE,
+ committee_key="project",
+ display_name="Project",
+ short_display_name="Project",
+ )
+ stale_release = SimpleNamespace(
+ key=sql.release_key("project", "1.0.0"),
+ project=active_project,
+ project_key="project",
+ version="1.0.0",
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_at=_dt.datetime.now(_dt.UTC) - _dt.timedelta(days=120),
+ )
+ fresh_release = SimpleNamespace(
+ key=sql.release_key("project", "1.0.0"),
+ project=active_project,
+ project_key="project",
+ version="1.0.0",
+ phase=sql.ReleasePhase.RELEASE_PREVIEW,
+ activity_at=_dt.datetime.now(_dt.UTC) - _dt.timedelta(days=120),
+ )
+
+ data = mock.MagicMock()
+ expired = {"called": False}
+
+ def _on_expire_all() -> None:
+ expired["called"] = True
+
+ def _release_query(**kwargs: object) -> mock.MagicMock:
+ if expired["called"]:
+ return _query_returning(fresh_release)
+ return _query_returning(stale_release)
+
+ data.release = mock.MagicMock(side_effect=_release_query)
+ data.begin_immediate = mock.AsyncMock()
+ data.rollback = mock.AsyncMock()
+ data.expire_all = mock.MagicMock(side_effect=_on_expire_all)
+ data.execute = mock.AsyncMock(return_value=mock.MagicMock(scalar_one=mock.MagicMock(return_value=0)))
+
+ writer = object.__new__(release.FoundationAdmin)
+ writer._FoundationAdmin__data = data
+ writer._FoundationAdmin__write_as = mock.MagicMock()
+ delete = mock.AsyncMock(return_value=None)
+ object.__setattr__(writer, "_FoundationAdmin__delete", delete)
+
+ result = await writer.delete_inactive(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ dry_run=False,
+ )
+ assert result is not None and "not eligible" in result
+ data.begin_immediate.assert_awaited_once()
+ data.expire_all.assert_called_once()
+ data.rollback.assert_awaited_once()
+ delete.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_foundation_admin_delete_inactive_rejects_non_active_project() -> None:
+ import datetime as _dt
+
+ retired_release = SimpleNamespace(
+ key=sql.release_key("project", "1.0.0"),
+ project=_retired_project(),
+ project_key="project",
+ version="1.0.0",
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_at=_dt.datetime(2025, 1, 1, tzinfo=_dt.UTC),
+ )
+ data = mock.MagicMock()
+ data.release = mock.MagicMock(return_value=_query_returning(retired_release))
+ data.begin_immediate = mock.AsyncMock()
+ data.rollback = mock.AsyncMock()
+
+ writer = object.__new__(release.FoundationAdmin)
+ writer._FoundationAdmin__data = data
+ writer._FoundationAdmin__write_as = mock.MagicMock()
+ delete = mock.AsyncMock(return_value=None)
+ object.__setattr__(writer, "_FoundationAdmin__delete", delete)
+
+ error = await writer.delete_inactive(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ dry_run=True,
+ )
+ assert error is not None and "not active" in error
+ delete.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_release_bump_activity_blocks_ineligible_phase() -> None:
+ data = mock.MagicMock()
+ candidate = _active_release(phase=sql.ReleasePhase.RELEASE)
+ data.release = mock.MagicMock(return_value=_query_returning(candidate))
+ data.commit = mock.AsyncMock()
+ data.execute = mock.AsyncMock()
+ writer = object.__new__(release.CommitteeParticipant)
+ writer._CommitteeParticipant__data = data
+ writer._CommitteeParticipant__asf_uid = "tester"
+ writer._CommitteeParticipant__committee_key = "project"
+
+ with pytest.raises(storage.AccessError, match="not eligible"):
+ await writer.bump_activity(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+ data.execute.assert_not_awaited()
+ data.commit.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_release_bump_activity_blocks_retired() -> None:
+ data = mock.MagicMock()
+ retired = _retired_release()
+ retired.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
+ retired.inactivity_notice_key = "warning:old"
+ retired.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+ data.release = mock.MagicMock(return_value=_query_returning(retired))
+ data.commit = mock.AsyncMock()
+ data.execute = mock.AsyncMock()
+ writer = object.__new__(release.CommitteeParticipant)
+ writer._CommitteeParticipant__data = data
+ writer._CommitteeParticipant__asf_uid = "tester"
+ writer._CommitteeParticipant__committee_key = "project"
+
+ with pytest.raises(storage.AccessError, match="archived"):
+ await writer.bump_activity(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+ data.execute.assert_not_awaited()
+ data.commit.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_release_bump_activity_clears_future_notice() -> None:
+ data = mock.MagicMock()
+ future_activity = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
+ candidate = _active_release(activity_at=future_activity)
+ data.release = mock.MagicMock(return_value=_query_returning(candidate))
+ execute_result = mock.MagicMock()
+ execute_result.rowcount = 1
+ data.execute = mock.AsyncMock(return_value=execute_result)
+ data.refresh = mock.AsyncMock(side_effect=lambda obj: _refresh_release_after_bump(obj, future_activity))
+ data.commit = mock.AsyncMock()
+ write_as = mock.MagicMock()
+ writer = object.__new__(release.CommitteeParticipant)
+ writer._CommitteeParticipant__data = data
+ writer._CommitteeParticipant__write_as = write_as
+ writer._CommitteeParticipant__asf_uid = "tester"
+ writer._CommitteeParticipant__committee_key = "project"
+
+ result = await writer.bump_activity(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+ assert result is candidate
+ assert candidate.activity_at is future_activity
+ assert candidate.inactivity_notice_key is None
+ data.execute.assert_awaited_once()
+ data.refresh.assert_awaited_once_with(candidate)
+ data.commit.assert_awaited_once()
+ write_as.append_to_audit_log.assert_called_once_with(
+ asf_uid="tester",
+ project_key="project",
+ version="1.0.0",
+ previous_activity_at=future_activity.isoformat(),
+ activity_at=future_activity.isoformat(),
+ )
+
+
+@pytest.mark.asyncio
+async def test_release_bump_activity_rejects_concurrent_state_change() -> None:
+ data = mock.MagicMock()
+ candidate = _active_release()
+ data.release = mock.MagicMock(return_value=_query_returning(candidate))
+ execute_result = mock.MagicMock()
+ execute_result.rowcount = 0
+ data.execute = mock.AsyncMock(return_value=execute_result)
+ data.rollback = mock.AsyncMock()
+ data.commit = mock.AsyncMock()
+ write_as = mock.MagicMock()
+ writer = object.__new__(release.CommitteeParticipant)
+ writer._CommitteeParticipant__data = data
+ writer._CommitteeParticipant__write_as = write_as
+ writer._CommitteeParticipant__asf_uid = "tester"
+ writer._CommitteeParticipant__committee_key = "project"
+
+ with pytest.raises(storage.AccessError, match="state has changed"):
+ await writer.bump_activity(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+ data.rollback.assert_awaited_once()
+ data.commit.assert_not_awaited()
+ write_as.append_to_audit_log.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_release_bump_activity_updates_activity() -> None:
+ old_activity = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=85)
+ new_activity = old_activity + datetime.timedelta(days=85)
+ candidate = _active_release(activity_at=old_activity)
+ data = mock.MagicMock()
+ data.release = mock.MagicMock(return_value=_query_returning(candidate))
+ execute_result = mock.MagicMock()
+ execute_result.rowcount = 1
+ data.execute = mock.AsyncMock(return_value=execute_result)
+ data.refresh = mock.AsyncMock(side_effect=lambda obj: _refresh_release_after_bump(obj, new_activity))
+ data.commit = mock.AsyncMock()
+ write_as = mock.MagicMock()
+ writer = object.__new__(release.CommitteeParticipant)
+ writer._CommitteeParticipant__data = data
+ writer._CommitteeParticipant__write_as = write_as
+ writer._CommitteeParticipant__asf_uid = "tester"
+ writer._CommitteeParticipant__committee_key = "project"
+
+ result = await writer.bump_activity(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+ assert result is candidate
+ assert candidate.activity_at == new_activity
+ assert candidate.inactivity_notice_key is None
+ data.execute.assert_awaited_once()
+ data.refresh.assert_awaited_once_with(candidate)
+ data.commit.assert_awaited_once()
+ write_as.append_to_audit_log.assert_called_once_with(
+ asf_uid="tester",
+ project_key="project",
+ version="1.0.0",
+ previous_activity_at=old_activity.isoformat(),
+ activity_at=new_activity.isoformat(),
+ )
+
+
@pytest.mark.asyncio
async def test_release_delete_blocks_retired() -> None:
data = mock.MagicMock()
@@ -273,6 +544,35 @@ async def test_vote_send_resolution_blocks_retired() -> None:
)
+def _active_project(key: str = "project") -> SimpleNamespace:
+ return SimpleNamespace(
+ key=key,
+ status=sql.ProjectStatus.ACTIVE,
+ is_active=True,
+ committee_key=key,
+ display_name=key.capitalize(),
+ short_display_name=key.capitalize(),
+ )
+
+
+def _active_release(
+ phase: sql.ReleasePhase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_at: datetime.datetime | None = None,
+) -> SimpleNamespace:
+ activity_at_missing = activity_at is None
+ if activity_at_missing:
+ activity_at = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=85)
+ return SimpleNamespace(
+ key=sql.release_key("project", "1.0.0"),
+ project=_active_project(),
+ project_key="project",
+ version="1.0.0",
+ phase=phase,
+ activity_at=activity_at,
+ inactivity_notice_key="warning:old",
+ )
+
+
def _async_context_manager(value: object) -> mock.MagicMock:
ctx = mock.MagicMock()
ctx.__aenter__ = mock.AsyncMock(return_value=value)
@@ -287,6 +587,11 @@ def _query_returning(obj: object) -> mock.MagicMock:
return query
+def _refresh_release_after_bump(release_obj: SimpleNamespace, activity_at: datetime.datetime) -> None:
+ release_obj.activity_at = activity_at
+ release_obj.inactivity_notice_key = None
+
+
def _retired_project(key: str = "project") -> SimpleNamespace:
return SimpleNamespace(
key=key,
diff --git a/tests/unit/test_create_revision.py b/tests/unit/test_create_revision.py
index 1173a68f..8add4c24 100644
--- a/tests/unit/test_create_revision.py
+++ b/tests/unit/test_create_revision.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import datetime
import os
import pathlib
import unittest.mock as mock
@@ -114,6 +115,7 @@ async def test_clone_from_older_revision_skips_merge_without_intervening_change(
release.project.status = sql.ProjectStatus.ACTIVE
release.project.release_policy = None
release.release_policy = None
+ release.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
release_key = sql.release_key("proj", "1.0")
latest_revision = mock.MagicMock()
@@ -208,6 +210,7 @@ async def test_intervening_revision_triggers_merge_and_uses_latest_parent(tmp_pa
release.project.status = sql.ProjectStatus.ACTIVE
release.project.release_policy = None
release.release_policy = None
+ release.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
release_key = sql.release_key("proj", "1.0")
old_revision = mock.MagicMock()
@@ -284,6 +287,7 @@ async def test_intervening_revision_with_path_provenance_verifies_sources(tmp_pa
release.project.status = sql.ProjectStatus.ACTIVE
release.project.release_policy = None
release.release_policy = None
+ release.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
release_key = sql.release_key("proj", "1.0")
old_revision = mock.MagicMock()
@@ -439,6 +443,7 @@ async def test_modify_path_provenance_flows_into_write_files_data(tmp_path: path
release.project.status = sql.ProjectStatus.ACTIVE
release.project.release_policy = None
release.release_policy = None
+ release.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
import contextlib
@@ -588,6 +593,7 @@ async def test_v1_previous_attestable_suppresses_file_state_rows(tmp_path: pathl
release.project.status = sql.ProjectStatus.ACTIVE
release.project.release_policy = None
release.release_policy = None
+ release.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
release_key = sql.release_key("proj", "1.0")
old_revision = mock.MagicMock()
diff --git a/tests/unit/test_inactivity.py b/tests/unit/test_inactivity.py
new file mode 100644
index 00000000..465af0eb
--- /dev/null
+++ b/tests/unit/test_inactivity.py
@@ -0,0 +1,443 @@
+# 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.
+
+from __future__ import annotations
+
+import dataclasses
+import datetime
+import unittest.mock as mock
+from typing import Any
+
+import pytest
+
+import atr.models.sql as sql
+import atr.tasks.inactivity as inactivity
+
+
+@dataclasses.dataclass
+class FakeProject:
+ key: str = "example"
+ status: sql.ProjectStatus = sql.ProjectStatus.ACTIVE
+ committee_key: str = "example"
+
+
+@dataclasses.dataclass
+class FakeRelease:
+ key: str
+ project_key: str
+ version: str
+ phase: sql.ReleasePhase
+ activity_at: datetime.datetime
+ vote_started: datetime.datetime | None = None
+ vote_resolved: datetime.datetime | None = None
+ revisions: list[Any] = dataclasses.field(default_factory=list)
+ project: FakeProject = dataclasses.field(default_factory=FakeProject)
+
+
+def test_classify_overdue_candidate_is_delete_candidate() -> None:
+ release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE, activity_age_days=120)
+ plan = inactivity.classify(release, now=_now())
+ assert plan.decision == inactivity.Decision.DELETE_CANDIDATE
+
+
+def test_classify_overdue_draft_is_delete_candidate() -> None:
+ release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=100)
+ plan = inactivity.classify(release, now=_now())
+ assert plan.decision == inactivity.Decision.DELETE_CANDIDATE
+
+
+def test_classify_overdue_preview_escalates() -> None:
+ release = _release(phase=sql.ReleasePhase.RELEASE_PREVIEW, activity_age_days=200)
+ plan = inactivity.classify(release, now=_now())
+ assert plan.decision == inactivity.Decision.PREVIEW_ESCALATE
+
+
+def test_classify_preview_warning_window_warns() -> None:
+ release = _release(phase=sql.ReleasePhase.RELEASE_PREVIEW, activity_age_days=85)
+ plan = inactivity.classify(release, now=_now())
+ assert plan.decision == inactivity.Decision.WARN
+ assert plan.phase == sql.ReleasePhase.RELEASE_PREVIEW
+
+
+def test_classify_release_phase_skips() -> None:
+ release = _release(phase=sql.ReleasePhase.RELEASE, activity_age_days=500)
+ plan = inactivity.classify(release, now=_now())
+ assert plan.decision == inactivity.Decision.SKIP
+
+
+def test_classify_under_warning_skips() -> None:
+ release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=10)
+ plan = inactivity.classify(release, now=_now())
+ assert plan.decision == inactivity.Decision.SKIP
+
+
+def test_classify_warning_boundary_warns() -> None:
+ release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=80)
+ plan = inactivity.classify(release, now=_now())
+ assert plan.decision == inactivity.Decision.WARN
+
+
+def test_classify_warning_zone_warns() -> None:
+ release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=85)
+ plan = inactivity.classify(release, now=_now())
+ assert plan.decision == inactivity.Decision.WARN
+
+
+def test_deletion_enabled_by_default() -> None:
+ assert inactivity.deletion_enabled() is True
+
+
+def test_notice_already_recorded_none_returns_false() -> None:
+ assert inactivity._notice_already_recorded(None, "warning:x") is False
+
+
+def test_notice_already_recorded_requires_exact_match_for_normal_warning() -> None:
+ stored = "warning:2026-02-01T00:00:00+00:00"
+ fresh = "warning:2026-03-01T00:00:00+00:00"
+ assert inactivity._notice_already_recorded(stored, fresh) is False
+ assert inactivity._notice_already_recorded(stored, stored) is True
+
+
+def test_notice_key_format_round_trips_activity_at() -> None:
+ activity_at = datetime.datetime(2026, 5, 21, 0, 0, tzinfo=datetime.UTC)
+ key = inactivity._notice_key(inactivity.NOTICE_KIND_WARNING, activity_at)
+ assert key == f"{inactivity.NOTICE_KIND_WARNING}:{activity_at.isoformat()}"
+
+
+def test_plan_still_current_allows_preview_warning() -> None:
+ activity_at = _now() - datetime.timedelta(days=85)
+ plan = inactivity.Plan(
+ release_key="example-0.0.1",
+ project_key="example",
+ version="0.0.1",
+ phase=sql.ReleasePhase.RELEASE_PREVIEW,
+ activity_at=activity_at,
+ decision=inactivity.Decision.WARN,
+ )
+ release = _release(phase=sql.ReleasePhase.RELEASE_PREVIEW, activity_age_days=85)
+ release.activity_at = activity_at
+ assert inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_WARNING) is True
+
+
+def test_plan_still_current_passes_for_matching_state() -> None:
+ activity_at = _now() - datetime.timedelta(days=85)
+ plan = inactivity.Plan(
+ release_key="example-0.0.1",
+ project_key="example",
+ version="0.0.1",
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_at=activity_at,
+ decision=inactivity.Decision.WARN,
+ )
+ release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=85)
+ release.activity_at = activity_at
+ assert inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_WARNING) is True
+
+
+def test_plan_still_current_skips_on_activity_advance() -> None:
+ activity_at = _now() - datetime.timedelta(days=85)
+ plan = inactivity.Plan(
+ release_key="example-0.0.1",
+ project_key="example",
+ version="0.0.1",
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_at=activity_at,
+ decision=inactivity.Decision.WARN,
+ )
+ release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=1)
+ assert inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_WARNING) is False
+
+
+def test_plan_still_current_skips_on_archived_project() -> None:
+ activity_at = _now() - datetime.timedelta(days=85)
+ plan = inactivity.Plan(
+ release_key="example-0.0.1",
+ project_key="example",
+ version="0.0.1",
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_at=activity_at,
+ decision=inactivity.Decision.WARN,
+ )
+ release = _release(
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_age_days=85,
+ project_status=sql.ProjectStatus.RETIRED,
+ )
+ release.activity_at = activity_at
+ assert inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_WARNING) is False
+
+
+def test_plan_still_current_skips_on_phase_change() -> None:
+ plan = inactivity.Plan(
+ release_key="example-0.0.1",
+ project_key="example",
+ version="0.0.1",
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_at=_now() - datetime.timedelta(days=85),
+ decision=inactivity.Decision.WARN,
+ )
+ release = _release(phase=sql.ReleasePhase.RELEASE, activity_age_days=85)
+ assert inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_WARNING) is False
+
+
+def test_plan_still_current_skips_preview_escalation_on_archived_project() -> None:
+ activity_at = _now() - datetime.timedelta(days=120)
+ plan = inactivity.Plan(
+ release_key="example-0.0.1",
+ project_key="example",
+ version="0.0.1",
+ phase=sql.ReleasePhase.RELEASE_PREVIEW,
+ activity_at=activity_at,
+ decision=inactivity.Decision.PREVIEW_ESCALATE,
+ )
+ release = _release(
+ phase=sql.ReleasePhase.RELEASE_PREVIEW,
+ activity_age_days=120,
+ project_status=sql.ProjectStatus.RETIRED,
+ )
+ release.activity_at = activity_at
+ assert (
+ inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_PREVIEW_ESCALATION) is False
+ )
+
+
+def test_private_committee_list_format() -> None:
+ assert inactivity.private_committee_list("example") == "private@example.apache.org"
+
+
+@pytest.mark.asyncio
+async def test_send_email_returns_false_on_smtp_errors(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(inactivity.config, "is_dev_environment", lambda: False)
+ monkeypatch.setattr(inactivity.storage, "audit", mock.MagicMock())
+ monkeypatch.setattr(
+ inactivity.mail,
+ "send",
+ mock.AsyncMock(return_value=("mid-456", ["550 rejected"])),
+ )
+ sent = await inactivity._send_email(recipient="alice@apache.org", subject="x", body="y")
+ assert sent is False
+
+
+@pytest.mark.asyncio
+async def test_send_email_returns_true_when_no_errors(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(inactivity.config, "is_dev_environment", lambda: False)
+ monkeypatch.setattr(inactivity.storage, "audit", mock.MagicMock())
+ monkeypatch.setattr(inactivity.mail, "send", mock.AsyncMock(return_value=("mid-789", [])))
+ sent = await inactivity._send_email(recipient="alice@apache.org", subject="x", body="y")
+ assert sent is True
+
+
+@pytest.mark.asyncio
+async def test_send_warning_does_not_record_on_partial_recipient_failure(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ activity_at = _now() - datetime.timedelta(days=85)
+ plan = inactivity.Plan(
+ release_key="example-0.0.1",
+ project_key="example",
+ version="0.0.1",
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_at=activity_at,
+ decision=inactivity.Decision.WARN,
+ )
+
+ release_stub = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=85)
+ release_stub.activity_at = activity_at
+ release_stub.inactivity_notice_key = None
+
+ data = mock.MagicMock()
+ monkeypatch.setattr(inactivity, "_load_release", mock.AsyncMock(return_value=release_stub))
+ monkeypatch.setattr(
+ inactivity,
+ "warning_recipients_for",
+ mock.AsyncMock(return_value=["alice@apache.org", "bob@apache.org"]),
+ )
+ monkeypatch.setattr(inactivity.db, "session", lambda: _async_context_manager(data))
+
+ send_mock = mock.AsyncMock(side_effect=[True, False])
+ monkeypatch.setattr(inactivity, "_send_email", send_mock)
+ record_mock = mock.AsyncMock()
+ monkeypatch.setattr(inactivity, "_record_notice_sent", record_mock)
+
+ await inactivity._send_warning(plan)
+
+ assert send_mock.await_count == 2
+ record_mock.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_send_warning_does_not_record_when_mail_send_returns_smtp_errors(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ activity_at = _now() - datetime.timedelta(days=85)
+ plan = inactivity.Plan(
+ release_key="example-0.0.1",
+ project_key="example",
+ version="0.0.1",
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_at=activity_at,
+ decision=inactivity.Decision.WARN,
+ )
+
+ release_stub = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=85)
+ release_stub.activity_at = activity_at
+ release_stub.inactivity_notice_key = None
+
+ data = mock.MagicMock()
+ monkeypatch.setattr(inactivity, "_load_release", mock.AsyncMock(return_value=release_stub))
+ monkeypatch.setattr(
+ inactivity,
+ "warning_recipients_for",
+ mock.AsyncMock(return_value=["alice@apache.org"]),
+ )
+ monkeypatch.setattr(inactivity.db, "session", lambda: _async_context_manager(data))
+
+ audit_mock = mock.MagicMock()
+ monkeypatch.setattr(inactivity.storage, "audit", audit_mock)
+ monkeypatch.setattr(inactivity.config, "is_dev_environment", lambda: False)
+
+ mail_send = mock.AsyncMock(return_value=("mid-123", ["550 mailbox unavailable"]))
+ monkeypatch.setattr(inactivity.mail, "send", mail_send)
+ record_mock = mock.AsyncMock()
+ monkeypatch.setattr(inactivity, "_record_notice_sent", record_mock)
+
+ await inactivity._send_warning(plan)
+
+ mail_send.assert_awaited_once()
+ audit_mock.assert_called_once()
+ record_mock.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_send_warning_records_when_all_recipients_succeed(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ activity_at = _now() - datetime.timedelta(days=85)
+ plan = inactivity.Plan(
+ release_key="example-0.0.1",
+ project_key="example",
+ version="0.0.1",
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ activity_at=activity_at,
+ decision=inactivity.Decision.WARN,
+ )
+
+ release_stub = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=85)
+ release_stub.activity_at = activity_at
+ release_stub.inactivity_notice_key = None
+
+ data = mock.MagicMock()
+ monkeypatch.setattr(inactivity, "_load_release", mock.AsyncMock(return_value=release_stub))
+ monkeypatch.setattr(
+ inactivity,
+ "warning_recipients_for",
+ mock.AsyncMock(return_value=["alice@apache.org", "bob@apache.org"]),
+ )
+ monkeypatch.setattr(inactivity.db, "session", lambda: _async_context_manager(data))
+
+ send_mock = mock.AsyncMock(return_value=True)
+ monkeypatch.setattr(inactivity, "_send_email", send_mock)
+ record_mock = mock.AsyncMock()
+ monkeypatch.setattr(inactivity, "_record_notice_sent", record_mock)
+
+ await inactivity._send_warning(plan)
+
+ assert send_mock.await_count == 2
+ record_mock.assert_awaited_once_with(plan.release_key, inactivity.NOTICE_KIND_WARNING, activity_at)
+
+
+def test_thresholds_are_80_and_90() -> None:
+ warning_days, delete_days = inactivity.thresholds()
+ assert warning_days == 80
+ assert delete_days == 90
+
+
+def test_warning_body_for_candidate_keeps_cleanup_copy() -> None:
+ body = inactivity._warning_body(_warning_plan(sql.ReleasePhase.RELEASE_CANDIDATE))
+ lowered = body.lower()
+ assert "80 days" in lowered
+ assert "will be deleted in 10 days" in lowered
+ assert "flagged" not in lowered
+ assert "2026-02-25" not in lowered
+
+
+def test_warning_body_for_draft_keeps_cleanup_copy() -> None:
+ body = inactivity._warning_body(_warning_plan(sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT))
+ lowered = body.lower()
+ assert "80 days" in lowered
+ assert "will be deleted in 10 days" in lowered
+ assert "flagged" not in lowered
+ assert "2026-02-25" not in lowered
+
+
+def test_warning_body_for_preview_does_not_say_deleted() -> None:
+ body = inactivity._warning_body(_warning_plan(sql.ReleasePhase.RELEASE_PREVIEW))
+ lowered = body.lower()
+ assert "80 days" in lowered
+ assert "will be deleted in" not in lowered
+ assert "flagged in\n10 days" in lowered
+ assert "not automatically deleted" in lowered
+ assert "2026-02-25" not in lowered
+
+
+def test_preview_escalation_body_uses_threshold_not_synthetic_date() -> None:
+ body = inactivity._preview_escalation_body(_warning_plan(sql.ReleasePhase.RELEASE_PREVIEW))
+ lowered = body.lower()
+ assert "inactive for at least 90 days" in lowered
+ assert "is being\nflagged" in lowered
+ assert "2026-02-25" not in lowered
+
+
+def _async_context_manager(value: object) -> mock.MagicMock:
+ ctx = mock.MagicMock()
+ ctx.__aenter__ = mock.AsyncMock(return_value=value)
+ ctx.__aexit__ = mock.AsyncMock(return_value=None)
+ return ctx
+
+
+def _now() -> datetime.datetime:
+ return datetime.datetime(2026, 5, 21, 12, 0, tzinfo=datetime.UTC)
+
+
+def _release(
+ *,
+ phase: sql.ReleasePhase,
+ activity_age_days: int,
+ project_status: sql.ProjectStatus = sql.ProjectStatus.ACTIVE,
+) -> FakeRelease:
+ now = _now()
+ activity_at = now - datetime.timedelta(days=activity_age_days)
+ return FakeRelease(
+ key="example-0.0.1",
+ project_key="example",
+ version="0.0.1",
+ phase=phase,
+ activity_at=activity_at,
+ project=FakeProject(key="example", status=project_status, committee_key="example"),
+ )
+
+
+def _warning_plan(phase: sql.ReleasePhase) -> inactivity.Plan:
+ activity_at = _now() - datetime.timedelta(days=85)
+ return inactivity.Plan(
+ release_key="example-0.0.1",
+ project_key="example",
+ version="0.0.1",
+ phase=phase,
+ activity_at=activity_at,
+ decision=inactivity.Decision.WARN,
+ )
diff --git a/tests/unit/test_post_release.py b/tests/unit/test_post_release.py
new file mode 100644
index 00000000..2553cc9c
--- /dev/null
+++ b/tests/unit/test_post_release.py
@@ -0,0 +1,83 @@
+# 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.
+
+import unittest.mock as mock
+
+import pytest
+
+import atr.models.safe as safe
+import atr.post.release as release
+import atr.storage as storage
+
+
+@pytest.mark.asyncio
+async def test_activity_converts_access_error_to_redirect() -> None:
+ redirect_response = mock.MagicMock()
+ session = mock.MagicMock()
+ session.redirect = mock.AsyncMock(return_value=redirect_response)
+ write = mock.MagicMock()
+ write.as_project_committee_participant = mock.AsyncMock(side_effect=storage.AccessError("No access"))
+ context_manager = _async_context_manager(write)
+
+ with mock.patch.object(release.storage, "write", return_value=context_manager):
+ result = await release._bump_activity(
+ session,
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ )
+
+ assert result is redirect_response
+ session.redirect.assert_awaited_once_with(release.get.root.index, error="No access")
+
+
+@pytest.mark.asyncio
+async def test_activity_redirects_with_success() -> None:
+ redirect_response = mock.MagicMock()
+ release_row = mock.MagicMock()
+ session = mock.MagicMock()
+ writer = mock.MagicMock()
+ writer.bump_activity = mock.AsyncMock(return_value=release_row)
+ write_as = mock.MagicMock()
+ write_as.release = writer
+ write = mock.MagicMock()
+ write.as_project_committee_participant = mock.AsyncMock(return_value=write_as)
+ context_manager = _async_context_manager(write)
+ flash = mock.AsyncMock()
+ release_as_redirect = mock.AsyncMock(return_value=redirect_response)
+
+ with (
+ mock.patch.object(release.storage, "write", return_value=context_manager),
+ mock.patch.object(release.quart, "flash", flash),
+ mock.patch.object(release.mapping, "release_as_redirect", release_as_redirect),
+ ):
+ result = await release._bump_activity(
+ session,
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ )
+
+ assert result is redirect_response
+ writer.bump_activity.assert_awaited_once_with(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+ flash.assert_awaited_once_with("Inactivity clock reset", "success")
+ release_as_redirect.assert_awaited_once_with(session, release_row)
+
+
+def _async_context_manager(value: object) -> mock.MagicMock:
+ context_manager = mock.MagicMock()
+ context_manager.__aenter__ = mock.AsyncMock(return_value=value)
+ context_manager.__aexit__ = mock.AsyncMock(return_value=None)
+ return context_manager
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org