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-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-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-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-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-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 |
|
| 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.write_idempotency_key import WriteIdempotencyKey
|
||||||
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
|
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
|
||||||
from api.models.review_contract import ReviewContract, ReviewReceipt
|
from api.models.review_contract import ReviewContract, ReviewReceipt
|
||||||
|
from api.models.repository_rename import (
|
||||||
|
RepositoryForgeIdentity,
|
||||||
|
RepositoryRenameOperation,
|
||||||
|
RepositorySlug,
|
||||||
|
)
|
||||||
from api.models.suggestion import (
|
from api.models.suggestion import (
|
||||||
Suggestion,
|
Suggestion,
|
||||||
SuggestionNote,
|
SuggestionNote,
|
||||||
|
|
@ -85,5 +90,6 @@ __all__ = [
|
||||||
"WriteIdempotencyKey",
|
"WriteIdempotencyKey",
|
||||||
"WorkRecordIdentifierAlias",
|
"WorkRecordIdentifierAlias",
|
||||||
"ReviewContract", "ReviewReceipt",
|
"ReviewContract", "ReviewReceipt",
|
||||||
|
"RepositoryForgeIdentity", "RepositoryRenameOperation", "RepositorySlug",
|
||||||
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
|
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,21 @@ class ManagedRepo(Base, TimestampMixin):
|
||||||
"RepoGoal", back_populates="repo", lazy="selectin"
|
"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
|
@property
|
||||||
def domain_slug(self) -> str:
|
def domain_slug(self) -> str:
|
||||||
return self.domain.slug if self.domain is not None else ""
|
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 fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlalchemy import case, func, or_, select
|
from sqlalchemy import case, func, or_, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import noload
|
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.interface_change import InterfaceChange
|
||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.repo_goal import RepoGoal
|
from api.models.repo_goal import RepoGoal
|
||||||
|
from api.models.repository_rename import RepositorySlug
|
||||||
from api.models.tpsc import TPSCSnapshot
|
from api.models.tpsc import TPSCSnapshot
|
||||||
from api.models.task import Task
|
from api.models.task import Task
|
||||||
from api.models.workplan import Workplan
|
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 SBOMNexusError
|
||||||
from api.services.sbom_nexus import get_json as get_sbom_nexus_json
|
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.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
|
from hub_core.routers.repos import create_repos_router
|
||||||
|
|
||||||
router = APIRouter(prefix="/repos", tags=["repos"])
|
router = APIRouter(prefix="/repos", tags=["repos"])
|
||||||
|
|
@ -150,7 +153,10 @@ async def register_repo(
|
||||||
if domain_obj is None:
|
if domain_obj is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
|
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
|
||||||
existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug))
|
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")
|
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
|
||||||
|
|
||||||
payload = body.model_dump()
|
payload = body.model_dump()
|
||||||
|
|
@ -179,7 +185,16 @@ async def register_repo(
|
||||||
standard_version=body.standard_version,
|
standard_version=body.standard_version,
|
||||||
)
|
)
|
||||||
session.add(repo)
|
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 session.refresh(repo)
|
||||||
await _publish_repo_registered(repo, body, domain_obj)
|
await _publish_repo_registered(repo, body, domain_obj)
|
||||||
return repo
|
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.database import async_session_factory, engine # noqa: E402
|
||||||
from api.models.domain import Domain # noqa: E402
|
from api.models.domain import Domain # noqa: E402
|
||||||
from api.models.managed_repo import ManagedRepo # noqa: E402
|
from api.models.managed_repo import ManagedRepo # noqa: E402
|
||||||
|
from api.services.repository_identity import stage_initial_repository_identity # noqa: E402
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -286,6 +287,8 @@ async def _upsert_via_db(
|
||||||
)
|
)
|
||||||
_apply_classification(repo, data, domain_id)
|
_apply_classification(repo, data, domain_id)
|
||||||
session.add(repo)
|
session.add(repo)
|
||||||
|
await session.flush()
|
||||||
|
stage_initial_repository_identity(session, repo)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
report.add(
|
report.add(
|
||||||
RowResult(slug, str(git_root), "registered", f"domain={data.domain}")
|
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
|
```task
|
||||||
id: STATE-WP-0085-T02
|
id: STATE-WP-0085-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c9e6c8c5-c254-5f4d-982c-c161f6ed6d29"
|
state_hub_task_id: "c9e6c8c5-c254-5f4d-982c-c161f6ed6d29"
|
||||||
```
|
```
|
||||||
|
|
@ -219,6 +219,22 @@ Acceptance:
|
||||||
- no migration rewrites workplan, task, progress, decision, token, or SBOM
|
- no migration rewrites workplan, task, progress, decision, token, or SBOM
|
||||||
identities.
|
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
|
## Implement preflight, apply, resume, verify, and rollback APIs
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue