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/07/02 16:15:17 UTC
(tooling-trusted-releases) 01/01: Use the contingent approvals service to archive or delete projects
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 18eb7fcf5a8642f00c5771b5c27a714d3d020fc4
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Jul 2 17:13:59 2026 +0100
Use the contingent approvals service to archive or delete projects
---
atr/config.py | 2 +
atr/constants.py | 14 +
atr/db/__init__.py | 31 ++
atr/get/projects.py | 92 +++--
atr/models/__init__.py | 2 +
atr/models/args.py | 5 +
atr/models/{__init__.py => cap.py} | 52 ++-
atr/models/results.py | 7 +-
atr/models/sql.py | 45 +++
atr/post/projects.py | 266 ++++++++++++--
atr/shared/projects.py | 4 +
atr/ssh.py | 1 +
atr/storage/__init__.py | 7 +
atr/storage/writers/project.py | 107 +++++-
atr/storage/writers/release.py | 1 +
atr/tasks/__init__.py | 31 ++
atr/tasks/cap.py | 123 +++++++
atr/util.py | 87 +++++
migrations/versions/0102_2026.06.30_258b186f.py | 75 ++++
tests/unit/test_cap.py | 458 ++++++++++++++++++++++++
tests/unit/test_project_writer_delete.py | 61 +++-
21 files changed, 1369 insertions(+), 102 deletions(-)
diff --git a/atr/config.py b/atr/config.py
index 38a11189..5c58b85f 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -92,6 +92,8 @@ class AppConfig:
SVN_TOKEN = _config_secrets("SVN_TOKEN", STATE_DIR, default=None, cast=str)
SVN_PUBLISH_URL = _config_secrets("SVN_PUBLISH_URL", STATE_DIR, default=None, cast=str)
GITHUB_TOKEN = _config_secrets("GITHUB_TOKEN", STATE_DIR, default=None, cast=str)
+ CAP_API_BASE_URL = decouple.config("CAP_API_BASE_URL", default="https://cap-test.apache.org")
+ CAP_ROLE_ACCOUNT_TOKEN = _config_secrets("CAP_ROLE_ACCOUNT_TOKEN", STATE_DIR, default=None, cast=str)
DEBUG = False
TEMPLATES_AUTO_RELOAD = False
diff --git a/atr/constants.py b/atr/constants.py
index 29231ea9..5060759a 100644
--- a/atr/constants.py
+++ b/atr/constants.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import datetime
from typing import Final
CLOSER_LUA_URL: Final[str] = "https://www.apache.org/dyn/closer.lua"
@@ -23,6 +24,19 @@ ARCHIVE_APACHE_URL: Final[str] = "https://archive.apache.org/dist"
SVN_DIST_PUBLIC_URL: Final[str] = "https://dist.apache.org/repos/dist/atr"
SVN_DIST_ROOT_URL: Final[str] = "https://dist.apache.org/repos/dist"
+CAP_VOTE_DURATION_MINUTES: Final[int] = 7 * 24 * 60
+CAP_ARCHIVE_APPROVAL_TYPE: Final[str] = "majority_approval"
+CAP_DELETE_APPROVAL_TYPE: Final[str] = "majority_approval"
+CAP_RESOLVE_INITIAL_DELAY: Final[datetime.timedelta] = datetime.timedelta(minutes=1)
+CAP_RESOLVE_RETRY_DELAYS: Final[tuple[datetime.timedelta, ...]] = (
+ datetime.timedelta(minutes=2),
+ datetime.timedelta(minutes=5),
+ datetime.timedelta(minutes=15),
+ datetime.timedelta(minutes=45),
+ datetime.timedelta(hours=2),
+ datetime.timedelta(hours=6),
+)
+
APACHE_LICENSE_2_0: Final[str] = """\
Apache License
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index a3c2dba1..b7d0698c 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -156,6 +156,37 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
async def begin_immediate(self) -> None:
await self.execute(sqlalchemy.text("BEGIN IMMEDIATE"))
+ def approval_request(
+ self,
+ id: Opt[int] = NOT_SET,
+ project_key: Opt[str] = NOT_SET,
+ action: Opt[sql.ApprovalAction] = NOT_SET,
+ cap_question_id: Opt[int] = NOT_SET,
+ status: Opt[sql.ApprovalStatus] = NOT_SET,
+ status_in: Opt[Sequence[sql.ApprovalStatus]] = NOT_SET,
+ project_key_in: Opt[Sequence[str]] = NOT_SET,
+ ) -> Query[sql.ApprovalRequest]:
+ query = sqlmodel.select(sql.ApprovalRequest)
+
+ via = sql.validate_instrumented_attribute
+
+ if is_defined(id):
+ query = query.where(sql.ApprovalRequest.id == id)
+ if is_defined(project_key):
+ query = query.where(sql.ApprovalRequest.project_key == project_key)
+ if is_defined(action):
+ query = query.where(sql.ApprovalRequest.action == action)
+ if is_defined(cap_question_id):
+ query = query.where(sql.ApprovalRequest.cap_question_id == cap_question_id)
+ if is_defined(status):
+ query = query.where(sql.ApprovalRequest.status == status)
+ if is_defined(status_in):
+ query = query.where(via(sql.ApprovalRequest.status).in_(status_in))
+ if is_defined(project_key_in):
+ query = query.where(via(sql.ApprovalRequest.project_key).in_(project_key_in))
+
+ return Query(self, query)
+
def artifact(
self,
project_key: Opt[str] = NOT_SET,
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 1281daa3..4ef6ed54 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -161,6 +161,11 @@ async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
"""
async with db.session() as data:
projects = await data.project(_committee=True, _releases=True).order_by(sql.Project.name).all()
+ approvals = await data.approval_request(
+ status_in=[sql.ApprovalStatus.PENDING, sql.ApprovalStatus.APPROVED]
+ ).all()
+
+ approvals_by_project = {a.project_key: a for a in approvals}
committee_project_counts: Counter[str] = Counter(
str(p.committee.key) for p in projects if p.committee and p.is_active
@@ -173,6 +178,10 @@ async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
continue
if not (user.is_committee_member(project.committee, session.uid) or session.is_admin):
continue
+ approval = approvals_by_project.get(str(project.key))
+ if approval is not None:
+ action_forms[str(project.key)] = await _approval_request_element(approval)
+ continue
if project.committee and committee_project_counts[str(project.committee.key)] <= 1:
continue
project_releases = project.releases_including_embargoed
@@ -182,10 +191,13 @@ async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
action=util.as_url(post.projects.delete),
form_classes=".d-inline-block.m-0",
submit_classes="btn-sm btn-outline-danger",
- submit_label="Delete project",
+ submit_label="Request deletion",
empty=True,
defaults={"project_key": str(project.key)},
- confirm="Are you sure you want to delete this project? This cannot be undone.",
+ confirm=(
+ "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
+ " ready to delete only if the vote passes, and you must then return to complete it."
+ ),
)
elif all(r.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT for r in project_releases):
action_forms[str(project.key)] = await form.render(
@@ -193,13 +205,13 @@ async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
action=util.as_url(post.projects.archive),
form_classes=".d-inline-block.m-0",
submit_classes="btn-sm btn-outline-secondary",
- submit_label="Archive project",
+ submit_label="Request archival",
empty=True,
defaults={"project_key": str(project.key)},
confirm=(
- f"This project has {util.plural(len(project_releases), 'draft release')}."
- " Archiving will delete those drafts and mark the project as retired."
- " Continue?"
+ "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
+ " ready to archive only if the vote passes, and you must then return to complete it, which"
+ " deletes the draft releases and retires the project."
),
)
@@ -344,6 +356,28 @@ async def view(
)
+async def _approval_request_element(approval: sql.ApprovalRequest) -> htm.Element:
+ verb = approval.action.value
+ if approval.status == sql.ApprovalStatus.APPROVED:
+ return await form.render(
+ shared.projects.CompleteApprovalRequest,
+ action=util.as_url(post.projects.complete_approval),
+ form_classes=".d-inline-block.m-0",
+ submit_classes="btn-sm btn-outline-danger",
+ submit_label=f"Complete {verb}",
+ empty=True,
+ defaults={"approval_request_id": approval.id},
+ confirm=f"The CAP approval vote passed. This will {verb} the project now. Continue?",
+ )
+ closes = approval.closes_at.strftime("%Y-%m-%d %H:%M UTC")
+ question_url = f"{config.get().CAP_API_BASE_URL.rstrip('/')}/#/question/{approval.cap_question_id}"
+ return htm.p(".text-muted.small.mb-0")[
+ "CAP approval vote ",
+ htm.a(href=question_url)[f"#{approval.cap_question_id}"],
+ f" to {verb} in progress (ends {closes}).",
+ ]
+
+
def _asf_yaml_export(project: sql.Project) -> str:
block: dict[str, object] = {"metadata": _asf_yaml_metadata(project)}
policy = _asf_yaml_policy(project)
@@ -477,17 +511,27 @@ def _cycle_has_dates(cycle: sql.ProjectCycle) -> bool:
async def _delete_form(project: sql.Project) -> htm.Element | None:
+ async with db.session() as data:
+ approval = await data.approval_request(
+ project_key=str(project.key),
+ status_in=[sql.ApprovalStatus.PENDING, sql.ApprovalStatus.APPROVED],
+ ).get()
+ if approval is not None:
+ return await _approval_request_element(approval)
delete_form = None
releases = project.releases_including_embargoed
if not releases:
delete_form = await form.render(
shared.projects.DeleteSelectedProject,
action=util.as_url(post.projects.delete),
- form_classes="",
+ form_classes=".d-inline-block.m-0",
submit_classes="btn-sm btn-outline-danger",
- submit_label="Delete project",
+ submit_label="Request deletion",
defaults={"project_key": str(project.key)},
- confirm="Are you sure you want to delete this project? This cannot be undone.",
+ confirm=(
+ "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
+ " ready to delete only if the vote passes, and you must then return to complete it."
+ ),
empty=True,
)
elif all(r.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT for r in releases):
@@ -496,13 +540,13 @@ async def _delete_form(project: sql.Project) -> htm.Element | None:
action=util.as_url(post.projects.archive),
form_classes=".d-inline-block.m-0",
submit_classes="btn-sm btn-outline-secondary",
- submit_label="Archive project",
+ submit_label="Request archival",
empty=True,
defaults={"project_key": str(project.key)},
confirm=(
- f"This project has {util.plural(len(releases), 'draft release')}."
- " Archiving will delete those drafts and mark the project as retired."
- " Continue?"
+ "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
+ " ready to archive only if the vote passes, and you must then return to complete it, which"
+ " deletes the draft releases and retires the project."
),
)
return delete_form
@@ -695,7 +739,7 @@ async def _render_actions_card(
) -> htm.Element:
card = htm.Block(htm.div, classes=".card.mb-4")
card.div(".card-header.bg-light")[htm.h3(".mb-2")["Actions"]]
- with card.block(htm.div, classes=".card-body") as body:
+ with card.block(htm.div, classes=".card-body.d-flex.flex-column.align-items-start.gap-2") as body:
if project.is_active and not is_sole_active_project:
action_form = await _delete_form(project)
if action_form:
@@ -704,20 +748,16 @@ async def _render_actions_card(
if project.committee:
if (project.committee.key in session.member_committees) or is_privileged:
body.append(
- htm.p[
- htm.a(
- ".btn.btn-sm.btn-outline-primary",
- href=util.as_url(add_project, committee_key=project.committee.key),
- )["Create a sibling project"]
- ]
+ htm.a(
+ ".btn.btn-sm.btn-outline-primary",
+ href=util.as_url(add_project, committee_key=project.committee.key),
+ )["Create a sibling project"]
)
body.append(
- htm.p[
- htm.a(
- ".btn.btn-sm.btn-outline-secondary",
- href=util.as_url(project_yaml, project_key=project.key),
- )["Export .asf.yaml"]
- ]
+ htm.a(
+ ".btn.btn-sm.btn-outline-secondary",
+ href=util.as_url(project_yaml, project_key=project.key),
+ )["Export .asf.yaml"]
)
return card.collect()
diff --git a/atr/models/__init__.py b/atr/models/__init__.py
index 7a3d2470..0fbc8f0d 100644
--- a/atr/models/__init__.py
+++ b/atr/models/__init__.py
@@ -19,6 +19,7 @@ from . import (
api,
args,
basic,
+ cap,
distribution,
github,
helpers,
@@ -36,6 +37,7 @@ __all__ = [
"api",
"args",
"basic",
+ "cap",
"distribution",
"github",
"helpers",
diff --git a/atr/models/args.py b/atr/models/args.py
index 8865c219..c46de6fd 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -22,6 +22,11 @@ import pydantic
from . import mail, safe, schema
+class CapApprovalResolveArgs(schema.Strict):
+ approval_request_id: int = schema.description("The ApprovalRequest row to resolve")
+ attempt: int = pydantic.Field(default=0, description="The current resolve attempt count")
+
+
class ConvertCycloneDX(schema.Strict):
"""Arguments for the task to convert an artifact to a CycloneDX SBOM."""
diff --git a/atr/models/__init__.py b/atr/models/cap.py
similarity index 65%
copy from atr/models/__init__.py
copy to atr/models/cap.py
index 7a3d2470..69c0007b 100644
--- a/atr/models/__init__.py
+++ b/atr/models/cap.py
@@ -15,35 +15,23 @@
# specific language governing permissions and limitations
# under the License.
-from . import (
- api,
- args,
- basic,
- distribution,
- github,
- helpers,
- mail,
- results,
- safe,
- schema,
- sql,
- tabulate,
- validation,
-)
-
-# If we use .__name__, pyright gives a warning
-__all__ = [
- "api",
- "args",
- "basic",
- "distribution",
- "github",
- "helpers",
- "mail",
- "results",
- "safe",
- "schema",
- "sql",
- "tabulate",
- "validation",
-]
+from . import schema
+
+
+class ErrorMessage(schema.Subset):
+ error: str
+
+
+class Question(schema.Subset):
+ question_id: int
+ outcome: str | None = None
+ permalink: str | None = None
+
+
+class Resolution(schema.Subset):
+ outcome: str | None = None
+ permalink: str | None = None
+
+
+class TokenIssued(schema.Subset):
+ token: str
diff --git a/atr/models/results.py b/atr/models/results.py
index b3d23e7c..831ee178 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -22,6 +22,10 @@ import pydantic
from . import safe, schema
+class CapApprovalResolve(schema.Strict):
+ kind: Literal["cap_approval_resolve"] = schema.Field(alias="kind")
+
+
class DistributionStatusCheck(schema.Strict):
"""Result of the task to check pending distribution statuses."""
@@ -279,7 +283,8 @@ class MetadataUpdate(schema.Strict):
Results = Annotated[
- DistributionStatusCheck
+ CapApprovalResolve
+ | DistributionStatusCheck
| DistributionWorkflow
| DistributionWorkflowStatus
| HashingCheck
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 5976a9e6..6869c4ce 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -69,6 +69,19 @@ class DistributionPlatformValue:
# Enumerations
+class ApprovalAction(enum.StrEnum):
+ ARCHIVE = "archive"
+ DELETE = "delete"
+
+
+class ApprovalStatus(enum.StrEnum):
+ PENDING = "pending"
+ APPROVED = "approved"
+ REJECTED = "rejected"
+ COMPLETED = "completed"
+ FAILED = "failed"
+
+
class CheckResultStatus(enum.StrEnum):
BLOCKER = "blocker"
CONCERN = "concern"
@@ -217,6 +230,7 @@ class TaskStatus(enum.StrEnum):
class TaskType(enum.StrEnum):
ARCHIVE_COMPARISON = "archive_comparison"
+ CAP_APPROVAL_RESOLVE = "cap_approval_resolve"
COMPARE_SOURCE_TREES = "compare_source_trees"
DISTRIBUTION_STATUS = "distribution_status"
DISTRIBUTION_WORKFLOW = "distribution_workflow"
@@ -253,6 +267,8 @@ class TaskType(enum.StrEnum):
match self:
case TaskType.ARCHIVE_COMPARISON:
return "Archive comparison"
+ case TaskType.CAP_APPROVAL_RESOLVE:
+ return "CAP approval resolution"
case TaskType.COMPARE_SOURCE_TREES:
return "Compare source trees"
case TaskType.DISTRIBUTION_STATUS:
@@ -552,6 +568,35 @@ def example(value: Any) -> dict[Literal["schema_extra"], dict[str, Any]]:
# SQL models with no dependencies
+# ApprovalRequest:
+class ApprovalRequest(sqlmodel.SQLModel, table=True):
+ id: int | None = sqlmodel.Field(default=None, primary_key=True)
+ project_key: str = sqlmodel.Field()
+ committee_key: str = sqlmodel.Field()
+ action: ApprovalAction = sqlmodel.Field()
+ cap_question_id: int = sqlmodel.Field(unique=True)
+ status: ApprovalStatus = sqlmodel.Field(default=ApprovalStatus.PENDING, index=True)
+ outcome: str | None = None
+ requested_by: str = sqlmodel.Field()
+ requested_at: datetime.datetime = sqlmodel.Field(
+ default_factory=lambda: datetime.datetime.now(datetime.UTC),
+ sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
+ )
+ closes_at: datetime.datetime = sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime, nullable=False))
+ resolved_at: datetime.datetime | None = sqlmodel.Field(default=None, sa_column=sqlalchemy.Column(UTCDateTime))
+ permalink: str | None = None
+ error: str | None = None
+
+ __table_args__ = (
+ sqlalchemy.Index(
+ "ix_approvalrequest_active_project",
+ "project_key",
+ unique=True,
+ sqlite_where=sqlalchemy.text("status IN ('PENDING', 'APPROVED')"),
+ ),
+ )
+
+
# KeyLink:
class KeyLink(sqlmodel.SQLModel, table=True):
committee_key: str = sqlmodel.Field(foreign_key="committee.key", primary_key=True)
diff --git a/atr/post/projects.py b/atr/post/projects.py
index 6018ee3f..4917e005 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -17,18 +17,25 @@
from __future__ import annotations
+import datetime
from typing import TYPE_CHECKING, Any, Final, Literal
import quart
+import sqlalchemy.exc
import atr.blueprints.post as post
+import atr.config as config
+import atr.constants as constants
import atr.db as db
import atr.get as get
+import atr.log as log
+import atr.models.cap
import atr.models.safe as safe
import atr.models.sql as sql
import atr.models.unsafe as unsafe
import atr.shared as shared
import atr.storage as storage
+import atr.tasks.cap as cap
import atr.util as util
import atr.web as web
@@ -74,37 +81,61 @@ async def archive(
) -> web.WerkzeugResponse:
"""
URL: /project/archive
- Archive a project as a PMC member. Any draft releases are deleted first - they
- would otherwise be stranded, because release.delete rejects once a project is retired.
"""
- project_key = archive_selected_project_form.project_key
+ return await _request_approval(session, archive_selected_project_form.project_key, sql.ApprovalAction.ARCHIVE)
- # Pull the draft list up front so we can both delete them and report a count
+
+@post.typed
+async def complete_approval(
+ session: web.Committer,
+ _project_complete_approval: Literal["project/complete-approval"],
+ complete_approval_request_form: shared.projects.CompleteApprovalRequest,
+) -> web.WerkzeugResponse:
+ """
+ URL: /project/complete-approval
+ """
+ approval_request_id = complete_approval_request_form.approval_request_id
+ async with db.session() as data:
+ approval = await data.approval_request(id=approval_request_id).get()
+ if approval is None:
+ return await session.redirect(get.projects.projects, error="Approval request not found.")
+ if approval.status != sql.ApprovalStatus.APPROVED:
+ return await session.redirect(get.projects.projects, error="This approval request is not ready to complete.")
+
+ project_key = safe.ProjectKey(approval.project_key)
async with db.session() as data:
project = await data.project(key=str(project_key), _releases=True).get()
- draft_versions: list[safe.VersionKey] = (
- [
- safe.VersionKey(r.version)
- for r in project.releases_including_embargoed
- if r.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
- ]
- if project is not None
- else []
+ if (project is None) or (project.committee is None):
+ return await session.redirect(get.projects.projects, error=f"Project '{project_key}' not found.")
+ active_siblings = len(
+ await data.project(
+ committee_key=project.committee.key, status=sql.ProjectStatus.ACTIVE, _committee=False
+ ).all()
)
+ eligibility_error = _action_eligibility_error(
+ project, approval.action, active_siblings, completing=True, requested_at=approval.requested_at
+ )
+ if eligibility_error is not None:
+ return await session.redirect(get.projects.projects, error=eligibility_error)
+ draft_versions = [
+ safe.VersionKey(r.version)
+ for r in project.releases_including_embargoed
+ if r.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+ ]
- async with storage.write(session) as write:
- wacm = await write.as_project_committee_member(project_key)
- try:
- for version in draft_versions:
- await wacm.release.delete(project_key, version)
- await wacm.project.archive(project_key)
- except storage.AccessError as e:
- return await session.redirect(get.projects.projects, error=f"Error archiving project: {e}")
+ try:
+ await _complete_action(session, approval.action, project_key, draft_versions, approval_request_id)
+ except storage.AccessError as e:
+ return await session.redirect(get.projects.projects, error=f"Error completing {approval.action.value}: {e}")
- success = f"Project '{project_key}' archived successfully."
- if draft_versions:
- success += f" Deleted {util.plural(len(draft_versions), 'draft release')}."
- return await session.redirect(get.projects.projects, success=success)
+ await cap.notify(
+ approval.requested_by,
+ f"Project {project_key} was {approval.action.value}d after CAP approval.",
+ sql.NotificationLevel.INFO,
+ )
+ return await session.redirect(
+ get.projects.projects, success=f"Project '{project_key}' {approval.action.value}d successfully."
+ )
@post.typed
@@ -115,20 +146,8 @@ async def delete(
) -> web.WerkzeugResponse:
"""
URL: /project/delete
- Delete a project created by the user.
"""
- project_key = delete_selected_project_form.project_key
-
- async with storage.write(session) as write:
- wacm = await write.as_project_committee_member(project_key)
- try:
- await wacm.project.delete(project_key)
- except storage.AccessError as e:
- # TODO: Redirect to committees
- return await session.redirect(get.projects.projects, error=f"Error deleting project: {e}")
-
- # TODO: Redirect to committees
- return await session.redirect(get.projects.projects, success=f"Project '{project_key}' deleted successfully.")
+ return await _request_approval(session, delete_selected_project_form.project_key, sql.ApprovalAction.DELETE)
@post.typed
@@ -144,6 +163,91 @@ async def view(
return await _VIEW_HANDLERS[type(project_form)](session, project_form)
+def _action_eligibility_error(
+ project: sql.Project,
+ action: sql.ApprovalAction,
+ active_sibling_count: int,
+ *,
+ completing: bool = False,
+ requested_at: datetime.datetime | None = None,
+) -> str | None:
+ if not project.is_active:
+ return f"Project '{project.key}' is not active."
+ if active_sibling_count <= 1:
+ return f"Project '{project.key}' is the only active project in its committee."
+ releases = project.releases_including_embargoed
+ if action == sql.ApprovalAction.DELETE:
+ if releases:
+ return f"Project '{project.key}' has releases and cannot be deleted."
+ return None
+ if (not completing) and (not releases):
+ return f"Project '{project.key}' has no releases to archive."
+ if not all(r.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT for r in releases):
+ return f"Project '{project.key}' has non-draft releases and cannot be archived."
+ if not completing:
+ return None
+ new_drafts = [r for r in releases if (requested_at is None) or (r.created > requested_at)]
+ if new_drafts:
+ return (
+ f"Project '{project.key}' has {util.plural(len(new_drafts), 'draft release')} created after the"
+ " approval vote was requested. Delete them before completing the archival."
+ )
+ return None
+
+
+async def _complete_action(
+ session: web.Committer,
+ action: sql.ApprovalAction,
+ project_key: safe.ProjectKey,
+ draft_versions: list[safe.VersionKey],
+ approval_request_id: int,
+) -> None:
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_key)
+ if action == sql.ApprovalAction.DELETE:
+ await wacm.project.delete(project_key, approval_request_id)
+ return
+ for version in draft_versions:
+ try:
+ await wacm.release.delete(project_key, version)
+ except storage.AccessError as e:
+ if e.status != 404:
+ raise
+ await wacm.project.archive(project_key, approval_request_id)
+
+
+async def _create_cap_question(
+ action: sql.ApprovalAction,
+ project_key: safe.ProjectKey,
+ display_name: str,
+ committee_key: str,
+ requested_by: str,
+ closes_at: datetime.datetime,
+) -> atr.models.cap.Question:
+ token = await util.cap_mint_token()
+ verb = action.value
+ if action == sql.ApprovalAction.ARCHIVE:
+ approval_type = constants.CAP_ARCHIVE_APPROVAL_TYPE
+ consequence = "ATR will mark the project RETIRED"
+ else:
+ approval_type = constants.CAP_DELETE_APPROVAL_TYPE
+ consequence = "ATR will permanently delete the project and its metadata"
+ description = (
+ f"{requested_by} has requested, through ATR, to {verb} the project {project_key} ({display_name}). "
+ f"If this vote passes, {consequence}, and an authorised {committee_key} PMC member may complete the {verb} "
+ f"in ATR. This request was filed by Apache Trusted Releases on behalf of {requested_by}."
+ )
+ return await util.cap_create_question(
+ token,
+ project_id=committee_key,
+ title=f"[ATR] {verb.capitalize()} project {project_key}",
+ description=description,
+ target_audience=f"Binding voters: {committee_key} PMC members",
+ approval_type=approval_type,
+ closes_at=closes_at,
+ )
+
+
async def _metadata_category_add(
wacm: storage.WriteAsCommitteeMember, project_key: safe.ProjectKey, category_to_add: str
) -> bool:
@@ -184,6 +288,35 @@ async def _metadata_language_remove(
return False
+async def _persist_approval(
+ session: web.Committer,
+ wacm: storage.WriteAsCommitteeMember,
+ project_key: safe.ProjectKey,
+ committee_key: str,
+ action: sql.ApprovalAction,
+ cap_question_id: int,
+ closes_at: datetime.datetime,
+) -> web.WerkzeugResponse:
+ try:
+ await wacm.project.request_approval(project_key, action, cap_question_id, closes_at)
+ except storage.AccessError as e:
+ log.warning(f"Concurrent CAP request for {project_key} lost. CAP question {cap_question_id} abandoned")
+ return await session.redirect(get.projects.projects, error=str(e))
+ except sqlalchemy.exc.SQLAlchemyError:
+ log.exception(f"Failed to record CAP approval for {project_key}. CAP question {cap_question_id} abandoned")
+ return await session.redirect(
+ get.projects.projects, error="Could not record the CAP approval vote. Please contact an administrator."
+ )
+
+ return await session.redirect(
+ get.projects.projects,
+ success=(
+ f"Approval vote created for the {committee_key} PMC (CAP #{cap_question_id}, closes "
+ f"{closes_at.strftime('%Y-%m-%d %H:%M UTC')}). ATR will mark it ready to complete once it passes."
+ ),
+ )
+
+
async def _process_add_category(
session: web.Committer, add_category_form: shared.projects.AddCategoryForm
) -> web.WerkzeugResponse:
@@ -480,6 +613,65 @@ async def _process_vote_form(session: web.Committer, vote_form: shared.projects.
)
+async def _request_approval(
+ session: web.Committer, project_key: safe.ProjectKey, action: sql.ApprovalAction
+) -> web.WerkzeugResponse:
+ if not config.get().CAP_ROLE_ACCOUNT_TOKEN:
+ return await session.redirect(
+ get.projects.projects, error="CAP approval is not configured. Please contact an administrator."
+ )
+
+ async with storage.write(session) as write:
+ try:
+ wacm = await write.as_project_committee_member(project_key)
+ except storage.AccessError as e:
+ return await session.redirect(get.projects.projects, error=f"Error requesting {action.value} approval: {e}")
+
+ async with db.session() as data:
+ project = await data.project(key=str(project_key), _committee=True, _releases=True).get()
+ if (project is None) or (project.committee is None):
+ return await session.redirect(get.projects.projects, error=f"Project '{project_key}' not found.")
+ if project.committee.is_podling:
+ return await session.redirect(
+ get.projects.projects,
+ error=(
+ "CAP approval voting is not available for podlings. Archival or deletion of a podling is an"
+ " Incubator PMC decision."
+ ),
+ )
+ committee_key = project.committee.key
+ display_name = project.display_name
+ active_siblings = len(
+ await data.project(committee_key=committee_key, status=sql.ProjectStatus.ACTIVE, _committee=False).all()
+ )
+ eligibility_error = _action_eligibility_error(project, action, active_siblings)
+ if eligibility_error is not None:
+ return await session.redirect(get.projects.projects, error=eligibility_error)
+ existing = await data.approval_request(
+ project_key=str(project_key),
+ status_in=[sql.ApprovalStatus.PENDING, sql.ApprovalStatus.APPROVED],
+ ).get()
+ if existing is not None:
+ return await session.redirect(
+ get.projects.projects,
+ error=f"A CAP approval request to {existing.action.value} this project is already in progress.",
+ )
+
+ closes_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
+ minutes=constants.CAP_VOTE_DURATION_MINUTES
+ )
+ try:
+ question = await _create_cap_question(
+ action, project_key, display_name, committee_key, session.uid, closes_at
+ )
+ except util.FetchError as e:
+ return await session.redirect(get.projects.projects, error=f"Could not create CAP approval vote: {e}")
+
+ return await _persist_approval(
+ session, wacm, project_key, committee_key, action, question.question_id, closes_at
+ )
+
+
_VIEW_HANDLERS: Final[dict[type, Callable[[web.Committer, Any], Awaitable[web.WerkzeugResponse]]]] = {
shared.projects.AddCategoryForm: _process_add_category,
shared.projects.AddLanguageForm: _process_add_language,
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 8991074c..d2306d4a 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -498,6 +498,10 @@ class ArchiveSelectedProject(form.Form):
project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
+class CompleteApprovalRequest(form.Form):
+ approval_request_id: form.Int = form.label("Approval request ID", widget=form.Widget.HIDDEN)
+
+
class DeleteSelectedProject(form.Form):
project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
diff --git a/atr/ssh.py b/atr/ssh.py
index bc378d8e..cda3c34b 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -710,6 +710,7 @@ async def _step_07c_ensure_release_object_for_write(project_key: safe.ProjectKey
"""Ensure the release object exists or create it for a write operation."""
release_key = sql.release_key(str(project_key), str(version_key))
async with db.session() as data:
+ await data.begin_immediate()
release = await data.release(key=release_key, _committee=True).get()
if release is None:
project = await data.project(key=str(project_key), status=sql.ProjectStatus.ACTIVE, _committee=True).demand(
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index a9f2deb6..08bcdf42 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -305,6 +305,13 @@ class WriteAsAsfYamlService(WriteAsSystemService):
self.project_upsert_config = admin.project.upsert_config
+class WriteAsCapResolveService(WriteAsSystemService):
+ def __init__(self, write: Write, data: db.Session):
+ super().__init__(write, data)
+ admin = WriteAsFoundationAdmin(write, data)
+ self.project_record_approval_outcome = admin.project.record_approval_outcome
+
+
class WriteAsDistCatalogService(WriteAsSystemService):
def __init__(self, write: Write, data: db.Session):
super().__init__(write, data)
diff --git a/atr/storage/writers/project.py b/atr/storage/writers/project.py
index 36202007..00a987d8 100644
--- a/atr/storage/writers/project.py
+++ b/atr/storage/writers/project.py
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING
import sqlalchemy.exc
+import atr.constants as constants
import atr.cycles as cycles
import atr.db as db
import atr.models.api as api
@@ -32,6 +33,8 @@ import atr.models.sql as sql
import atr.models.validation as validation
import atr.registry as registry
import atr.storage as storage
+import atr.tasks as tasks
+import atr.util as util
if TYPE_CHECKING:
import atr.shared as shared
@@ -232,10 +235,11 @@ class CommitteeMember(ReleaseManager):
project_key=str(project_key),
)
- async def archive(self, project_key: safe.ProjectKey) -> None:
+ async def archive(self, project_key: safe.ProjectKey, approval_request_id: int) -> None:
await self.__data.begin_immediate()
self.__data.expire_all()
try:
+ await self.__claim_approval(approval_request_id, project_key, sql.ApprovalAction.ARCHIVE)
project = await self.__data.project(
key=str(project_key), status=sql.ProjectStatus.ACTIVE, _releases=True
).get()
@@ -256,6 +260,12 @@ class CommitteeMember(ReleaseManager):
" complete or remove them first."
)
+ if project.releases_including_embargoed:
+ raise storage.AccessError(
+ f"Cannot archive project '{project_key}' because it still has draft releases;"
+ " delete them and try again."
+ )
+
if project.committee_key:
committee_projects = await self.__data.project(
committee_key=project.committee_key, status=sql.ProjectStatus.ACTIVE
@@ -273,12 +283,14 @@ class CommitteeMember(ReleaseManager):
self.__write_as.append_to_audit_log(
asf_uid=self.__asf_uid,
project_key=str(project_key),
+ approval_request_id=approval_request_id,
)
- async def delete(self, project_key: safe.ProjectKey) -> None:
+ async def delete(self, project_key: safe.ProjectKey, approval_request_id: int) -> None:
await self.__data.begin_immediate()
self.__data.expire_all()
try:
+ await self.__claim_approval(approval_request_id, project_key, sql.ApprovalAction.DELETE)
project = await self.__data.project(
key=str(project_key), status=sql.ProjectStatus.ACTIVE, _releases=True
).get()
@@ -308,6 +320,7 @@ class CommitteeMember(ReleaseManager):
self.__write_as.append_to_audit_log(
asf_uid=self.__asf_uid,
project_key=str(project_key),
+ approval_request_id=approval_request_id,
)
return None
@@ -355,6 +368,62 @@ class CommitteeMember(ReleaseManager):
return True
return False
+ async def request_approval(
+ self,
+ project_key: safe.ProjectKey,
+ action: sql.ApprovalAction,
+ cap_question_id: int,
+ closes_at: datetime.datetime,
+ ) -> sql.ApprovalRequest:
+ approval = sql.ApprovalRequest(
+ project_key=str(project_key),
+ committee_key=self.__committee_key,
+ action=action,
+ cap_question_id=cap_question_id,
+ requested_by=self.__asf_uid,
+ closes_at=closes_at,
+ )
+ try:
+ self.__data.add(approval)
+ await self.__data.flush()
+ await tasks.cap_approval_resolve(
+ util.unwrap(approval.id),
+ schedule=closes_at + constants.CAP_RESOLVE_INITIAL_DELAY,
+ caller_data=self.__data,
+ )
+ await self.__data.commit()
+ except sqlalchemy.exc.IntegrityError as error:
+ await self.__data.rollback()
+ if "approvalrequest.project_key" in str(error):
+ raise storage.AccessError("A CAP approval request for this project is already in progress.", status=409)
+ raise storage.AccessError(
+ f"CAP issued question #{cap_question_id}, which ATR has already recorded for another request.",
+ status=409,
+ )
+ except Exception:
+ await self.__data.rollback()
+ raise
+ self.__write_as.append_to_audit_log(
+ asf_uid=self.__asf_uid,
+ project_key=str(project_key),
+ approval_action=action.value,
+ cap_question_id=cap_question_id,
+ closes_at=closes_at.isoformat(),
+ )
+ return approval
+
+ async def __claim_approval(
+ self, approval_request_id: int, project_key: safe.ProjectKey, action: sql.ApprovalAction
+ ) -> None:
+ approval = await self.__data.approval_request(id=approval_request_id).get()
+ if (approval is None) or (approval.status != sql.ApprovalStatus.APPROVED):
+ raise storage.AccessError("This approval request is not ready to complete.", status=409)
+ if (approval.project_key != str(project_key)) or (approval.action != action):
+ raise storage.AccessError("This approval request does not match the requested action.", status=409)
+ if approval.committee_key != self.__committee_key:
+ raise storage.AccessError("This approval request was filed for a different committee.", status=409)
+ approval.status = sql.ApprovalStatus.COMPLETED
+
def __current_categories(self, project: sql.Project) -> list[str]:
return (
[category.strip() for category in (project.categories or "").split(",") if category.strip()]
@@ -382,6 +451,40 @@ class FoundationAdmin(FoundationCommitter):
raise storage.AccessError("Not authorized", status=403)
self.__asf_uid = asf_uid
+ async def record_approval_outcome(
+ self,
+ approval_request_id: int,
+ status: sql.ApprovalStatus,
+ vote_outcome: str | None,
+ permalink: str | None,
+ error: str | None = None,
+ ) -> bool:
+ await self.__data.begin_immediate()
+ self.__data.expire_all()
+ try:
+ approval = await self.__data.approval_request(id=approval_request_id).get()
+ if (approval is None) or (approval.status != sql.ApprovalStatus.PENDING):
+ await self.__data.rollback()
+ return False
+ approval.status = status
+ approval.outcome = vote_outcome
+ approval.permalink = permalink
+ approval.error = error
+ if error is None:
+ approval.resolved_at = datetime.datetime.now(datetime.UTC)
+ await self.__data.commit()
+ except Exception:
+ await self.__data.rollback()
+ raise
+ self.__write_as.append_to_audit_log(
+ asf_uid=self.__asf_uid,
+ approval_request_id=approval_request_id,
+ approval_status=status.value,
+ outcome=vote_outcome,
+ error=error,
+ )
+ return True
+
async def upsert_config(
self,
args: api.ProjectConfigArgs,
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index e5399872..ed7e10e0 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -305,6 +305,7 @@ async def _start_release(
expedited: bool,
) -> tuple[sql.Release, sql.Project]:
"""Creates the initial release draft record and revision directory."""
+ await data.begin_immediate()
project = await data.project(key=str(project_key), status=sql.ProjectStatus.ACTIVE, _committee=True).get()
if not project:
raise storage.AccessError(f"Project {project_key} not found", status=404)
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index b805f6cb..f2e6ab95 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -26,6 +26,7 @@ import sqlmodel
import atr.analysis as analysis
import atr.attestable as attestable
+import atr.constants as constants
import atr.db as db
import atr.hashes as hashes
import atr.log as log
@@ -34,6 +35,7 @@ import atr.models.results as results
import atr.models.safe as safe
import atr.models.sql as sql
import atr.paths as file_paths
+import atr.tasks.cap as cap
import atr.tasks.checks as checks
import atr.tasks.checks.compare as compare
import atr.tasks.checks.hashing as hashing
@@ -91,6 +93,33 @@ async def asc_checks(
return tasks
+async def cap_approval_resolve(
+ approval_request_id: int,
+ *,
+ schedule: datetime.datetime,
+ attempt: int = 0,
+ caller_data: db.Session | None = None,
+) -> sql.Task:
+ task_args = args.CapApprovalResolveArgs(approval_request_id=approval_request_id, attempt=attempt)
+ async with db.ensure_session(caller_data) as data:
+ task = sql.Task(
+ status=sql.TaskStatus.QUEUED,
+ task_type=sql.TaskType.CAP_APPROVAL_RESOLVE,
+ task_args=task_args.model_dump(),
+ asf_uid=constants.SYSTEM_SERVICE_UID,
+ revision_number=None,
+ primary_rel_path=None,
+ project_key=None,
+ version_key=None,
+ )
+ task.scheduled = schedule
+ data.add(task)
+ await data.flush()
+ if caller_data is None:
+ await data.commit()
+ return task
+
+
async def clear_scheduled(caller_data: db.Session | None = None) -> None:
"""Clear all future scheduled tasks of the given types."""
async with db.ensure_session(caller_data) as data:
@@ -316,6 +345,8 @@ def resolve(task_type: sql.TaskType) -> Callable[..., Awaitable[results.Results
match task_type:
case sql.TaskType.ARCHIVE_COMPARISON:
return parity.across_formats
+ case sql.TaskType.CAP_APPROVAL_RESOLVE:
+ return cap.resolve
case sql.TaskType.COMPARE_SOURCE_TREES:
return compare.source_trees
case sql.TaskType.DISTRIBUTION_STATUS:
diff --git a/atr/tasks/cap.py b/atr/tasks/cap.py
new file mode 100644
index 00000000..1c1f7921
--- /dev/null
+++ b/atr/tasks/cap.py
@@ -0,0 +1,123 @@
+# 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.constants as constants
+import atr.db as db
+import atr.log as log
+import atr.models.args as args
+import atr.models.cap
+import atr.models.results as results
+import atr.models.sql as sql
+import atr.storage as storage
+import atr.tasks as tasks
+import atr.tasks.checks as checks
+import atr.util as util
+
+
+async def notify(asf_uid: str, message: str, level: sql.NotificationLevel) -> None:
+ if asf_uid == constants.SYSTEM_SERVICE_UID:
+ return
+ try:
+ async with storage.write_as_user_service(asf_uid) as waus:
+ await waus.notifications_create(message, level)
+ except Exception:
+ log.exception("Failed to record CAP notification")
+
+
+@checks.with_model(args.CapApprovalResolveArgs)
+async def resolve(task_args: args.CapApprovalResolveArgs, *, task_id: int | None = None) -> results.Results | None:
+ result = results.CapApprovalResolve(kind="cap_approval_resolve")
+ async with db.session() as data:
+ row = await data.approval_request(id=task_args.approval_request_id).get()
+ if row is None:
+ log.error(f"CAP approval request {task_args.approval_request_id} not found")
+ return result
+ if row.status != sql.ApprovalStatus.PENDING:
+ return result
+
+ try:
+ resolution = await _resolve_question(row.cap_question_id)
+ if (resolution is not None) and (resolution.outcome is not None):
+ await _apply_outcome(task_args.approval_request_id, row, resolution)
+ return result
+ if resolution is not None:
+ log.warning(f"CAP resolution for question {row.cap_question_id} returned no outcome")
+ except Exception:
+ log.exception(f"CAP approval resolve failed for request {task_args.approval_request_id}")
+
+ await _reschedule_or_fail(task_args, row)
+ return result
+
+
+async def _apply_outcome(request_id: int, row: sql.ApprovalRequest, resolution: atr.models.cap.Resolution) -> None:
+ vote_outcome = resolution.outcome
+ permalink = resolution.permalink
+ if vote_outcome == "approved":
+ if await _record_outcome(request_id, sql.ApprovalStatus.APPROVED, vote_outcome, permalink):
+ await notify(
+ row.requested_by,
+ f"The CAP approval vote to {row.action.value} project {row.project_key} passed. "
+ f"You can now complete the {row.action.value}.",
+ sql.NotificationLevel.INFO,
+ )
+ return
+ if await _record_outcome(request_id, sql.ApprovalStatus.REJECTED, vote_outcome, permalink):
+ await notify(
+ row.requested_by,
+ f"The CAP approval vote to {row.action.value} project {row.project_key} did not pass "
+ f"(outcome: {vote_outcome}).",
+ sql.NotificationLevel.WARNING,
+ )
+
+
+async def _record_outcome(
+ request_id: int,
+ status: sql.ApprovalStatus,
+ vote_outcome: str | None,
+ permalink: str | None,
+ *,
+ error: str | None = None,
+) -> bool:
+ async with storage.write_as_system(storage.WriteAsCapResolveService) as wacrs:
+ return await wacrs.project_record_approval_outcome(request_id, status, vote_outcome, permalink, error=error)
+
+
+async def _reschedule_or_fail(task_args: args.CapApprovalResolveArgs, row: sql.ApprovalRequest) -> None:
+ next_attempt = task_args.attempt + 1
+ if next_attempt <= len(constants.CAP_RESOLVE_RETRY_DELAYS):
+ schedule = datetime.datetime.now(datetime.UTC) + constants.CAP_RESOLVE_RETRY_DELAYS[next_attempt - 1]
+ await tasks.cap_approval_resolve(task_args.approval_request_id, attempt=next_attempt, schedule=schedule)
+ return
+ attempts = len(constants.CAP_RESOLVE_RETRY_DELAYS) + 1
+ error = f"CAP could not be resolved after {attempts} attempts"
+ if await _record_outcome(task_args.approval_request_id, sql.ApprovalStatus.FAILED, None, None, error=error):
+ await notify(
+ row.requested_by,
+ f"ATR could not resolve the CAP approval vote to {row.action.value} project {row.project_key}: {error}.",
+ sql.NotificationLevel.ERROR,
+ )
+
+
+async def _resolve_question(question_id: int) -> atr.models.cap.Resolution | None:
+ try:
+ token = await util.cap_mint_token()
+ return await util.cap_resolve_question(token, question_id)
+ except util.FetchError as error:
+ log.warning(f"CAP resolve unavailable for question {question_id}: {error}")
+ return None
diff --git a/atr/util.py b/atr/util.py
index 8e0bb266..2346062c 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -47,6 +47,7 @@ import asfquart.session as session
import gitignore_parser
import jinja2
import markupsafe
+import pydantic
import quart
# NOTE: The atr.db module imports this module
@@ -55,6 +56,7 @@ import atr.config as config
import atr.constants as constants
import atr.ldap as ldap
import atr.log as log
+import atr.models.cap as cap
import atr.models.safe as safe
import atr.models.sql as sql
import atr.models.validation as validation
@@ -105,6 +107,7 @@ PRIVATE_KEY_UPLOAD_WARNING: Final[str] = (
)
USER_TESTS_ADDRESS: Final[str] = "user-tests@tooling.apache.org"
LISTS_APACHE_TIMEOUT: Final[aiohttp.ClientTimeout] = aiohttp.ClientTimeout(total=30, connect=10)
+CAP_TIMEOUT: Final[aiohttp.ClientTimeout] = aiohttp.ClientTimeout(total=30, connect=10)
MAX_PROPAGATION_ARTIFACTS: Final[int] = 24
EXPECTED_DEFAULT_TLS_CHECK_HOSTNAME: Final[bool] = True
EXPECTED_DEFAULT_TLS_MINIMUM_VERSION: Final[ssl.TLSVersion] = ssl.TLSVersion.TLSv1_2
@@ -316,6 +319,90 @@ async def atomic_write_file(file_path: pathlib.Path, content: str, encoding: str
raise
+async def cap_create_question(
+ token: str,
+ *,
+ project_id: str,
+ title: str,
+ description: str,
+ target_audience: str,
+ approval_type: str,
+ closes_at: datetime.datetime,
+) -> cap.Question:
+ url = f"{config.get().CAP_API_BASE_URL.rstrip('/')}/api/question"
+ headers = {"Authorization": f"bearer {token}"}
+ payload = {
+ "project_id": project_id,
+ "title": title,
+ "description": description,
+ "target_audience": target_audience,
+ "approval_type": approval_type,
+ "is_binding": True,
+ "is_private": True,
+ "response_option": {"kind": "vote"},
+ "closes_at": closes_at.astimezone(datetime.UTC).isoformat(),
+ }
+ try:
+ async with create_secure_session(timeout=CAP_TIMEOUT) as http_session:
+ async with http_session.post(url, json=payload, headers=headers) as response:
+ status = response.status
+ body = await response.text()
+ except Exception as exc:
+ raise FetchError(f"CAP question creation failed: {exc}", url=url) from exc
+ if status != 201:
+ raise FetchError(f"CAP question creation failed ({status}): {body}", url=url)
+ try:
+ return cap.Question.model_validate_json(body)
+ except pydantic.ValidationError as exc:
+ raise FetchError(f"CAP question creation returned an invalid response: {exc}", url=url) from exc
+
+
+async def cap_mint_token() -> str:
+ url = f"{config.get().CAP_API_BASE_URL.rstrip('/')}/api/token"
+ permanent = config.get().CAP_ROLE_ACCOUNT_TOKEN
+ if not permanent:
+ raise FetchError("CAP role account token is not configured", url=url)
+ headers = {"Authorization": f"bearer {permanent}"}
+ try:
+ async with create_secure_session(timeout=CAP_TIMEOUT) as http_session:
+ async with http_session.get(url, headers=headers) as response:
+ status = response.status
+ body = await response.text()
+ except Exception as exc:
+ raise FetchError(f"CAP token minting failed: {exc}", url=url) from exc
+ if status != 201:
+ raise FetchError(f"CAP token minting failed ({status})", url=url)
+ try:
+ return cap.TokenIssued.model_validate_json(body).token
+ except pydantic.ValidationError as exc:
+ raise FetchError(f"CAP token minting returned an invalid response: {exc}", url=url) from exc
+
+
+async def cap_resolve_question(token: str, question_id: int) -> cap.Resolution | None:
+ url = f"{config.get().CAP_API_BASE_URL.rstrip('/')}/api/question/{question_id}/resolve"
+ headers = {"Authorization": f"bearer {token}"}
+ try:
+ async with create_secure_session(timeout=CAP_TIMEOUT) as http_session:
+ async with http_session.post(url, headers=headers) as response:
+ status = response.status
+ body = await response.text()
+ except Exception as exc:
+ raise FetchError(f"CAP question resolution failed: {exc}", url=url) from exc
+ if status == 403:
+ try:
+ error_kind = cap.ErrorMessage.model_validate_json(body).error
+ except pydantic.ValidationError:
+ error_kind = None
+ if error_kind == "deadline_in_future":
+ return None
+ if status != 200:
+ raise FetchError(f"CAP question resolution failed ({status}): {body}", url=url)
+ try:
+ return cap.Resolution.model_validate_json(body)
+ except pydantic.ValidationError as exc:
+ raise FetchError(f"CAP question resolution returned an invalid response: {exc}", url=url) from exc
+
+
async def check_propagation(
target: SvnPublishTarget,
public_base_url: str,
diff --git a/migrations/versions/0102_2026.06.30_258b186f.py b/migrations/versions/0102_2026.06.30_258b186f.py
new file mode 100644
index 00000000..b5930564
--- /dev/null
+++ b/migrations/versions/0102_2026.06.30_258b186f.py
@@ -0,0 +1,75 @@
+# 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.
+
+"""Add approvalrequest table for CAP gated archival and deletion
+
+Revision ID: 0102_2026.06.30_258b186f
+Revises: 0101_2026.06.30_ee33c8d8
+Create Date: 2026-06-30 17:59:31.689612+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+import atr.models.sql as sql
+
+revision: str = "0102_2026.06.30_258b186f"
+down_revision: str | None = "0101_2026.06.30_ee33c8d8"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "approvalrequest",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("project_key", sa.String(), nullable=False),
+ sa.Column("committee_key", sa.String(), nullable=False),
+ sa.Column("action", sa.Enum("ARCHIVE", "DELETE", name="approvalaction"), nullable=False),
+ sa.Column("cap_question_id", sa.Integer(), nullable=False),
+ sa.Column(
+ "status",
+ sa.Enum("PENDING", "APPROVED", "REJECTED", "COMPLETED", "FAILED", name="approvalstatus"),
+ nullable=False,
+ ),
+ sa.Column("outcome", sa.String(), nullable=True),
+ sa.Column("requested_by", sa.String(), nullable=False),
+ sa.Column("requested_at", sql.UTCDateTime(timezone=True), nullable=False),
+ sa.Column("closes_at", sql.UTCDateTime(timezone=True), nullable=False),
+ sa.Column("resolved_at", sql.UTCDateTime(timezone=True), nullable=True),
+ sa.Column("permalink", sa.String(), nullable=True),
+ sa.Column("error", sa.String(), nullable=True),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_approvalrequest")),
+ sa.UniqueConstraint("cap_question_id", name=op.f("uq_approvalrequest_cap_question_id")),
+ )
+ with op.batch_alter_table("approvalrequest", schema=None) as batch_op:
+ batch_op.create_index(batch_op.f("ix_approvalrequest_status"), ["status"], unique=False)
+ batch_op.create_index(
+ "ix_approvalrequest_active_project",
+ ["project_key"],
+ unique=True,
+ sqlite_where=sa.text("status IN ('PENDING', 'APPROVED')"),
+ )
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("approvalrequest", schema=None) as batch_op:
+ batch_op.drop_index("ix_approvalrequest_active_project")
+ batch_op.drop_index(batch_op.f("ix_approvalrequest_status"))
+ op.drop_table("approvalrequest")
diff --git a/tests/unit/test_cap.py b/tests/unit/test_cap.py
new file mode 100644
index 00000000..9fe83364
--- /dev/null
+++ b/tests/unit/test_cap.py
@@ -0,0 +1,458 @@
+# 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 json
+import unittest.mock as mock
+
+import pytest
+import sqlalchemy.exc
+
+import atr.models.cap
+import atr.models.results as results
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.post.projects as projects
+import atr.storage as storage
+import atr.storage.writers.project as project
+import atr.tasks.cap as cap
+import atr.util as util
+
+
+class Conf:
+ CAP_API_BASE_URL = "https://cap.example.test"
+ CAP_ROLE_ACCOUNT_TOKEN = "perm-token"
+
+
+class FakeResponse:
+ def __init__(self, status: int, body: str):
+ self.status = status
+ self._body = body
+
+ async def text(self) -> str:
+ return self._body
+
+ async def __aenter__(self) -> "FakeResponse":
+ return self
+
+ async def __aexit__(self, *args: object) -> bool:
+ return False
+
+
+class FakeSession:
+ def __init__(self, response: FakeResponse):
+ self._response = response
+
+ def get(self, url: str, **kwargs: object) -> FakeResponse:
+ return self._response
+
+ def post(self, url: str, **kwargs: object) -> FakeResponse:
+ return self._response
+
+ async def __aenter__(self) -> "FakeSession":
+ return self
+
+ async def __aexit__(self, *args: object) -> bool:
+ return False
+
+
+class FakeQuery:
+ def __init__(self, row: sql.ApprovalRequest | None):
+ self._row = row
+
+ async def get(self, log_query: bool = False) -> sql.ApprovalRequest | None:
+ return self._row
+
+
+class FakeData:
+ def __init__(self, row: sql.ApprovalRequest | None):
+ self.row = row
+
+ def approval_request(self, **kwargs: object) -> FakeQuery:
+ return FakeQuery(self.row)
+
+ async def begin_immediate(self) -> None:
+ return None
+
+ def add(self, obj: object) -> None:
+ if isinstance(obj, sql.ApprovalRequest) and obj.id is None:
+ obj.id = 1
+ return None
+
+ async def commit(self) -> None:
+ return None
+
+ def expire_all(self) -> None:
+ return None
+
+ async def flush(self) -> None:
+ return None
+
+ async def rollback(self) -> None:
+ return None
+
+
+class FakeSessionCM:
+ def __init__(self, data: FakeData):
+ self._data = data
+
+ async def __aenter__(self) -> FakeData:
+ return self._data
+
+ async def __aexit__(self, *args: object) -> bool:
+ return False
+
+
+class FakeSystemService:
+ def __init__(self, record: object):
+ self.project_record_approval_outcome = record
+
+ async def __aenter__(self) -> "FakeSystemService":
+ return self
+
+ async def __aexit__(self, *args: object) -> bool:
+ return False
+
+
+class FakeUserService:
+ def __init__(self) -> None:
+ self.notifications_create = mock.AsyncMock()
+
+ async def __aenter__(self) -> "FakeUserService":
+ return self
+
+ async def __aexit__(self, *args: object) -> bool:
+ return False
+
+
+class FakeAuthorisation:
+ asf_uid = "alice"
+
+
+class FakeWrite:
+ authorisation = FakeAuthorisation()
+
+
+class FakeWriteAs:
+ def append_to_audit_log(self, **kwargs: object) -> None:
+ return None
+
+
+@pytest.fixture
+def cap_config(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(util.config, "get", lambda: Conf)
+
+
+@pytest.mark.asyncio
+async def test_cap_create_question_error(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+ _patch_http(monkeypatch, 500, "boom")
+ with pytest.raises(util.FetchError):
+ await util.cap_create_question(
+ "tok",
+ project_id="commons",
+ title="t",
+ description="d",
+ target_audience="a",
+ approval_type="majority_approval",
+ closes_at=datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC),
+ )
+
+
+@pytest.mark.asyncio
+async def test_cap_create_question_invalid_json(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+ _patch_http(monkeypatch, 201, "<html>not json</html>")
+ with pytest.raises(util.FetchError):
+ await util.cap_create_question(
+ "tok",
+ project_id="commons",
+ title="t",
+ description="d",
+ target_audience="a",
+ approval_type="majority_approval",
+ closes_at=datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC),
+ )
+
+
+@pytest.mark.asyncio
+async def test_cap_create_question_success(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+ _patch_http(monkeypatch, 201, json.dumps({"question_id": 42, "permalink": None}))
+ closes_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+ result = await util.cap_create_question(
+ "tok",
+ project_id="commons",
+ title="t",
+ description="d",
+ target_audience="a",
+ approval_type="majority_approval",
+ closes_at=closes_at,
+ )
+ assert result.question_id == 42
+
+
+@pytest.mark.asyncio
+async def test_cap_mint_token_success(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+ _patch_http(monkeypatch, 201, json.dumps({"token": "temp-token", "scopes": ["ask"]}))
+ assert await util.cap_mint_token() == "temp-token"
+
+
+@pytest.mark.asyncio
+async def test_cap_mint_token_unconfigured(monkeypatch: pytest.MonkeyPatch) -> None:
+ class NoToken:
+ CAP_API_BASE_URL = "https://cap.example.test"
+ CAP_ROLE_ACCOUNT_TOKEN = None
+
+ monkeypatch.setattr(util.config, "get", lambda: NoToken)
+ with pytest.raises(util.FetchError):
+ await util.cap_mint_token()
+
+
+@pytest.mark.asyncio
+async def test_cap_resolve_question_approved(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+ _patch_http(monkeypatch, 200, json.dumps({"outcome": "approved", "permalink": "https://cap/x"}))
+ resolution = await util.cap_resolve_question("tok", 42)
+ assert resolution is not None
+ assert resolution.outcome == "approved"
+
+
+@pytest.mark.asyncio
+async def test_cap_resolve_question_deadline_in_future(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+ _patch_http(monkeypatch, 403, json.dumps({"error": "deadline_in_future"}))
+ assert await util.cap_resolve_question("tok", 42) is None
+
+
+@pytest.mark.asyncio
+async def test_cap_resolve_question_error(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+ _patch_http(monkeypatch, 500, "boom")
+ with pytest.raises(util.FetchError):
+ await util.cap_resolve_question("tok", 42)
+
+
+@pytest.mark.asyncio
+async def test_cap_resolve_question_invalid_json(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+ _patch_http(monkeypatch, 200, "<html>not json</html>")
+ with pytest.raises(util.FetchError):
+ await util.cap_resolve_question("tok", 42)
+
+
+@pytest.mark.asyncio
+async def test_cap_resolve_question_not_approved(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+ _patch_http(monkeypatch, 200, json.dumps({"outcome": "insufficient_votes"}))
+ resolution = await util.cap_resolve_question("tok", 42)
+ assert resolution is not None
+ assert resolution.outcome == "insufficient_votes"
+
+
+def test_completion_eligibility_allows_empty_project() -> None:
+ requested_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+ project = _project_with_drafts()
+ error = projects._action_eligibility_error(
+ project, sql.ApprovalAction.ARCHIVE, 2, completing=True, requested_at=requested_at
+ )
+ assert error is None
+
+
+def test_completion_eligibility_allows_prior_drafts() -> None:
+ requested_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+ project = _project_with_drafts(requested_at - datetime.timedelta(days=1))
+ error = projects._action_eligibility_error(
+ project, sql.ApprovalAction.ARCHIVE, 2, completing=True, requested_at=requested_at
+ )
+ assert error is None
+
+
+def test_completion_eligibility_blocks_new_drafts() -> None:
+ requested_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+ project = _project_with_drafts(requested_at + datetime.timedelta(hours=1))
+ error = projects._action_eligibility_error(
+ project, sql.ApprovalAction.ARCHIVE, 2, completing=True, requested_at=requested_at
+ )
+ assert error is not None
+ assert "created after" in error
+
+
+@pytest.mark.asyncio
+async def test_request_approval_question_id_collision(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = FakeData(None)
+ data.commit = mock.AsyncMock(
+ side_effect=sqlalchemy.exc.IntegrityError(
+ "INSERT", {}, Exception("UNIQUE constraint failed: approvalrequest.cap_question_id")
+ )
+ )
+ wacm = project.CommitteeMember(FakeWrite(), FakeWriteAs(), data, "commons")
+ monkeypatch.setattr(project.tasks, "cap_approval_resolve", mock.AsyncMock())
+ closes_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+ with pytest.raises(storage.AccessError, match="already recorded"):
+ await wacm.request_approval(safe.ProjectKey("commons-foo"), sql.ApprovalAction.ARCHIVE, 42, closes_at)
+
+
+@pytest.mark.asyncio
+async def test_request_approval_schedules_initial_resolution(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = FakeData(None)
+ wacm = project.CommitteeMember(FakeWrite(), FakeWriteAs(), data, "commons")
+ schedule = mock.AsyncMock()
+ monkeypatch.setattr(project.tasks, "cap_approval_resolve", schedule)
+ closes_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+ approval = await wacm.request_approval(safe.ProjectKey("commons-foo"), sql.ApprovalAction.ARCHIVE, 42, closes_at)
+ assert approval.status == sql.ApprovalStatus.PENDING
+ assert approval.requested_by == "alice"
+ schedule.assert_awaited_once()
+ assert schedule.call_args.kwargs["schedule"] == closes_at + projects.constants.CAP_RESOLVE_INITIAL_DELAY
+ assert schedule.call_args.kwargs["caller_data"] is data
+
+
+@pytest.mark.asyncio
+async def test_resolve_approved_marks_approved(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = FakeData(_approval_row())
+ reenqueue = _patch_resolve_env(monkeypatch, data, {"outcome": "approved", "permalink": "https://cap/x"})
+ result = await cap.resolve({"approval_request_id": 1, "attempt": 0})
+ assert isinstance(result, results.CapApprovalResolve)
+ assert data.row is not None
+ assert data.row.status == sql.ApprovalStatus.APPROVED
+ assert data.row.permalink == "https://cap/x"
+ reenqueue.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_resolve_db_error_reenqueues(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = FakeData(_approval_row())
+ reenqueue = _patch_resolve_env(monkeypatch, data, {"outcome": "approved", "permalink": "https://cap/x"})
+ data.begin_immediate = mock.AsyncMock(side_effect=sqlalchemy.exc.InvalidRequestError("database error"))
+ await cap.resolve({"approval_request_id": 1, "attempt": 0})
+ assert data.row is not None
+ assert data.row.status == sql.ApprovalStatus.PENDING
+ reenqueue.assert_awaited_once()
+ assert reenqueue.call_args.kwargs["attempt"] == 1
+
+
+@pytest.mark.asyncio
+async def test_resolve_exhausted_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = FakeData(_approval_row())
+ reenqueue = _patch_resolve_env(monkeypatch, data, None)
+ await cap.resolve({"approval_request_id": 1, "attempt": len(cap.constants.CAP_RESOLVE_RETRY_DELAYS)})
+ assert data.row is not None
+ assert data.row.status == sql.ApprovalStatus.FAILED
+ assert data.row.error is not None
+ reenqueue.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_resolve_last_attempt_reenqueues(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = FakeData(_approval_row())
+ reenqueue = _patch_resolve_env(monkeypatch, data, None)
+ await cap.resolve({"approval_request_id": 1, "attempt": len(cap.constants.CAP_RESOLVE_RETRY_DELAYS) - 1})
+ assert data.row is not None
+ assert data.row.status == sql.ApprovalStatus.PENDING
+ reenqueue.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_resolve_missing_outcome_reenqueues(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = FakeData(_approval_row())
+ reenqueue = _patch_resolve_env(monkeypatch, data, {"permalink": "https://cap/x"})
+ await cap.resolve({"approval_request_id": 1, "attempt": 0})
+ assert data.row is not None
+ assert data.row.status == sql.ApprovalStatus.PENDING
+ reenqueue.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_resolve_not_approved_marks_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = FakeData(_approval_row())
+ _patch_resolve_env(monkeypatch, data, {"outcome": "insufficient_votes", "permalink": "https://cap/x"})
+ await cap.resolve({"approval_request_id": 1, "attempt": 0})
+ assert data.row is not None
+ assert data.row.status == sql.ApprovalStatus.REJECTED
+ assert data.row.outcome == "insufficient_votes"
+
+
+@pytest.mark.asyncio
+async def test_resolve_unavailable_reenqueues(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = FakeData(_approval_row())
+ reenqueue = _patch_resolve_env(monkeypatch, data, None)
+ before = datetime.datetime.now(datetime.UTC)
+ await cap.resolve({"approval_request_id": 1, "attempt": 0})
+ after = datetime.datetime.now(datetime.UTC)
+ assert data.row is not None
+ assert data.row.status == sql.ApprovalStatus.PENDING
+ reenqueue.assert_awaited_once()
+ assert reenqueue.call_args.kwargs["attempt"] == 1
+ assert before + cap.constants.CAP_RESOLVE_RETRY_DELAYS[0] <= reenqueue.call_args.kwargs["schedule"]
+ assert reenqueue.call_args.kwargs["schedule"] <= after + cap.constants.CAP_RESOLVE_RETRY_DELAYS[0]
+
+
+@pytest.mark.asyncio
+async def test_resolve_unexpected_error_reenqueues(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = FakeData(_approval_row())
+ reenqueue = _patch_resolve_env(monkeypatch, data, {"outcome": "approved", "permalink": "https://cap/x"})
+ monkeypatch.setattr(cap.util, "cap_resolve_question", mock.AsyncMock(side_effect=RuntimeError("unexpected")))
+ await cap.resolve({"approval_request_id": 1, "attempt": 0})
+ assert data.row is not None
+ assert data.row.status == sql.ApprovalStatus.PENDING
+ reenqueue.assert_awaited_once()
+ assert reenqueue.call_args.kwargs["attempt"] == 1
+
+
+def _approval_row(
+ status: sql.ApprovalStatus = sql.ApprovalStatus.PENDING,
+ action: sql.ApprovalAction = sql.ApprovalAction.ARCHIVE,
+) -> sql.ApprovalRequest:
+ return sql.ApprovalRequest(
+ id=1,
+ project_key="commons-foo",
+ committee_key="commons",
+ action=action,
+ cap_question_id=10,
+ status=status,
+ requested_by="alice",
+ closes_at=datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC),
+ )
+
+
+def _patch_http(monkeypatch: pytest.MonkeyPatch, status: int, body: str) -> None:
+ session = FakeSession(FakeResponse(status, body))
+ monkeypatch.setattr(util, "create_secure_session", lambda **kwargs: session)
+
+
+def _patch_resolve_env(
+ monkeypatch: pytest.MonkeyPatch, data: FakeData, resolution: dict[str, object] | None
+) -> mock.AsyncMock:
+ resolution_model = None
+ if resolution is not None:
+ resolution_model = atr.models.cap.Resolution.model_validate(resolution)
+ monkeypatch.setattr(cap.db, "session", lambda: FakeSessionCM(data))
+ monkeypatch.setattr(cap.util, "cap_mint_token", mock.AsyncMock(return_value="tok"))
+ monkeypatch.setattr(cap.util, "cap_resolve_question", mock.AsyncMock(return_value=resolution_model))
+ monkeypatch.setattr(cap.storage, "write_as_user_service", lambda uid: FakeUserService())
+ admin = project.FoundationAdmin(FakeWrite(), FakeWriteAs(), data)
+ monkeypatch.setattr(cap.storage, "write_as_system", lambda cls: FakeSystemService(admin.record_approval_outcome))
+ reenqueue = mock.AsyncMock()
+ monkeypatch.setattr(cap.tasks, "cap_approval_resolve", reenqueue)
+ return reenqueue
+
+
+def _project_with_drafts(*created: datetime.datetime) -> sql.Project:
+ project = sql.Project(key="commons-foo", status=sql.ProjectStatus.ACTIVE)
+ project.releases_including_embargoed = [
+ sql.Release(
+ project_key="commons-foo",
+ version=f"0.{i}",
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ created=created_at,
+ )
+ for i, created_at in enumerate(created)
+ ]
+ return project
diff --git a/tests/unit/test_project_writer_delete.py b/tests/unit/test_project_writer_delete.py
index ae829840..9aa4a2d3 100644
--- a/tests/unit/test_project_writer_delete.py
+++ b/tests/unit/test_project_writer_delete.py
@@ -26,6 +26,25 @@ import atr.storage as storage
import atr.storage.writers.project as project
+@pytest.mark.asyncio
+async def test_archive_committee_mismatch_rolls_back() -> None:
+ data = mock.MagicMock()
+ data.begin_immediate = mock.AsyncMock()
+ data.rollback = mock.AsyncMock()
+ data.commit = mock.AsyncMock()
+ data.approval_request = mock.MagicMock(
+ return_value=_query(get=_approval(sql.ApprovalAction.ARCHIVE, committee_key="beta"))
+ )
+ writer = _writer(data)
+
+ with pytest.raises(storage.AccessError, match="different committee"):
+ await writer.archive(safe.ProjectKey("alpha-one"), 7)
+
+ data.begin_immediate.assert_awaited_once()
+ data.rollback.assert_awaited_once()
+ data.commit.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_archive_sole_active_project_locks_then_rolls_back() -> None:
target = SimpleNamespace(
@@ -35,11 +54,35 @@ async def test_archive_sole_active_project_locks_then_rolls_back() -> None:
data.begin_immediate = mock.AsyncMock()
data.rollback = mock.AsyncMock()
data.commit = mock.AsyncMock()
+ data.approval_request = mock.MagicMock(return_value=_query(get=_approval(sql.ApprovalAction.ARCHIVE)))
data.project = mock.MagicMock(side_effect=[_query(get=target), _query(all_=[target])])
writer = _writer(data)
with pytest.raises(storage.AccessError, match="only project"):
- await writer.archive(safe.ProjectKey("alpha-one"))
+ await writer.archive(safe.ProjectKey("alpha-one"), 7)
+
+ data.begin_immediate.assert_awaited_once()
+ data.rollback.assert_awaited_once()
+ data.commit.assert_not_awaited()
+ assert target.status == sql.ProjectStatus.ACTIVE
+
+
+@pytest.mark.asyncio
+async def test_archive_with_drafts_rolls_back() -> None:
+ draft = SimpleNamespace(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT)
+ target = SimpleNamespace(
+ key="alpha-one", committee_key="alpha", status=sql.ProjectStatus.ACTIVE, releases_including_embargoed=[draft]
+ )
+ data = mock.MagicMock()
+ data.begin_immediate = mock.AsyncMock()
+ data.rollback = mock.AsyncMock()
+ data.commit = mock.AsyncMock()
+ data.approval_request = mock.MagicMock(return_value=_query(get=_approval(sql.ApprovalAction.ARCHIVE)))
+ data.project = mock.MagicMock(side_effect=[_query(get=target)])
+ writer = _writer(data)
+
+ with pytest.raises(storage.AccessError, match="draft releases"):
+ await writer.archive(safe.ProjectKey("alpha-one"), 7)
data.begin_immediate.assert_awaited_once()
data.rollback.assert_awaited_once()
@@ -56,10 +99,11 @@ async def test_delete_non_sole_project_commits() -> None:
data.rollback = mock.AsyncMock()
data.commit = mock.AsyncMock()
data.delete = mock.AsyncMock()
+ data.approval_request = mock.MagicMock(return_value=_query(get=_approval(sql.ApprovalAction.DELETE)))
data.project = mock.MagicMock(side_effect=[_query(get=target), _query(all_=[target, other])])
writer = _writer(data)
- await writer.delete(safe.ProjectKey("alpha-one"))
+ await writer.delete(safe.ProjectKey("alpha-one"), 7)
data.begin_immediate.assert_awaited_once()
data.delete.assert_awaited_once_with(target)
@@ -75,11 +119,12 @@ async def test_delete_sole_active_project_locks_then_rolls_back() -> None:
data.rollback = mock.AsyncMock()
data.commit = mock.AsyncMock()
data.delete = mock.AsyncMock()
+ data.approval_request = mock.MagicMock(return_value=_query(get=_approval(sql.ApprovalAction.DELETE)))
data.project = mock.MagicMock(side_effect=[_query(get=target), _query(all_=[target])])
writer = _writer(data)
with pytest.raises(storage.AccessError, match="only project"):
- await writer.delete(safe.ProjectKey("alpha-one"))
+ await writer.delete(safe.ProjectKey("alpha-one"), 7)
data.begin_immediate.assert_awaited_once()
data.rollback.assert_awaited_once()
@@ -96,11 +141,12 @@ async def test_delete_with_releases_rolls_back() -> None:
data.rollback = mock.AsyncMock()
data.commit = mock.AsyncMock()
data.delete = mock.AsyncMock()
+ data.approval_request = mock.MagicMock(return_value=_query(get=_approval(sql.ApprovalAction.DELETE)))
data.project = mock.MagicMock(side_effect=[_query(get=target), _query(all_=[target, other])])
writer = _writer(data)
with pytest.raises(storage.AccessError, match="associated releases"):
- await writer.delete(safe.ProjectKey("alpha-one"))
+ await writer.delete(safe.ProjectKey("alpha-one"), 7)
data.begin_immediate.assert_awaited_once()
data.rollback.assert_awaited_once()
@@ -108,6 +154,12 @@ async def test_delete_with_releases_rolls_back() -> None:
data.delete.assert_not_awaited()
+def _approval(action: sql.ApprovalAction, committee_key: str = "alpha") -> SimpleNamespace:
+ return SimpleNamespace(
+ status=sql.ApprovalStatus.APPROVED, project_key="alpha-one", committee_key=committee_key, action=action
+ )
+
+
def _query(get: object = None, all_: list | None = None) -> mock.MagicMock:
query = mock.MagicMock()
query.get = mock.AsyncMock(return_value=get)
@@ -120,4 +172,5 @@ def _writer(data: mock.MagicMock) -> project.CommitteeMember:
writer._CommitteeMember__data = data
writer._CommitteeMember__asf_uid = "tester"
writer._CommitteeMember__write_as = mock.MagicMock()
+ writer._CommitteeMember__committee_key = "alpha"
return writer
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org