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
|
|
@ -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,
|
||||
),
|
||||
]
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue