feat: persist repository rename identity
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
parent
13f7a10018
commit
8988a093f2
10 changed files with 1454 additions and 4 deletions
|
|
@ -322,7 +322,7 @@
|
|||
| task | STATE-WP-0084-T03 | progress | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
|
||||
| task | STATE-WP-0084-T04 | wait | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
|
||||
| task | STATE-WP-0085-T01 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T02 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T02 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T03 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T04 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T05 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
|
|||
from api.models.write_idempotency_key import WriteIdempotencyKey
|
||||
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
|
||||
from api.models.review_contract import ReviewContract, ReviewReceipt
|
||||
from api.models.repository_rename import (
|
||||
RepositoryForgeIdentity,
|
||||
RepositoryRenameOperation,
|
||||
RepositorySlug,
|
||||
)
|
||||
from api.models.suggestion import (
|
||||
Suggestion,
|
||||
SuggestionNote,
|
||||
|
|
@ -85,5 +90,6 @@ __all__ = [
|
|||
"WriteIdempotencyKey",
|
||||
"WorkRecordIdentifierAlias",
|
||||
"ReviewContract", "ReviewReceipt",
|
||||
"RepositoryForgeIdentity", "RepositoryRenameOperation", "RepositorySlug",
|
||||
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -53,6 +53,21 @@ class ManagedRepo(Base, TimestampMixin):
|
|||
"RepoGoal", back_populates="repo", lazy="selectin"
|
||||
)
|
||||
|
||||
forge_identity: Mapped["RepositoryForgeIdentity | None"] = relationship( # noqa: F821
|
||||
"RepositoryForgeIdentity",
|
||||
back_populates="repo",
|
||||
lazy="selectin",
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
slug_records: Mapped[list["RepositorySlug"]] = relationship( # noqa: F821
|
||||
"RepositorySlug", back_populates="repo", lazy="selectin"
|
||||
)
|
||||
|
||||
rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship( # noqa: F821
|
||||
"RepositoryRenameOperation", back_populates="repo", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
|
|
|
|||
292
api/models/repository_rename.py
Normal file
292
api/models/repository_rename.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
FORGE_IDENTITY_STATES = ("unverified", "verified")
|
||||
REPOSITORY_SLUG_KINDS = ("canonical", "alias")
|
||||
REPOSITORY_RENAME_PHASES = (
|
||||
"draft",
|
||||
"preflighted",
|
||||
"forge-renamed",
|
||||
"statehub-rebound",
|
||||
"source-synced",
|
||||
"consumers-verified",
|
||||
"completed",
|
||||
"rollback-preflight",
|
||||
"rolled-back",
|
||||
)
|
||||
TERMINAL_REPOSITORY_RENAME_PHASES = ("completed", "rolled-back")
|
||||
|
||||
|
||||
class RepositoryForgeIdentity(Base, TimestampMixin):
|
||||
"""One explicitly verified (or explicitly unverified) Forge identity per repo."""
|
||||
|
||||
__tablename__ = "repository_forge_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("repo_id", name="uq_repository_forge_identities_repo_id"),
|
||||
UniqueConstraint(
|
||||
"id",
|
||||
"repo_id",
|
||||
"verification_state",
|
||||
name="uq_repository_forge_identity_verified_ref",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"provider",
|
||||
"forge_instance",
|
||||
"forge_owner",
|
||||
"forge_repository_id",
|
||||
name="uq_repository_forge_identity_tuple",
|
||||
),
|
||||
CheckConstraint(
|
||||
"verification_state IN ('unverified', 'verified')",
|
||||
name="ck_repository_forge_identity_state",
|
||||
),
|
||||
CheckConstraint(
|
||||
"forge_repository_id IS NULL OR forge_repository_id > 0",
|
||||
name="ck_repository_forge_id_positive",
|
||||
),
|
||||
CheckConstraint(
|
||||
"verification_state != 'verified' OR "
|
||||
"(provider IS NOT NULL AND forge_instance IS NOT NULL "
|
||||
"AND forge_owner IS NOT NULL AND forge_repository_id IS NOT NULL "
|
||||
"AND verified_at IS NOT NULL AND verified_by IS NOT NULL)",
|
||||
name="ck_repository_forge_verified_complete",
|
||||
),
|
||||
CheckConstraint(
|
||||
"verification_state = 'verified' OR "
|
||||
"(verified_at IS NULL AND verified_by IS NULL)",
|
||||
name="ck_repository_forge_unverified_has_no_attestation",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
provider: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
forge_instance: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
forge_owner: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
forge_repository_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
verification_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="unverified", server_default="unverified"
|
||||
)
|
||||
verified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
verified_by: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
verification_evidence: Mapped[dict] = mapped_column(
|
||||
JSONB, nullable=False, default=dict, server_default="{}"
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="forge_identity", lazy="selectin"
|
||||
)
|
||||
rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship(
|
||||
"RepositoryRenameOperation",
|
||||
back_populates="forge_identity",
|
||||
lazy="selectin",
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
|
||||
class RepositoryRenameOperation(Base, TimestampMixin):
|
||||
"""Append-preserving journal for one phased canonical-coordinate change."""
|
||||
|
||||
__tablename__ = "repository_rename_operations"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"phase IN ('draft', 'preflighted', 'forge-renamed', "
|
||||
"'statehub-rebound', 'source-synced', 'consumers-verified', "
|
||||
"'completed', 'rollback-preflight', 'rolled-back')",
|
||||
name="ck_repository_rename_phase",
|
||||
),
|
||||
CheckConstraint(
|
||||
"old_slug != new_slug", name="ck_repository_rename_distinct_slugs"
|
||||
),
|
||||
CheckConstraint(
|
||||
"expected_forge_repository_id > 0",
|
||||
name="ck_repository_rename_forge_id_positive",
|
||||
),
|
||||
CheckConstraint(
|
||||
"forge_identity_state = 'verified'",
|
||||
name="ck_repository_rename_verified_identity",
|
||||
),
|
||||
CheckConstraint(
|
||||
"phase = 'draft' OR preflighted_at IS NOT NULL",
|
||||
name="ck_repository_rename_preflight_timestamp",
|
||||
),
|
||||
CheckConstraint(
|
||||
"phase != 'completed' OR completed_at IS NOT NULL",
|
||||
name="ck_repository_rename_completed_timestamp",
|
||||
),
|
||||
CheckConstraint(
|
||||
"phase != 'rolled-back' OR rolled_back_at IS NOT NULL",
|
||||
name="ck_repository_rename_rollback_timestamp",
|
||||
),
|
||||
Index(
|
||||
"uq_repository_rename_active_repo",
|
||||
"repo_id",
|
||||
unique=True,
|
||||
postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"),
|
||||
),
|
||||
Index(
|
||||
"uq_repository_rename_active_new_slug",
|
||||
"new_slug",
|
||||
unique=True,
|
||||
postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"),
|
||||
),
|
||||
Index(
|
||||
"ix_repository_rename_phase", "phase"
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["forge_identity_id", "repo_id", "forge_identity_state"],
|
||||
[
|
||||
"repository_forge_identities.id",
|
||||
"repository_forge_identities.repo_id",
|
||||
"repository_forge_identities.verification_state",
|
||||
],
|
||||
name="fk_repository_rename_verified_identity",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
forge_identity_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=False
|
||||
)
|
||||
forge_identity_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="verified", server_default="verified"
|
||||
)
|
||||
expected_provider: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
expected_forge_instance: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
expected_forge_owner: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
expected_forge_repository_id: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False
|
||||
)
|
||||
expected_source_commit: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expected_default_branch: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
old_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
new_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
old_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
new_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
phase: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="draft", server_default="draft"
|
||||
)
|
||||
actor: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
phase_changed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
preflighted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
preflight_expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
rolled_back_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
evidence: Mapped[dict] = mapped_column(
|
||||
JSONB, nullable=False, default=dict, server_default="{}"
|
||||
)
|
||||
error_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
error_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="rename_operations", lazy="selectin"
|
||||
)
|
||||
forge_identity: Mapped[RepositoryForgeIdentity] = relationship(
|
||||
"RepositoryForgeIdentity",
|
||||
back_populates="rename_operations",
|
||||
lazy="selectin",
|
||||
viewonly=True,
|
||||
)
|
||||
slugs: Mapped[list["RepositorySlug"]] = relationship(
|
||||
"RepositorySlug", back_populates="source_operation", lazy="selectin"
|
||||
)
|
||||
|
||||
|
||||
class RepositorySlug(Base, TimestampMixin):
|
||||
"""Global uniqueness boundary for current and protected prior slugs."""
|
||||
|
||||
__tablename__ = "repository_slugs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("slug", name="uq_repository_slugs_slug"),
|
||||
CheckConstraint(
|
||||
"kind IN ('canonical', 'alias')", name="ck_repository_slug_kind"
|
||||
),
|
||||
CheckConstraint(
|
||||
"kind != 'alias' OR protected",
|
||||
name="ck_repository_slug_alias_protected",
|
||||
),
|
||||
Index(
|
||||
"uq_repository_slugs_one_canonical",
|
||||
"repo_id",
|
||||
unique=True,
|
||||
postgresql_where=text("kind = 'canonical'"),
|
||||
),
|
||||
Index("ix_repository_slugs_repo_kind", "repo_id", "kind"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
protected: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="true"
|
||||
)
|
||||
source_operation_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("repository_rename_operations.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="slug_records", lazy="selectin"
|
||||
)
|
||||
source_operation: Mapped[RepositoryRenameOperation | None] = relationship(
|
||||
"RepositoryRenameOperation", back_populates="slugs", lazy="selectin"
|
||||
)
|
||||
|
|
@ -11,6 +11,7 @@ from pathlib import Path
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import noload
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ from api.models.domain import Domain
|
|||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal
|
||||
from api.models.repository_rename import RepositorySlug
|
||||
from api.models.tpsc import TPSCSnapshot
|
||||
from api.models.task import Task
|
||||
from api.models.workplan import Workplan
|
||||
|
|
@ -51,6 +53,7 @@ from api.schemas.managed_repo import (
|
|||
from api.services.sbom_nexus import SBOMNexusError
|
||||
from api.services.sbom_nexus import get_json as get_sbom_nexus_json
|
||||
from api.services.sbom_nexus import reads_from_nexus
|
||||
from api.services.repository_identity import stage_initial_repository_identity
|
||||
from hub_core.routers.repos import create_repos_router
|
||||
|
||||
router = APIRouter(prefix="/repos", tags=["repos"])
|
||||
|
|
@ -150,7 +153,10 @@ async def register_repo(
|
|||
if domain_obj is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
|
||||
existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug))
|
||||
if existing.scalar_one_or_none():
|
||||
claimed_slug = await session.execute(
|
||||
select(RepositorySlug).where(RepositorySlug.slug == body.slug)
|
||||
)
|
||||
if existing.scalar_one_or_none() or claimed_slug.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
|
||||
|
||||
payload = body.model_dump()
|
||||
|
|
@ -179,7 +185,16 @@ async def register_repo(
|
|||
standard_version=body.standard_version,
|
||||
)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
await session.flush()
|
||||
stage_initial_repository_identity(session, repo)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Repo slug '{body.slug}' is already claimed",
|
||||
) from exc
|
||||
await session.refresh(repo)
|
||||
await _publish_repo_registered(repo, body, domain_obj)
|
||||
return repo
|
||||
|
|
|
|||
23
api/services/repository_identity.py
Normal file
23
api/services/repository_identity.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repository_rename import RepositoryForgeIdentity, RepositorySlug
|
||||
|
||||
|
||||
def stage_initial_repository_identity(
|
||||
session: AsyncSession, repo: ManagedRepo
|
||||
) -> None:
|
||||
"""Attach non-guessed identity state to a newly flushed repository row."""
|
||||
if repo.id is None:
|
||||
raise ValueError("repository must be flushed before identity records are staged")
|
||||
session.add_all(
|
||||
[
|
||||
RepositoryForgeIdentity(repo_id=repo.id),
|
||||
RepositorySlug(
|
||||
repo_id=repo.id,
|
||||
slug=repo.slug,
|
||||
kind="canonical",
|
||||
protected=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
475
migrations/versions/f3c4d5e6a7b8_repository_rename_identity.py
Normal file
475
migrations/versions/f3c4d5e6a7b8_repository_rename_identity.py
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
"""repository identity, protected slugs, and rename journal (STATE-WP-0085-T02)
|
||||
|
||||
The existing repository UUID remains the durable State Hub identity. This
|
||||
migration adds only new rows around it: an explicit unverified Forge identity,
|
||||
one canonical slug record, and empty operation history. It deliberately does
|
||||
not infer Forge coordinates from a slug, remote URL, checkout, or Git commit.
|
||||
|
||||
Revision ID: f3c4d5e6a7b8
|
||||
Revises: e2b3c4d5f6a7
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
revision = "f3c4d5e6a7b8"
|
||||
down_revision = "e2b3c4d5f6a7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"repository_forge_identities",
|
||||
sa.Column(
|
||||
"id",
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"repo_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("provider", sa.String(length=40), nullable=True),
|
||||
sa.Column("forge_instance", sa.String(length=255), nullable=True),
|
||||
sa.Column("forge_owner", sa.String(length=255), nullable=True),
|
||||
sa.Column("forge_repository_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column(
|
||||
"verification_state",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default="unverified",
|
||||
),
|
||||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("verified_by", sa.String(length=160), nullable=True),
|
||||
sa.Column(
|
||||
"verification_evidence", JSONB(), nullable=False, server_default="{}"
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"repo_id", name="uq_repository_forge_identities_repo_id"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"id",
|
||||
"repo_id",
|
||||
"verification_state",
|
||||
name="uq_repository_forge_identity_verified_ref",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"provider",
|
||||
"forge_instance",
|
||||
"forge_owner",
|
||||
"forge_repository_id",
|
||||
name="uq_repository_forge_identity_tuple",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"verification_state IN ('unverified', 'verified')",
|
||||
name="ck_repository_forge_identity_state",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"forge_repository_id IS NULL OR forge_repository_id > 0",
|
||||
name="ck_repository_forge_id_positive",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"verification_state != 'verified' OR "
|
||||
"(provider IS NOT NULL AND forge_instance IS NOT NULL "
|
||||
"AND forge_owner IS NOT NULL AND forge_repository_id IS NOT NULL "
|
||||
"AND verified_at IS NOT NULL AND verified_by IS NOT NULL)",
|
||||
name="ck_repository_forge_verified_complete",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"verification_state = 'verified' OR "
|
||||
"(verified_at IS NULL AND verified_by IS NULL)",
|
||||
name="ck_repository_forge_unverified_has_no_attestation",
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"repository_rename_operations",
|
||||
sa.Column(
|
||||
"id",
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"repo_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("forge_identity_id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column(
|
||||
"forge_identity_state",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default="verified",
|
||||
),
|
||||
sa.Column("expected_provider", sa.String(length=40), nullable=False),
|
||||
sa.Column(
|
||||
"expected_forge_instance", sa.String(length=255), nullable=False
|
||||
),
|
||||
sa.Column("expected_forge_owner", sa.String(length=255), nullable=False),
|
||||
sa.Column("expected_forge_repository_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("expected_source_commit", sa.String(length=64), nullable=False),
|
||||
sa.Column("expected_default_branch", sa.String(length=255), nullable=False),
|
||||
sa.Column("old_slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("new_slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("old_coordinates", JSONB(), nullable=False),
|
||||
sa.Column("new_coordinates", JSONB(), nullable=False),
|
||||
sa.Column(
|
||||
"phase", sa.String(length=32), nullable=False, server_default="draft"
|
||||
),
|
||||
sa.Column("actor", sa.String(length=160), nullable=False),
|
||||
sa.Column("phase_changed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("preflighted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("preflight_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rolled_back_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("evidence", JSONB(), nullable=False, server_default="{}"),
|
||||
sa.Column("error_code", sa.String(length=80), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("error_details", JSONB(), nullable=True),
|
||||
sa.Column("error_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"phase IN ('draft', 'preflighted', 'forge-renamed', "
|
||||
"'statehub-rebound', 'source-synced', 'consumers-verified', "
|
||||
"'completed', 'rollback-preflight', 'rolled-back')",
|
||||
name="ck_repository_rename_phase",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"old_slug != new_slug", name="ck_repository_rename_distinct_slugs"
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"expected_forge_repository_id > 0",
|
||||
name="ck_repository_rename_forge_id_positive",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"forge_identity_state = 'verified'",
|
||||
name="ck_repository_rename_verified_identity",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"phase = 'draft' OR preflighted_at IS NOT NULL",
|
||||
name="ck_repository_rename_preflight_timestamp",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"phase != 'completed' OR completed_at IS NOT NULL",
|
||||
name="ck_repository_rename_completed_timestamp",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"phase != 'rolled-back' OR rolled_back_at IS NOT NULL",
|
||||
name="ck_repository_rename_rollback_timestamp",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["forge_identity_id", "repo_id", "forge_identity_state"],
|
||||
[
|
||||
"repository_forge_identities.id",
|
||||
"repository_forge_identities.repo_id",
|
||||
"repository_forge_identities.verification_state",
|
||||
],
|
||||
name="fk_repository_rename_verified_identity",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_repository_rename_operations_repo_id",
|
||||
"repository_rename_operations",
|
||||
["repo_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_repository_rename_operations_old_slug",
|
||||
"repository_rename_operations",
|
||||
["old_slug"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_repository_rename_operations_new_slug",
|
||||
"repository_rename_operations",
|
||||
["new_slug"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_repository_rename_phase",
|
||||
"repository_rename_operations",
|
||||
["phase"],
|
||||
)
|
||||
op.create_index(
|
||||
"uq_repository_rename_active_repo",
|
||||
"repository_rename_operations",
|
||||
["repo_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("phase NOT IN ('completed', 'rolled-back')"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_repository_rename_active_new_slug",
|
||||
"repository_rename_operations",
|
||||
["new_slug"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("phase NOT IN ('completed', 'rolled-back')"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"repository_slugs",
|
||||
sa.Column(
|
||||
"id",
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"repo_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||
sa.Column(
|
||||
"protected", sa.Boolean(), nullable=False, server_default=sa.true()
|
||||
),
|
||||
sa.Column(
|
||||
"source_operation_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("repository_rename_operations.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.UniqueConstraint("slug", name="uq_repository_slugs_slug"),
|
||||
sa.CheckConstraint(
|
||||
"kind IN ('canonical', 'alias')", name="ck_repository_slug_kind"
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"kind != 'alias' OR protected",
|
||||
name="ck_repository_slug_alias_protected",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_repository_slugs_source_operation_id",
|
||||
"repository_slugs",
|
||||
["source_operation_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_repository_slugs_repo_kind",
|
||||
"repository_slugs",
|
||||
["repo_id", "kind"],
|
||||
)
|
||||
op.create_index(
|
||||
"uq_repository_slugs_one_canonical",
|
||||
"repository_slugs",
|
||||
["repo_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("kind = 'canonical'"),
|
||||
)
|
||||
|
||||
# Explicitly mark every existing repository unverified. Do not parse or
|
||||
# infer Forge coordinates from current mutable fields.
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO repository_forge_identities
|
||||
(id, repo_id, verification_state, verification_evidence,
|
||||
created_at, updated_at)
|
||||
SELECT gen_random_uuid(), id, 'unverified', '{}'::jsonb, now(), now()
|
||||
FROM managed_repos
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE FUNCTION guard_repository_forge_identity_history()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'repository Forge identity rows are durable';
|
||||
END IF;
|
||||
IF OLD.verification_state = 'verified' AND ROW(
|
||||
NEW.repo_id,
|
||||
NEW.provider,
|
||||
NEW.forge_instance,
|
||||
NEW.forge_owner,
|
||||
NEW.forge_repository_id,
|
||||
NEW.verification_state,
|
||||
NEW.verified_at,
|
||||
NEW.verified_by,
|
||||
NEW.verification_evidence
|
||||
) IS DISTINCT FROM ROW(
|
||||
OLD.repo_id,
|
||||
OLD.provider,
|
||||
OLD.forge_instance,
|
||||
OLD.forge_owner,
|
||||
OLD.forge_repository_id,
|
||||
OLD.verification_state,
|
||||
OLD.verified_at,
|
||||
OLD.verified_by,
|
||||
OLD.verification_evidence
|
||||
) THEN
|
||||
RAISE EXCEPTION 'verified repository Forge identity is immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE TRIGGER repository_forge_identity_history_guard
|
||||
BEFORE UPDATE OR DELETE ON repository_forge_identities
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_repository_forge_identity_history()
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE FUNCTION guard_repository_slug_history()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'repository slug rows are durable';
|
||||
END IF;
|
||||
IF NEW.repo_id IS DISTINCT FROM OLD.repo_id
|
||||
OR NEW.slug IS DISTINCT FROM OLD.slug THEN
|
||||
RAISE EXCEPTION 'repository slug ownership is immutable';
|
||||
END IF;
|
||||
IF OLD.kind = 'alias' AND ROW(
|
||||
NEW.kind, NEW.protected, NEW.source_operation_id
|
||||
) IS DISTINCT FROM ROW(
|
||||
OLD.kind, OLD.protected, OLD.source_operation_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'protected repository alias is immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE TRIGGER repository_slug_history_guard
|
||||
BEFORE UPDATE OR DELETE ON repository_slugs
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_repository_slug_history()
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE FUNCTION guard_repository_rename_operation_history()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'repository rename operation rows are durable';
|
||||
END IF;
|
||||
IF ROW(
|
||||
NEW.repo_id,
|
||||
NEW.forge_identity_id,
|
||||
NEW.forge_identity_state,
|
||||
NEW.expected_provider,
|
||||
NEW.expected_forge_instance,
|
||||
NEW.expected_forge_owner,
|
||||
NEW.expected_forge_repository_id,
|
||||
NEW.expected_source_commit,
|
||||
NEW.expected_default_branch,
|
||||
NEW.old_slug,
|
||||
NEW.new_slug,
|
||||
NEW.old_coordinates,
|
||||
NEW.new_coordinates,
|
||||
NEW.actor,
|
||||
NEW.created_at
|
||||
) IS DISTINCT FROM ROW(
|
||||
OLD.repo_id,
|
||||
OLD.forge_identity_id,
|
||||
OLD.forge_identity_state,
|
||||
OLD.expected_provider,
|
||||
OLD.expected_forge_instance,
|
||||
OLD.expected_forge_owner,
|
||||
OLD.expected_forge_repository_id,
|
||||
OLD.expected_source_commit,
|
||||
OLD.expected_default_branch,
|
||||
OLD.old_slug,
|
||||
OLD.new_slug,
|
||||
OLD.old_coordinates,
|
||||
OLD.new_coordinates,
|
||||
OLD.actor,
|
||||
OLD.created_at
|
||||
) THEN
|
||||
RAISE EXCEPTION 'repository rename operation assertions are immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE TRIGGER repository_rename_operation_history_guard
|
||||
BEFORE UPDATE OR DELETE ON repository_rename_operations
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_repository_rename_operation_history()
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO repository_slugs
|
||||
(id, repo_id, slug, kind, protected, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), id, slug, 'canonical', true, now(), now()
|
||||
FROM managed_repos
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# These tables are additive. Dropping them restores the prior schema and
|
||||
# cannot rewrite or delete any pre-existing work or telemetry row.
|
||||
op.drop_table("repository_slugs")
|
||||
op.drop_table("repository_rename_operations")
|
||||
op.drop_table("repository_forge_identities")
|
||||
op.execute(sa.text("DROP FUNCTION guard_repository_slug_history()"))
|
||||
op.execute(sa.text("DROP FUNCTION guard_repository_rename_operation_history()"))
|
||||
op.execute(sa.text("DROP FUNCTION guard_repository_forge_identity_history()"))
|
||||
|
|
@ -39,6 +39,7 @@ from api.config import settings # noqa: E402
|
|||
from api.database import async_session_factory, engine # noqa: E402
|
||||
from api.models.domain import Domain # noqa: E402
|
||||
from api.models.managed_repo import ManagedRepo # noqa: E402
|
||||
from api.services.repository_identity import stage_initial_repository_identity # noqa: E402
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
|
@ -286,6 +287,8 @@ async def _upsert_via_db(
|
|||
)
|
||||
_apply_classification(repo, data, domain_id)
|
||||
session.add(repo)
|
||||
await session.flush()
|
||||
stage_initial_repository_identity(session, repo)
|
||||
await session.commit()
|
||||
report.add(
|
||||
RowResult(slug, str(git_root), "registered", f"domain={data.domain}")
|
||||
|
|
|
|||
605
tests/test_repository_rename_persistence.py
Normal file
605
tests/test_repository_rename_persistence.py
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.exc import DBAPIError, IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from api.models.decision import Decision
|
||||
from api.models.domain import Domain
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.repository_rename import (
|
||||
RepositoryForgeIdentity,
|
||||
RepositoryRenameOperation,
|
||||
RepositorySlug,
|
||||
)
|
||||
from api.models.sbom_snapshot import SBOMSnapshot
|
||||
from api.models.task import Task
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.workplan import Workplan
|
||||
from tests.conftest import create_test_domain, create_test_repo
|
||||
|
||||
|
||||
def _operation(
|
||||
*,
|
||||
repo_id: uuid.UUID,
|
||||
identity_id: uuid.UUID,
|
||||
old_slug: str,
|
||||
new_slug: str,
|
||||
forge_repository_id: int,
|
||||
phase: str = "draft",
|
||||
) -> RepositoryRenameOperation:
|
||||
now = datetime.now(timezone.utc)
|
||||
return RepositoryRenameOperation(
|
||||
repo_id=repo_id,
|
||||
forge_identity_id=identity_id,
|
||||
forge_identity_state="verified",
|
||||
expected_provider="forgejo",
|
||||
expected_forge_instance="https://forgejo.coulomb.social",
|
||||
expected_forge_owner="coulomb",
|
||||
expected_forge_repository_id=forge_repository_id,
|
||||
expected_source_commit="a" * 40,
|
||||
expected_default_branch="main",
|
||||
old_slug=old_slug,
|
||||
new_slug=new_slug,
|
||||
old_coordinates={"slug": old_slug},
|
||||
new_coordinates={"slug": new_slug},
|
||||
phase=phase,
|
||||
actor="test",
|
||||
phase_changed_at=now,
|
||||
preflighted_at=now if phase != "draft" else None,
|
||||
preflight_expires_at=(now + timedelta(minutes=30)) if phase != "draft" else None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repo_registration_creates_unverified_identity_and_canonical_slug(
|
||||
client, test_engine
|
||||
):
|
||||
await create_test_domain(client)
|
||||
repo = await create_test_repo(client, slug="identity-test")
|
||||
factory = async_sessionmaker(
|
||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with factory() as session:
|
||||
identity = await session.scalar(
|
||||
select(RepositoryForgeIdentity).where(
|
||||
RepositoryForgeIdentity.repo_id == uuid.UUID(repo["id"])
|
||||
)
|
||||
)
|
||||
slug = await session.scalar(
|
||||
select(RepositorySlug).where(
|
||||
RepositorySlug.repo_id == uuid.UUID(repo["id"])
|
||||
)
|
||||
)
|
||||
|
||||
assert identity is not None
|
||||
assert identity.verification_state == "unverified"
|
||||
assert identity.provider is None
|
||||
assert identity.forge_instance is None
|
||||
assert identity.forge_owner is None
|
||||
assert identity.forge_repository_id is None
|
||||
assert slug is not None
|
||||
assert (slug.slug, slug.kind, slug.protected) == (
|
||||
"identity-test",
|
||||
"canonical",
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_protected_alias_cannot_be_claimed_by_another_repository(
|
||||
client, test_engine
|
||||
):
|
||||
await create_test_domain(client)
|
||||
source = await create_test_repo(client, slug="access-engine")
|
||||
other = await create_test_repo(client, slug="another-repo")
|
||||
factory = async_sessionmaker(
|
||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with factory() as session:
|
||||
session.add(
|
||||
RepositorySlug(
|
||||
repo_id=uuid.UUID(source["id"]),
|
||||
slug="flex-auth",
|
||||
kind="alias",
|
||||
protected=True,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/repos/",
|
||||
json={
|
||||
"domain_slug": "infotech",
|
||||
"slug": "flex-auth",
|
||||
"name": "Conflicting repository",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
async with factory() as session:
|
||||
session.add(
|
||||
RepositorySlug(
|
||||
repo_id=uuid.UUID(other["id"]),
|
||||
slug="flex-auth",
|
||||
kind="alias",
|
||||
protected=True,
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
async with factory() as session:
|
||||
session.add(
|
||||
RepositorySlug(
|
||||
repo_id=uuid.UUID(source["id"]),
|
||||
slug="second-canonical",
|
||||
kind="canonical",
|
||||
protected=True,
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forge_identity_is_unique_and_unverified_repo_cannot_open_operation(
|
||||
client, test_engine
|
||||
):
|
||||
await create_test_domain(client)
|
||||
first = await create_test_repo(client, slug="first-repo")
|
||||
second = await create_test_repo(client, slug="second-repo")
|
||||
factory = async_sessionmaker(
|
||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async with factory() as session:
|
||||
first_identity = await session.scalar(
|
||||
select(RepositoryForgeIdentity).where(
|
||||
RepositoryForgeIdentity.repo_id == uuid.UUID(first["id"])
|
||||
)
|
||||
)
|
||||
assert first_identity is not None
|
||||
first_identity.provider = "forgejo"
|
||||
first_identity.forge_instance = "https://forgejo.coulomb.social"
|
||||
first_identity.forge_owner = "coulomb"
|
||||
first_identity.forge_repository_id = 42
|
||||
first_identity.verification_state = "verified"
|
||||
first_identity.verified_at = now
|
||||
first_identity.verified_by = "test"
|
||||
first_identity.verification_evidence = {"source": "forge-api"}
|
||||
await session.commit()
|
||||
|
||||
async with factory() as session:
|
||||
second_identity = await session.scalar(
|
||||
select(RepositoryForgeIdentity).where(
|
||||
RepositoryForgeIdentity.repo_id == uuid.UUID(second["id"])
|
||||
)
|
||||
)
|
||||
assert second_identity is not None
|
||||
session.add(
|
||||
_operation(
|
||||
repo_id=uuid.UUID(second["id"]),
|
||||
identity_id=second_identity.id,
|
||||
old_slug="second-repo",
|
||||
new_slug="second-repo-renamed",
|
||||
forge_repository_id=43,
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
async with factory() as session:
|
||||
second_identity = await session.scalar(
|
||||
select(RepositoryForgeIdentity).where(
|
||||
RepositoryForgeIdentity.repo_id == uuid.UUID(second["id"])
|
||||
)
|
||||
)
|
||||
assert second_identity is not None
|
||||
second_identity.provider = "forgejo"
|
||||
second_identity.forge_instance = "https://forgejo.coulomb.social"
|
||||
second_identity.forge_owner = "coulomb"
|
||||
second_identity.forge_repository_id = 42
|
||||
second_identity.verification_state = "verified"
|
||||
second_identity.verified_at = now
|
||||
second_identity.verified_by = "test"
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_one_active_operation_can_hold_a_repo_or_target_slug(
|
||||
client, test_engine
|
||||
):
|
||||
await create_test_domain(client)
|
||||
first = await create_test_repo(client, slug="first-active")
|
||||
second = await create_test_repo(client, slug="second-active")
|
||||
factory = async_sessionmaker(
|
||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
identities: dict[str, RepositoryForgeIdentity] = {}
|
||||
|
||||
async with factory() as session:
|
||||
for repo, forge_repository_id in ((first, 101), (second, 102)):
|
||||
identity = await session.scalar(
|
||||
select(RepositoryForgeIdentity).where(
|
||||
RepositoryForgeIdentity.repo_id == uuid.UUID(repo["id"])
|
||||
)
|
||||
)
|
||||
assert identity is not None
|
||||
identity.provider = "forgejo"
|
||||
identity.forge_instance = "https://forgejo.coulomb.social"
|
||||
identity.forge_owner = "coulomb"
|
||||
identity.forge_repository_id = forge_repository_id
|
||||
identity.verification_state = "verified"
|
||||
identity.verified_at = now
|
||||
identity.verified_by = "test"
|
||||
identities[repo["slug"]] = identity
|
||||
await session.commit()
|
||||
|
||||
async with factory() as session:
|
||||
session.add(
|
||||
_operation(
|
||||
repo_id=uuid.UUID(first["id"]),
|
||||
identity_id=identities["first-active"].id,
|
||||
old_slug="first-active",
|
||||
new_slug="shared-target",
|
||||
forge_repository_id=101,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with factory() as session:
|
||||
session.add(
|
||||
_operation(
|
||||
repo_id=uuid.UUID(first["id"]),
|
||||
identity_id=identities["first-active"].id,
|
||||
old_slug="first-active",
|
||||
new_slug="another-target",
|
||||
forge_repository_id=101,
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
async with factory() as session:
|
||||
session.add(
|
||||
_operation(
|
||||
repo_id=uuid.UUID(second["id"]),
|
||||
identity_id=identities["second-active"].id,
|
||||
old_slug="second-active",
|
||||
new_slug="shared-target",
|
||||
forge_repository_id=102,
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flex_auth_preflight_representation_preserves_all_existing_ids(test_engine):
|
||||
factory = async_sessionmaker(
|
||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
repo_id = uuid.UUID("fda8ad85-a7d7-4055-8f21-902a533e59df")
|
||||
domain_id = uuid.uuid4()
|
||||
workplan_id = uuid.uuid4()
|
||||
task_id = uuid.uuid4()
|
||||
progress_id = uuid.uuid4()
|
||||
decision_id = uuid.uuid4()
|
||||
token_id = uuid.uuid4()
|
||||
sbom_id = uuid.uuid4()
|
||||
identity_id = uuid.uuid4()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async with factory() as session:
|
||||
session.add_all(
|
||||
[
|
||||
Domain(id=domain_id, slug="net-kingdom", name="Net Kingdom"),
|
||||
ManagedRepo(
|
||||
id=repo_id,
|
||||
domain_id=domain_id,
|
||||
slug="flex-auth",
|
||||
name="Flex Auth",
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
RepositoryForgeIdentity(
|
||||
id=identity_id,
|
||||
repo_id=repo_id,
|
||||
provider="forgejo",
|
||||
forge_instance="https://forgejo.coulomb.social",
|
||||
forge_owner="coulomb",
|
||||
forge_repository_id=42,
|
||||
verification_state="verified",
|
||||
verified_at=now,
|
||||
verified_by="test",
|
||||
verification_evidence={"source": "forge-api", "repository_id": 42},
|
||||
),
|
||||
RepositorySlug(
|
||||
repo_id=repo_id,
|
||||
slug="flex-auth",
|
||||
kind="canonical",
|
||||
protected=True,
|
||||
),
|
||||
Workplan(
|
||||
id=workplan_id,
|
||||
repo_id=repo_id,
|
||||
slug="flex-wp-0001",
|
||||
title="Existing work",
|
||||
),
|
||||
TokenEvent(
|
||||
id=token_id,
|
||||
repo_id=repo_id,
|
||||
tokens_in=10,
|
||||
tokens_out=5,
|
||||
),
|
||||
SBOMSnapshot(
|
||||
id=sbom_id,
|
||||
repo_id=repo_id,
|
||||
snapshot_at=now,
|
||||
entry_count=0,
|
||||
created_at=now,
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
Task(
|
||||
id=task_id,
|
||||
workplan_id=workplan_id,
|
||||
record_id="FLEX-WP-0001-T01",
|
||||
title="Existing task",
|
||||
),
|
||||
Decision(
|
||||
id=decision_id,
|
||||
workplan_id=workplan_id,
|
||||
title="Existing decision",
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.flush()
|
||||
session.add(
|
||||
ProgressEvent(
|
||||
id=progress_id,
|
||||
workplan_id=workplan_id,
|
||||
task_id=task_id,
|
||||
decision_id=decision_id,
|
||||
event_type="note",
|
||||
summary="Existing evidence",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
_operation(
|
||||
repo_id=repo_id,
|
||||
identity_id=identity_id,
|
||||
old_slug="flex-auth",
|
||||
new_slug="access-engine",
|
||||
forge_repository_id=42,
|
||||
phase="preflighted",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
assert await session.scalar(
|
||||
select(ManagedRepo.slug).where(ManagedRepo.id == repo_id)
|
||||
) == "flex-auth"
|
||||
assert await session.scalar(
|
||||
select(RepositoryRenameOperation.expected_forge_repository_id).where(
|
||||
RepositoryRenameOperation.repo_id == repo_id
|
||||
)
|
||||
) == 42
|
||||
assert await session.scalar(select(Workplan.id)) == workplan_id
|
||||
assert await session.scalar(select(Task.id)) == task_id
|
||||
assert await session.scalar(select(ProgressEvent.id)) == progress_id
|
||||
assert await session.scalar(select(Decision.id)) == decision_id
|
||||
assert await session.scalar(select(TokenEvent.id)) == token_id
|
||||
assert await session.scalar(select(SBOMSnapshot.id)) == sbom_id
|
||||
|
||||
# No commit: this proves representability without touching either live
|
||||
# State Hub or Forgejo and leaves the test database unchanged.
|
||||
await session.rollback()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
|
||||
migration = importlib.import_module(
|
||||
"migrations.versions.f3c4d5e6a7b8_repository_rename_identity"
|
||||
)
|
||||
schema = f"rename_migration_{uuid.uuid4().hex}"
|
||||
repo_ids = [uuid.uuid4(), uuid.uuid4()]
|
||||
sentinel_ids = {
|
||||
"workplans": uuid.uuid4(),
|
||||
"tasks": uuid.uuid4(),
|
||||
"progress_events": uuid.uuid4(),
|
||||
"decisions": uuid.uuid4(),
|
||||
"token_events": uuid.uuid4(),
|
||||
"sbom_snapshots": uuid.uuid4(),
|
||||
}
|
||||
|
||||
async with test_engine.begin() as connection:
|
||||
def run(sync_connection):
|
||||
quoted_schema = sync_connection.dialect.identifier_preparer.quote(schema)
|
||||
sync_connection.exec_driver_sql(f"CREATE SCHEMA {quoted_schema}")
|
||||
sync_connection.exec_driver_sql(
|
||||
f"SET LOCAL search_path TO {quoted_schema}, public"
|
||||
)
|
||||
sync_connection.exec_driver_sql(
|
||||
"CREATE TABLE managed_repos "
|
||||
"(id uuid PRIMARY KEY, slug varchar(100) NOT NULL UNIQUE, name text)"
|
||||
)
|
||||
for table_name in sentinel_ids:
|
||||
sync_connection.exec_driver_sql(
|
||||
f"CREATE TABLE {table_name} (id uuid PRIMARY KEY, marker text)"
|
||||
)
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"INSERT INTO managed_repos (id, slug, name) "
|
||||
"VALUES (:first, 'flex-auth', 'Flex Auth'), "
|
||||
"(:second, 'state-hub', 'State Hub')"
|
||||
),
|
||||
{"first": repo_ids[0], "second": repo_ids[1]},
|
||||
)
|
||||
for table_name, sentinel_id in sentinel_ids.items():
|
||||
sync_connection.execute(
|
||||
text(f"INSERT INTO {table_name} (id, marker) VALUES (:id, 'keep')"),
|
||||
{"id": sentinel_id},
|
||||
)
|
||||
|
||||
original_op = migration.op
|
||||
migration.op = Operations(MigrationContext.configure(sync_connection))
|
||||
try:
|
||||
migration.upgrade()
|
||||
tables = set(inspect(sync_connection).get_table_names(schema=schema))
|
||||
assert {
|
||||
"repository_forge_identities",
|
||||
"repository_rename_operations",
|
||||
"repository_slugs",
|
||||
}.issubset(tables)
|
||||
identity_rows = sync_connection.execute(
|
||||
text(
|
||||
"SELECT repo_id, verification_state, provider, forge_repository_id "
|
||||
"FROM repository_forge_identities ORDER BY repo_id"
|
||||
)
|
||||
).all()
|
||||
assert len(identity_rows) == 2
|
||||
assert all(
|
||||
row.verification_state == "unverified"
|
||||
and row.provider is None
|
||||
and row.forge_repository_id is None
|
||||
for row in identity_rows
|
||||
)
|
||||
slug_rows = sync_connection.execute(
|
||||
text(
|
||||
"SELECT repo_id, slug, kind, protected "
|
||||
"FROM repository_slugs ORDER BY slug"
|
||||
)
|
||||
).all()
|
||||
assert {(row.slug, row.kind, row.protected) for row in slug_rows} == {
|
||||
("flex-auth", "canonical", True),
|
||||
("state-hub", "canonical", True),
|
||||
}
|
||||
identity_id = sync_connection.execute(
|
||||
text(
|
||||
"UPDATE repository_forge_identities SET "
|
||||
"provider = 'forgejo', "
|
||||
"forge_instance = 'https://forgejo.coulomb.social', "
|
||||
"forge_owner = 'coulomb', forge_repository_id = 42, "
|
||||
"verification_state = 'verified', verified_at = now(), "
|
||||
"verified_by = 'migration-test' "
|
||||
"WHERE repo_id = :repo_id RETURNING id"
|
||||
),
|
||||
{"repo_id": repo_ids[0]},
|
||||
).scalar_one()
|
||||
with pytest.raises(DBAPIError):
|
||||
with sync_connection.begin_nested():
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"UPDATE repository_forge_identities "
|
||||
"SET forge_repository_id = 43 WHERE id = :id"
|
||||
),
|
||||
{"id": identity_id},
|
||||
)
|
||||
with pytest.raises(DBAPIError):
|
||||
with sync_connection.begin_nested():
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"DELETE FROM repository_slugs "
|
||||
"WHERE repo_id = :repo_id"
|
||||
),
|
||||
{"repo_id": repo_ids[0]},
|
||||
)
|
||||
|
||||
operation_id = uuid.uuid4()
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"INSERT INTO repository_rename_operations ("
|
||||
"id, repo_id, forge_identity_id, forge_identity_state, "
|
||||
"expected_provider, expected_forge_instance, "
|
||||
"expected_forge_owner, expected_forge_repository_id, "
|
||||
"expected_source_commit, expected_default_branch, "
|
||||
"old_slug, new_slug, old_coordinates, new_coordinates, "
|
||||
"phase, actor, phase_changed_at, evidence, created_at, updated_at"
|
||||
") VALUES ("
|
||||
":id, :repo_id, :identity_id, 'verified', 'forgejo', "
|
||||
"'https://forgejo.coulomb.social', 'coulomb', 42, "
|
||||
":commit, 'main', 'flex-auth', 'access-engine', "
|
||||
"'{\"slug\": \"flex-auth\"}'::jsonb, "
|
||||
"'{\"slug\": \"access-engine\"}'::jsonb, "
|
||||
"'draft', 'migration-test', now(), '{}'::jsonb, now(), now()"
|
||||
")"
|
||||
),
|
||||
{
|
||||
"id": operation_id,
|
||||
"repo_id": repo_ids[0],
|
||||
"identity_id": identity_id,
|
||||
"commit": "a" * 40,
|
||||
},
|
||||
)
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"UPDATE repository_rename_operations SET "
|
||||
"phase = 'preflighted', preflighted_at = now(), "
|
||||
"phase_changed_at = now() WHERE id = :id"
|
||||
),
|
||||
{"id": operation_id},
|
||||
)
|
||||
with pytest.raises(DBAPIError):
|
||||
with sync_connection.begin_nested():
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"UPDATE repository_rename_operations "
|
||||
"SET old_slug = 'rewritten' WHERE id = :id"
|
||||
),
|
||||
{"id": operation_id},
|
||||
)
|
||||
with pytest.raises(DBAPIError):
|
||||
with sync_connection.begin_nested():
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"DELETE FROM repository_rename_operations "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{"id": operation_id},
|
||||
)
|
||||
assert sync_connection.execute(
|
||||
text("SELECT id, slug FROM managed_repos ORDER BY slug")
|
||||
).all() == sorted(
|
||||
[(repo_ids[0], "flex-auth"), (repo_ids[1], "state-hub")],
|
||||
key=lambda item: item[1],
|
||||
)
|
||||
for table_name, sentinel_id in sentinel_ids.items():
|
||||
assert sync_connection.execute(
|
||||
text(f"SELECT id, marker FROM {table_name}")
|
||||
).one() == (sentinel_id, "keep")
|
||||
|
||||
migration.downgrade()
|
||||
tables = set(inspect(sync_connection).get_table_names(schema=schema))
|
||||
assert "repository_forge_identities" not in tables
|
||||
assert "repository_rename_operations" not in tables
|
||||
assert "repository_slugs" not in tables
|
||||
for table_name, sentinel_id in sentinel_ids.items():
|
||||
assert sync_connection.execute(
|
||||
text(f"SELECT id, marker FROM {table_name}")
|
||||
).one() == (sentinel_id, "keep")
|
||||
finally:
|
||||
migration.op = original_op
|
||||
sync_connection.exec_driver_sql("SET LOCAL search_path TO public")
|
||||
sync_connection.exec_driver_sql(f"DROP SCHEMA {quoted_schema} CASCADE")
|
||||
|
||||
await connection.run_sync(run)
|
||||
|
|
@ -188,7 +188,7 @@ are explicit target-workplan boundaries.
|
|||
|
||||
```task
|
||||
id: STATE-WP-0085-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c9e6c8c5-c254-5f4d-982c-c161f6ed6d29"
|
||||
```
|
||||
|
|
@ -219,6 +219,22 @@ Acceptance:
|
|||
- no migration rewrites workplan, task, progress, decision, token, or SBOM
|
||||
identities.
|
||||
|
||||
Result (2026-08-28): migration `f3c4d5e6a7b8` adds an explicitly unverified or
|
||||
verified Forge identity per managed-repository UUID, a global canonical/alias
|
||||
slug registry, and a phased rename-operation journal. Composite foreign keys
|
||||
prevent operations for unverified or mismatched repository identities; partial
|
||||
unique indexes reserve one canonical slug, one active operation per repository,
|
||||
and one active target slug. Database history guards prevent verified Forge
|
||||
identity, protected slug, and operation-assertion rewrites or deletion while
|
||||
leaving phase/evidence/error progress updateable. Existing repositories are
|
||||
backfilled only with an unverified marker and their current canonical slug;
|
||||
Forge coordinates are not guessed. Both API and direct classification
|
||||
registration create the same initial records. PostgreSQL tests prove protected
|
||||
alias conflicts, unique Forge identity, active-operation locks, a non-mutating
|
||||
`flex-auth`/Forgejo-id-42 preflight representation with all existing identities
|
||||
preserved, isolated migration upgrade/downgrade, and full-chain migration. The
|
||||
full Python suite passes (765 tests).
|
||||
|
||||
## Implement preflight, apply, resume, verify, and rollback APIs
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue