From e823f7c0c0983d84085ee0c04edd9836f722558f Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 05:22:10 +0300 Subject: [PATCH 01/17] M10: CompatibilityCampaign domain, deterministic state semantics, org compatibility graph Adds the campaign vocabulary (repo/campaign states, freshness, org classes), the two pure state-derivation functions, the derived (never stored) org graph and the bounded explicit-identity internal contract model. Includes the Phase 0 audit/implementation map. --- patchfrog/campaigns/__init__.py | 32 +++ patchfrog/campaigns/domain.py | 302 +++++++++++++++++++++++++++ patchfrog/campaigns/graph.py | 170 +++++++++++++++ patchfrog/campaigns/internal.py | 89 ++++++++ patchfrog/campaigns/state.py | 137 ++++++++++++ tests/unit/test_campaign_state.py | 105 ++++++++++ validation/m10_m11/latest-summary.md | 126 +++++++++++ 7 files changed, 961 insertions(+) create mode 100644 patchfrog/campaigns/__init__.py create mode 100644 patchfrog/campaigns/domain.py create mode 100644 patchfrog/campaigns/graph.py create mode 100644 patchfrog/campaigns/internal.py create mode 100644 patchfrog/campaigns/state.py create mode 100644 tests/unit/test_campaign_state.py create mode 100644 validation/m10_m11/latest-summary.md diff --git a/patchfrog/campaigns/__init__.py b/patchfrog/campaigns/__init__.py new file mode 100644 index 0000000..ff3076b --- /dev/null +++ b/patchfrog/campaigns/__init__.py @@ -0,0 +1,32 @@ +"""Compatibility campaigns (M10): one upstream change, many repositories, +one coherent, honest, idempotent record.""" + +from __future__ import annotations + +from patchfrog.campaigns.domain import ( + CAMPAIGN_ENGINE_VERSION, + CampaignState, + CompatibilityCampaign, + EnrolledRepository, + Freshness, + OrgBlastRadius, + OrgClass, + RepositoryAccess, + RepositoryRecord, + RepoState, + campaign_identity_key, +) + +__all__ = [ + "CAMPAIGN_ENGINE_VERSION", + "CampaignState", + "CompatibilityCampaign", + "EnrolledRepository", + "Freshness", + "OrgBlastRadius", + "OrgClass", + "RepoState", + "RepositoryAccess", + "RepositoryRecord", + "campaign_identity_key", +] diff --git a/patchfrog/campaigns/domain.py b/patchfrog/campaigns/domain.py new file mode 100644 index 0000000..ed98eb5 --- /dev/null +++ b/patchfrog/campaigns/domain.py @@ -0,0 +1,302 @@ +"""Pure domain model for Compatibility Campaigns (M10). + +One upstream API/SDK change may reach several repositories of one +workspace. A :class:`CompatibilityCampaign` is the single, durable record of +that fact: which repositories were in scope, how each was classified, and how +far each migration got -- never a pile of unrelated per-repo results. + +Everything here is pure data. No I/O, no provider calls, no model. + +**Honesty rules baked into the vocabulary** + +* A repository whose dependency discovery is stale, missing or lost is never + ``NOT_AFFECTED``; it is ``STALE`` / ``UNKNOWN`` / ``ACCESS_LOST``. +* ``RESOLVED`` is only ever reached by *fresh re-evaluation* showing a + previously affected repository is no longer affected -- never by "a PR was + opened" or "a patch exists". +* A campaign is ``RESOLVED`` only when every repository in scope is + ``NOT_AFFECTED`` or ``RESOLVED`` (see :mod:`patchfrog.campaigns.state`). +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime +from enum import StrEnum +from types import MappingProxyType + +#: Bumped when campaign identity, state semantics or aggregation rules +#: change such that a previously recorded campaign is no longer what +#: re-running would produce. Part of the campaign identity key. +CAMPAIGN_ENGINE_VERSION = 1 + +#: Free-text fields stored on a record are bounded. +MAX_REASON_CHARS = 400 +MAX_REASONS = 12 +MAX_ERROR_CHARS = 300 + + +class Freshness(StrEnum): + """How current the dependency discovery behind a repository's evidence is.""" + + FRESH = "fresh" + STALE = "stale" + UNKNOWN = "unknown" + ACCESS_LOST = "access_lost" + + +class RepositoryAccess(StrEnum): + """Whether PatchFrog may currently read the repository. Taken from the + installation/enrollment record -- never assumed.""" + + AVAILABLE = "available" + ACCESS_LOST = "access_lost" + + +class RepoState(StrEnum): + """Per-repository campaign state. The first block mirrors the spec's + vocabulary; the second block exists so uncertainty is representable + instead of being rounded to ``NOT_AFFECTED``.""" + + NOT_AFFECTED = "not_affected" + IMPACTED = "impacted" + MIGRATION_PLANNED = "migration_planned" + PATCH_GENERATED = "patch_generated" + VERIFYING = "verifying" + VERIFIED = "verified" + PARTIALLY_VERIFIED = "partially_verified" + HUMAN_REQUIRED = "human_required" + FAILED = "failed" + PR_OPENED = "pr_opened" + RESOLVED = "resolved" + # -- uncertainty / honesty states + #: Discovery is older than the freshness window; requires rediscovery. + STALE = "stale" + #: No usable discovery, or only version-level evidence reaches the change. + UNKNOWN = "unknown" + #: The repository is no longer readable under the installation. + ACCESS_LOST = "access_lost" + #: A patch exists but verification produced insufficient evidence. + UNVERIFIED = "unverified" + + +class CampaignState(StrEnum): + DETECTED = "detected" + ANALYZING = "analyzing" + ACTION_REQUIRED = "action_required" + MIGRATING = "migrating" + PARTIALLY_RESOLVED = "partially_resolved" + RESOLVED = "resolved" + BLOCKED = "blocked" + + +class OrgClass(StrEnum): + """Organization blast-radius classification of one repository.""" + + NOT_AFFECTED = "not_affected" + AFFECTED = "affected" + UNKNOWN = "unknown" + + +class UnknownReason(StrEnum): + STALE_DISCOVERY = "stale_discovery" + NO_DISCOVERY = "no_discovery" + ACCESS_LOST = "access_lost" + INSUFFICIENT_EVIDENCE = "insufficient_evidence" + + +#: States from which work is still progressing without a human decision. +IN_FLIGHT_STATES: frozenset[RepoState] = frozenset( + { + RepoState.MIGRATION_PLANNED, RepoState.PATCH_GENERATED, RepoState.VERIFYING, RepoState.VERIFIED, + RepoState.PR_OPENED, + } +) +#: States that need a human or an operator action before the campaign can finish. +#: ``IMPACTED`` is blocking by design: it is only ever a final evaluation +#: state when no migration was generated (detect-only policy, or no +#: checkout), so someone must decide what happens next. +BLOCKING_STATES: frozenset[RepoState] = frozenset( + { + RepoState.IMPACTED, RepoState.HUMAN_REQUIRED, RepoState.FAILED, RepoState.PARTIALLY_VERIFIED, RepoState.UNVERIFIED, + RepoState.STALE, RepoState.UNKNOWN, RepoState.ACCESS_LOST, + } +) +#: Done: nothing more to do for this repository. +DONE_STATES: frozenset[RepoState] = frozenset({RepoState.NOT_AFFECTED, RepoState.RESOLVED}) +#: Blocked by something no human decision can fix inside the campaign itself. +SYSTEM_BLOCKED_STATES: frozenset[RepoState] = frozenset({RepoState.FAILED, RepoState.ACCESS_LOST}) + + +@dataclass(frozen=True, slots=True) +class EnrolledRepository: + """One repository the workspace has explicitly made available. + + ``last_discovery_at`` is the last *successful* dependency discovery; + ``None`` means there never was one. A repository that is not in the list + of enrolled repositories is simply not in scope -- there is no implicit + access (M10.6).""" + + full_name: str + access: RepositoryAccess = RepositoryAccess.AVAILABLE + last_discovery_at: datetime | None = None + last_discovery_commit_sha: str | None = None + installation_id: int | None = None + + +@dataclass(frozen=True, slots=True) +class RepositoryRecord: + """Everything a campaign knows about one repository. All fields are + bounded, controlled values -- never source text, never secrets.""" + + repository: str + state: RepoState + org_class: OrgClass + freshness: Freshness + unknown_reason: UnknownReason | None = None + #: M6 repository status (``affected``/``uncertain``/``unaffected``) or ``None``. + impact_status: str | None = None + direct_consumers: int = 0 + transitive_consumers: int = 0 + potential_consumers: int = 0 + #: Event compatibility class that reaches this repository, or ``"none"``. + severity: str = "none" + migration_status: str | None = None + #: Whether any deterministic automatic migration step exists. + migration_strategy_available: bool = False + #: ``sandbox`` (executable verification ran) | ``static_only`` | ``not_applicable``. + verification_feasibility: str = "not_applicable" + verification_outcome: str | None = None + #: ``auto_open`` | ``open_with_operator_approval`` | ``plan_only`` | ``not_eligible`` | ``None``. + publication_readiness: str | None = None + base_commit_sha: str | None = None + plan_fingerprint: str | None = None + patch_fingerprint: str | None = None + bundle_fingerprint: str | None = None + pr_status: str | None = None + pr_number: int | None = None + pr_url: str | None = None + residual_risk: str | None = None + #: True once any evaluation classified this repository as affected. Lets a + #: later "no longer affected" re-evaluation become RESOLVED, not NOT_AFFECTED. + ever_affected: bool = False + reasons: tuple[str, ...] = () + #: Bounded descriptions of what a human must do (unresolved migration steps). + human_actions: tuple[str, ...] = () + error: str | None = None + evaluated_at: datetime | None = None + + @property + def requires_rediscovery(self) -> bool: + return self.state in (RepoState.STALE, RepoState.UNKNOWN) and self.unknown_reason in ( + UnknownReason.STALE_DISCOVERY, UnknownReason.NO_DISCOVERY, + ) + + +@dataclass(frozen=True, slots=True) +class OrgBlastRadius: + """Organization-level blast radius. ``safe`` is only ever true when every + enrolled repository is positively known to be unaffected.""" + + total_enrolled: int + potentially_relevant: int + affected: tuple[str, ...] + not_affected: tuple[str, ...] + unknown: tuple[str, ...] + unknown_reasons: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({})) + + @property + def safe(self) -> bool: + return self.total_enrolled > 0 and not self.affected and not self.unknown + + def summary(self) -> dict[str, int]: + return { + "total_enrolled": self.total_enrolled, + "potentially_relevant": self.potentially_relevant, + "affected": len(self.affected), + "not_affected": len(self.not_affected), + "unknown": len(self.unknown), + } + + +def campaign_identity_key(*, workspace_key: str, change_fingerprint: str, engine_version: int = CAMPAIGN_ENGINE_VERSION) -> str: + """One campaign per workspace + upstream change + engine version (M10.9).""" + + payload = {"workspace": workspace_key, "change": change_fingerprint, "engine": engine_version} + return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest() + + +@dataclass(frozen=True, slots=True) +class CompatibilityCampaign: + campaign_id: str + workspace_key: str + change_fingerprint: str + #: Display label of the dependency/provider the change is about. + dependency_label: str + provider_key: str | None + #: ``external`` (a provider/SDK) or ``internal`` (an explicit internal contract). + origin: str + discovered_at: datetime + state: CampaignState + org_blast_radius: OrgBlastRadius + records: tuple[RepositoryRecord, ...] + unresolved_risks: tuple[str, ...] + #: Bumped on every recorded transition of the campaign's own state. + version: int = 1 + engine_version: int = CAMPAIGN_ENGINE_VERSION + #: Repository whose contract changed, for an internal-contract campaign. + producer_repository: str | None = None + old_version: str | None = None + new_version: str | None = None + compatibility: str | None = None + + @property + def repositories_in_scope(self) -> tuple[str, ...]: + return tuple(r.repository for r in self.records) + + @property + def affected_repositories(self) -> tuple[str, ...]: + return tuple(r.repository for r in self.records if r.org_class is OrgClass.AFFECTED) + + def record_for(self, repository: str) -> RepositoryRecord | None: + return next((r for r in self.records if r.repository == repository), None) + + def state_counts(self) -> dict[str, int]: + counts: dict[str, int] = {} + for record in self.records: + counts[record.state.value] = counts.get(record.state.value, 0) + 1 + return dict(sorted(counts.items())) + + +def bound_reasons(reasons: tuple[str, ...] | list[str]) -> tuple[str, ...]: + seen: list[str] = [] + for reason in reasons: + text = reason.strip()[:MAX_REASON_CHARS] + if text and text not in seen: + seen.append(text) + return tuple(seen[:MAX_REASONS]) + + +__all__ = [ + "BLOCKING_STATES", + "CAMPAIGN_ENGINE_VERSION", + "DONE_STATES", + "IN_FLIGHT_STATES", + "MAX_ERROR_CHARS", + "SYSTEM_BLOCKED_STATES", + "CampaignState", + "CompatibilityCampaign", + "EnrolledRepository", + "Freshness", + "OrgBlastRadius", + "OrgClass", + "RepoState", + "RepositoryAccess", + "RepositoryRecord", + "UnknownReason", + "bound_reasons", + "campaign_identity_key", +] diff --git a/patchfrog/campaigns/graph.py b/patchfrog/campaigns/graph.py new file mode 100644 index 0000000..c9a72b3 --- /dev/null +++ b/patchfrog/campaigns/graph.py @@ -0,0 +1,170 @@ +"""Organization compatibility graph (M10.1) -- a *derived view*. + +``Workspace -> repositories -> dependency identities -> per-repository +dependency instances (version, usage sites, contract)``. It is computed from +M5 inventories/registry rows on demand and never stored: one dependency +identity is shared logically across repositories, while every per-repository +fact stays owned by that repository's own M5 rows (no duplicated truth). + +Grouping is by the discovered provider identity, so a provider's Python SDK +consumer, a direct-REST consumer and a generated-OpenAPI-client consumer +land under the same provider identity. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Any + +from patchfrog.campaigns.domain import EnrolledRepository, Freshness +from patchfrog.campaigns.internal import InternalContract +from patchfrog.dependencies.domain import DependencyInventory, ExternalDependency +from patchfrog.upstream.consumers import DependencyIdentity, match_dependency + + +@dataclass(frozen=True, slots=True) +class DependencyInstance: + """One repository's use of one dependency identity.""" + + repository: str + dependency_key: str + kind: str + ecosystem: str + package_name: str | None + version: str | None + usage_sites: int + usage_symbols: tuple[str, ...] + contract_fingerprint: str | None + confidence: str + + +@dataclass(frozen=True, slots=True) +class IdentityNode: + identity_key: str + display_name: str + #: ``external`` | ``internal`` + origin: str + producer_repository: str | None = None + instances: tuple[DependencyInstance, ...] = () + + @property + def repositories(self) -> tuple[str, ...]: + return tuple(sorted({i.repository for i in self.instances})) + + +@dataclass(frozen=True, slots=True) +class RepositoryNode: + full_name: str + freshness: Freshness + has_inventory: bool + commit_sha: str | None = None + identities: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class OrgCompatibilityGraph: + workspace_key: str + repositories: tuple[RepositoryNode, ...] + identities: tuple[IdentityNode, ...] + notes: tuple[str, ...] = field(default_factory=tuple) + + def identity(self, identity_key: str) -> IdentityNode | None: + return next((i for i in self.identities if i.identity_key == identity_key), None) + + def repositories_using(self, identity_key: str) -> tuple[str, ...]: + node = self.identity(identity_key) + return node.repositories if node else () + + def to_dict(self) -> dict[str, Any]: + return { + "workspace": self.workspace_key, + "repositories": [ + {"name": r.full_name, "freshness": r.freshness.value, "has_inventory": r.has_inventory, + "commit_sha": r.commit_sha, "identities": list(r.identities)} + for r in self.repositories + ], + "identities": [ + {"identity": i.identity_key, "display_name": i.display_name, "origin": i.origin, + "producer_repository": i.producer_repository, + "instances": [ + {"repository": x.repository, "dependency_key": x.dependency_key, "kind": x.kind, + "ecosystem": x.ecosystem, "package": x.package_name, "version": x.version, + "usage_sites": x.usage_sites, "contract_fingerprint": x.contract_fingerprint, + "confidence": x.confidence} + for x in i.instances + ]} + for i in self.identities + ], + "notes": list(self.notes), + } + + +def _instance(repository: str, dependency: ExternalDependency) -> DependencyInstance: + symbols = sorted({s.symbol for s in dependency.usage_sites if s.symbol}) + return DependencyInstance( + repository=repository, dependency_key=dependency.key, kind=dependency.kind.value, + ecosystem=dependency.ecosystem.value, package_name=dependency.package_name, + version=dependency.version.display, usage_sites=len(dependency.usage_sites), usage_symbols=tuple(symbols[:20]), + contract_fingerprint=dependency.contract.fingerprint.value if dependency.contract else None, + confidence=dependency.confidence.value, + ) + + +def build_org_graph( + workspace_key: str, + entries: Sequence[tuple[EnrolledRepository, Freshness, DependencyInventory | None]], + *, + internal_contracts: Sequence[InternalContract] = (), +) -> OrgCompatibilityGraph: + """``entries``: each enrolled repository, its freshness, and its latest + inventory (``None`` when there is no usable discovery). Inventories of + non-fresh repositories are still shown -- the node carries the freshness, + so a reader sees the evidence *and* its age.""" + + grouped: dict[str, list[DependencyInstance]] = {} + names: dict[str, str] = {} + origins: dict[str, tuple[str, str | None]] = {} + repo_nodes: list[RepositoryNode] = [] + for repository, freshness, inventory in sorted(entries, key=lambda e: e[0].full_name): + keys: set[str] = set() + if inventory is not None: + for dependency in inventory.dependencies: + identity = DependencyIdentity.of(dependency) + key, name, origin, producer = dependency.provider.key, dependency.provider.display_name, "external", None + for contract in internal_contracts: + if match_dependency(contract.target, identity) is not None: + key, name = f"internal:{contract.contract_id}", contract.label + origin, producer = "internal", contract.producer_repository + break + grouped.setdefault(key, []).append(_instance(repository.full_name, dependency)) + names.setdefault(key, name) + origins.setdefault(key, (origin, producer)) + keys.add(key) + repo_nodes.append( + RepositoryNode( + full_name=repository.full_name, freshness=freshness, has_inventory=inventory is not None, + commit_sha=inventory.commit_sha if inventory else repository.last_discovery_commit_sha, + identities=tuple(sorted(keys)), + ) + ) + nodes = tuple( + IdentityNode( + identity_key=key, display_name=names[key], origin=origins[key][0], producer_repository=origins[key][1], + instances=tuple(sorted(instances, key=lambda i: (i.repository, i.dependency_key))), + ) + for key, instances in sorted(grouped.items()) + ) + notes = tuple( + f"{r.full_name}: evidence is {r.freshness.value}" for r in repo_nodes if r.freshness is not Freshness.FRESH + ) + return OrgCompatibilityGraph(workspace_key=workspace_key, repositories=tuple(repo_nodes), identities=nodes, notes=notes) + + +__all__ = [ + "DependencyInstance", + "IdentityNode", + "OrgCompatibilityGraph", + "RepositoryNode", + "build_org_graph", +] diff --git a/patchfrog/campaigns/internal.py b/patchfrog/campaigns/internal.py new file mode 100644 index 0000000..7332d91 --- /dev/null +++ b/patchfrog/campaigns/internal.py @@ -0,0 +1,89 @@ +"""Bounded internal-contract support (M10.5). + +Beyond external providers, one repository of a workspace may *produce* a +contract other repositories consume: a published package, an OpenAPI spec, +a generated client, an internal SDK. This module models only that, and only +through **explicit identity**: + +* a package identity (ecosystem + package name), or +* an OpenAPI/API identity (declared API hosts), or +* an explicit M5 dependency key. + +No relationship is ever inferred from repository names, organization, +directory layout or similarity. A consumer is related to an internal +contract only because its *own* M5 discovery evidence (manifest declaration, +import, OpenAPI server host, ...) matches that explicit identity -- the very +same matcher external changes use (:func:`patchfrog.upstream.consumers.match_dependency`). +There is deliberately no second graph and no new relation table. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from enum import StrEnum + +from patchfrog.dependencies.domain import Ecosystem +from patchfrog.upstream.domain import DependencyTarget + + +class InternalContractKind(StrEnum): + PACKAGE = "package" + OPENAPI = "openapi" + GENERATED_CLIENT = "generated_client" + INTERNAL_SDK = "internal_sdk" + + +class InternalContractError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class InternalContract: + contract_id: str + kind: InternalContractKind + #: The repository that owns/publishes the contract. + producer_repository: str + target: DependencyTarget + + def __post_init__(self) -> None: + if not self.contract_id.strip(): + raise InternalContractError("an internal contract needs an explicit contract_id") + if not self.producer_repository.strip(): + raise InternalContractError("an internal contract needs an explicit producer repository") + has_package = bool(self.target.package_name) and self.target.ecosystem not in (None, Ecosystem.NONE) + if not (has_package or self.target.api_hosts or self.target.dependency_key): + raise InternalContractError( + f"internal contract {self.contract_id!r} has no explicit identity: give a package " + "(ecosystem + name), API hosts, or a dependency key -- repository names are never used" + ) + + @property + def label(self) -> str: + return self.target.display_name or self.contract_id + + +def contract_target( + *, + package_name: str | None = None, + ecosystem: Ecosystem | None = None, + api_hosts: Sequence[str] = (), + dependency_key: str | None = None, + modules: Sequence[str] = (), + display_name: str | None = None, +) -> DependencyTarget: + """Build the explicit identity of an internal contract. ``provider_key`` + is intentionally never set: a provider-key match would be a broader, + weaker signal than the explicit identity this model demands.""" + + return DependencyTarget( + package_name=package_name, ecosystem=ecosystem, api_hosts=tuple(sorted(set(api_hosts))), + dependency_key=dependency_key, modules=tuple(sorted(set(modules))), display_name=display_name, + ) + + +def producers(contracts: Sequence[InternalContract]) -> frozenset[str]: + return frozenset(c.producer_repository for c in contracts) + + +__all__ = ["InternalContract", "InternalContractError", "InternalContractKind", "contract_target", "producers"] diff --git a/patchfrog/campaigns/state.py b/patchfrog/campaigns/state.py new file mode 100644 index 0000000..80c871e --- /dev/null +++ b/patchfrog/campaigns/state.py @@ -0,0 +1,137 @@ +"""Deterministic state semantics for repositories and campaigns (M10.3). + +Two pure functions, no I/O: + +* :func:`derive_repo_state` -- one repository's evaluation facts -> a + :class:`RepoState`; +* :func:`derive_campaign_state` -- all repository states -> a + :class:`CampaignState`. + +**Campaign precedence (first match wins)** + +1. no repository evaluated yet -> ``DETECTED`` +2. some repositories still unevaluated -> ``ANALYZING`` +3. every repo ``NOT_AFFECTED``/``RESOLVED`` -> ``RESOLVED`` +4. at least one ``RESOLVED`` and anything unfinished -> ``PARTIALLY_RESOLVED`` +5. blocking repos exist, all system-blocked, none in + flight -> ``BLOCKED`` +6. blocking repos exist -> ``ACTION_REQUIRED`` +7. otherwise (only in-flight work) -> ``MIGRATING`` + +Rule 3 is the only road to ``RESOLVED``: a stale, unknown, access-lost, +failed, unverified or human-required repository can never be rounded away. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +from patchfrog.campaigns.domain import ( + BLOCKING_STATES, + DONE_STATES, + IN_FLIGHT_STATES, + SYSTEM_BLOCKED_STATES, + CampaignState, + Freshness, + OrgClass, + RepoState, +) + + +@dataclass(frozen=True, slots=True) +class RepoFacts: + """Inputs to :func:`derive_repo_state`. All strings are the ``.value`` of + the corresponding M6/M7/M8/M9 enum, so this module imports none of them.""" + + freshness: Freshness + org_class: OrgClass + ever_affected: bool = False + #: M7 ``MigrationStatus.value`` or ``None`` if no plan was made. + migration_status: str | None = None + has_automatic_steps: bool = False + patch_generated: bool = False + #: M8 ``VerificationOutcome.value`` or ``None`` if verification did not run. + verification_outcome: str | None = None + #: M9 ``MigrationPRStatus.value`` or ``None``. + pr_status: str | None = None + pr_number: int | None = None + #: The evaluation raised; the repository is isolated, never guessed. + failed: bool = False + + +_PR_OPEN_STATUSES = frozenset({"opened", "updated"}) +#: M9 reports an already-existing, human-closed PR as ``no_op_unchanged`` and does not reopen it. +_PR_CLOSED_BY_HUMAN = "no_op_unchanged" + + +def derive_repo_state(facts: RepoFacts) -> RepoState: + if facts.failed: + return RepoState.FAILED + if facts.freshness is Freshness.ACCESS_LOST: + return RepoState.ACCESS_LOST + if facts.freshness is Freshness.STALE: + return RepoState.STALE + if facts.freshness is Freshness.UNKNOWN: + return RepoState.UNKNOWN + + if facts.org_class is OrgClass.NOT_AFFECTED: + return RepoState.RESOLVED if facts.ever_affected else RepoState.NOT_AFFECTED + if facts.org_class is OrgClass.UNKNOWN: + return RepoState.UNKNOWN + + # -- affected and evidence is fresh + outcome = facts.verification_outcome + if outcome in ("regression_detected", "failed"): + return RepoState.FAILED + if outcome == "human_required": + return RepoState.HUMAN_REQUIRED + if facts.pr_status == _PR_CLOSED_BY_HUMAN and facts.pr_number is not None: + # M9 never reopens a PR a human closed: the next step is a human decision. + return RepoState.HUMAN_REQUIRED + if facts.pr_status in _PR_OPEN_STATUSES and facts.pr_number is not None and outcome in ("verified", "partially_verified"): + return RepoState.PR_OPENED + if outcome == "verified": + return RepoState.VERIFIED + if outcome == "partially_verified": + return RepoState.PARTIALLY_VERIFIED + if outcome == "unverified": + return RepoState.UNVERIFIED + + # -- no verification outcome yet + if facts.migration_status in ("human_required", "unsupported"): + return RepoState.HUMAN_REQUIRED + if facts.migration_status == "failed": + return RepoState.FAILED + if facts.patch_generated: + return RepoState.PATCH_GENERATED + if facts.migration_status in ("planned", "patch_generated", "partial") and facts.has_automatic_steps: + return RepoState.MIGRATION_PLANNED + if facts.migration_status in ("planned", "patch_generated", "partial"): + return RepoState.HUMAN_REQUIRED + return RepoState.IMPACTED + + +def derive_campaign_state(states: Iterable[RepoState | None]) -> CampaignState: + """``None`` marks a repository in scope that has not been evaluated yet.""" + + items = list(states) + if not items or all(s is None for s in items): + return CampaignState.DETECTED + if any(s is None for s in items): + return CampaignState.ANALYZING + evaluated: list[RepoState] = [s for s in items if s is not None] + if all(s in DONE_STATES for s in evaluated): + return CampaignState.RESOLVED + if any(s is RepoState.RESOLVED for s in evaluated): + return CampaignState.PARTIALLY_RESOLVED + blocking = [s for s in evaluated if s in BLOCKING_STATES] + in_flight = [s for s in evaluated if s in IN_FLIGHT_STATES] + if blocking and not in_flight and all(s in SYSTEM_BLOCKED_STATES for s in blocking): + return CampaignState.BLOCKED + if blocking: + return CampaignState.ACTION_REQUIRED + return CampaignState.MIGRATING + + +__all__ = ["RepoFacts", "derive_campaign_state", "derive_repo_state"] diff --git a/tests/unit/test_campaign_state.py b/tests/unit/test_campaign_state.py new file mode 100644 index 0000000..eb244ac --- /dev/null +++ b/tests/unit/test_campaign_state.py @@ -0,0 +1,105 @@ +"""M10.3: deterministic repository and campaign state semantics.""" + +from __future__ import annotations + +import pytest + +from patchfrog.campaigns.domain import CampaignState, Freshness, OrgClass, RepoState +from patchfrog.campaigns.state import RepoFacts, derive_campaign_state, derive_repo_state + +F, A, N, U = Freshness.FRESH, OrgClass.AFFECTED, OrgClass.NOT_AFFECTED, OrgClass.UNKNOWN + + +@pytest.mark.parametrize( + ("facts", "expected"), + [ + (RepoFacts(Freshness.ACCESS_LOST, A), RepoState.ACCESS_LOST), + (RepoFacts(Freshness.STALE, N), RepoState.STALE), + (RepoFacts(Freshness.UNKNOWN, N), RepoState.UNKNOWN), + (RepoFacts(F, N), RepoState.NOT_AFFECTED), + (RepoFacts(F, N, ever_affected=True), RepoState.RESOLVED), + (RepoFacts(F, U), RepoState.UNKNOWN), + (RepoFacts(F, A), RepoState.IMPACTED), + (RepoFacts(F, A, migration_status="planned", has_automatic_steps=True), RepoState.MIGRATION_PLANNED), + (RepoFacts(F, A, migration_status="planned", has_automatic_steps=False), RepoState.HUMAN_REQUIRED), + (RepoFacts(F, A, migration_status="human_required"), RepoState.HUMAN_REQUIRED), + (RepoFacts(F, A, migration_status="unsupported"), RepoState.HUMAN_REQUIRED), + (RepoFacts(F, A, migration_status="failed"), RepoState.FAILED), + (RepoFacts(F, A, migration_status="patch_generated", has_automatic_steps=True, patch_generated=True), + RepoState.PATCH_GENERATED), + (RepoFacts(F, A, verification_outcome="verified"), RepoState.VERIFIED), + (RepoFacts(F, A, verification_outcome="partially_verified"), RepoState.PARTIALLY_VERIFIED), + (RepoFacts(F, A, verification_outcome="unverified"), RepoState.UNVERIFIED), + (RepoFacts(F, A, verification_outcome="failed"), RepoState.FAILED), + (RepoFacts(F, A, verification_outcome="regression_detected"), RepoState.FAILED), + (RepoFacts(F, A, verification_outcome="human_required"), RepoState.HUMAN_REQUIRED), + (RepoFacts(F, A, verification_outcome="verified", pr_status="opened", pr_number=7), RepoState.PR_OPENED), + (RepoFacts(F, A, verification_outcome="verified", pr_status="stale_requires_regeneration"), + RepoState.VERIFIED), + (RepoFacts(F, A, failed=True), RepoState.FAILED), + (RepoFacts(F, A, verification_outcome="verified", pr_status="updated", pr_number=7), RepoState.PR_OPENED), + # M9 never reopens a PR a human closed -> a human decision, not "in flight" + (RepoFacts(F, A, verification_outcome="verified", pr_status="no_op_unchanged", pr_number=7), + RepoState.HUMAN_REQUIRED), + ], +) +def test_repo_state_matrix(facts: RepoFacts, expected: RepoState) -> None: + assert derive_repo_state(facts) is expected + + +def test_stale_evidence_beats_everything_else_but_failure() -> None: + # Even a repository that would be a verified, PR-opened migration is STALE if its evidence is not fresh. + facts = RepoFacts(Freshness.STALE, A, verification_outcome="verified", pr_status="opened", pr_number=1) + assert derive_repo_state(facts) is RepoState.STALE + + +def test_open_pr_never_hides_a_failed_or_human_outcome() -> None: + assert derive_repo_state(RepoFacts(F, A, verification_outcome="human_required", pr_status="opened", pr_number=1)) \ + is RepoState.HUMAN_REQUIRED + assert derive_repo_state(RepoFacts(F, A, verification_outcome="failed", pr_status="opened", pr_number=1)) \ + is RepoState.FAILED + + +S = RepoState + + +@pytest.mark.parametrize( + ("states", "expected"), + [ + ([], CampaignState.DETECTED), + ([None, None], CampaignState.DETECTED), + ([S.NOT_AFFECTED, None], CampaignState.ANALYZING), + ([S.NOT_AFFECTED, S.NOT_AFFECTED], CampaignState.RESOLVED), + ([S.RESOLVED, S.NOT_AFFECTED], CampaignState.RESOLVED), + ([S.VERIFIED, S.NOT_AFFECTED], CampaignState.MIGRATING), + ([S.PR_OPENED, S.PATCH_GENERATED], CampaignState.MIGRATING), + ([S.HUMAN_REQUIRED, S.NOT_AFFECTED], CampaignState.ACTION_REQUIRED), + ([S.VERIFIED, S.HUMAN_REQUIRED, S.NOT_AFFECTED, S.STALE], CampaignState.ACTION_REQUIRED), + ([S.STALE], CampaignState.ACTION_REQUIRED), + ([S.UNKNOWN, S.NOT_AFFECTED], CampaignState.ACTION_REQUIRED), + ([S.PARTIALLY_VERIFIED], CampaignState.ACTION_REQUIRED), + ([S.UNVERIFIED], CampaignState.ACTION_REQUIRED), + ([S.RESOLVED, S.HUMAN_REQUIRED], CampaignState.PARTIALLY_RESOLVED), + ([S.RESOLVED, S.PR_OPENED], CampaignState.PARTIALLY_RESOLVED), + ([S.RESOLVED, S.STALE, S.NOT_AFFECTED], CampaignState.PARTIALLY_RESOLVED), + ([S.FAILED, S.NOT_AFFECTED], CampaignState.BLOCKED), + ([S.FAILED, S.ACCESS_LOST], CampaignState.BLOCKED), + # failed alongside a human-actionable repo is still actionable, not "blocked" + ([S.FAILED, S.HUMAN_REQUIRED], CampaignState.ACTION_REQUIRED), + # failed with other work still in flight is not blocked + ([S.FAILED, S.VERIFIED], CampaignState.ACTION_REQUIRED), + ], +) +def test_campaign_state_matrix(states: list[RepoState | None], expected: CampaignState) -> None: + assert derive_campaign_state(states) is expected + + +@pytest.mark.parametrize("blocker", [S.STALE, S.UNKNOWN, S.ACCESS_LOST, S.FAILED, S.UNVERIFIED, S.PARTIALLY_VERIFIED, + S.HUMAN_REQUIRED]) +def test_a_campaign_is_never_resolved_while_any_repo_is_unresolved(blocker: RepoState) -> None: + assert derive_campaign_state([S.RESOLVED, S.NOT_AFFECTED, blocker]) is not CampaignState.RESOLVED + assert derive_campaign_state([S.NOT_AFFECTED, blocker]) is not CampaignState.RESOLVED + + +def test_impacted_without_a_migration_needs_action_not_progress() -> None: + assert derive_campaign_state([S.IMPACTED, S.NOT_AFFECTED]) is CampaignState.ACTION_REQUIRED diff --git a/validation/m10_m11/latest-summary.md b/validation/m10_m11/latest-summary.md new file mode 100644 index 0000000..47cc4aa --- /dev/null +++ b/validation/m10_m11/latest-summary.md @@ -0,0 +1,126 @@ +# M10 + M11 -- Phase 0 audit and implementation map + +Baseline: `patchfrog` main `66cf5fa55ca8623a5214dd685406704636473f22`. +Cloud baseline: `patchfrog-cloud` main `2eb9a29` (pins engine `989567c`, pre-M4). + +Invariant: `change -> impact -> evidence -> verification -> decision`. + +This file is written before any code. It records what already exists, what +is reused, what is generalized, and where each new thing lives. + +## 1. What exists in the public engine (reused, not rebuilt) + +| Need | Existing primitive | Notes | +|---|---|---| +| Dependency identity / inventory | `patchfrog.dependencies` (`ExternalDependency`, `DependencyInventory`, `discover_dependencies`) | Provider-agnostic; env-var *names* only. | +| Persistent registry | `DependencyRegistry` + `external_dependencies`, `external_dependency_usage_sites`, `external_contract_snapshots` | Per repository, idempotent, has `last_observed_at`. This is the freshness source. | +| Upstream change | `patchfrog.upstream` (`ExternalChangeEvent`, `build_contract_change`, `build_version_change`, fingerprint) | Event fingerprint is already the dedup identity. | +| Multi-repo impact | `upstream.workspace` (`analyze_inventory`, `analyze_workspace`, `registry_impact`) | Already sorts repos into affected / uncertain / unaffected. Works from checkouts *or* registry rows. | +| Blast radius per repo | `upstream.blast_radius.compute_blast_radius` | Direct/transitive/potential. | +| Migration plan / patch | `migration.planner.plan_migration`, `migration.generator.generate_patch` | Operate on a local `root: Path`. | +| Verification | `migration_verification.service.run_migration_verification` -> `MigrationEvidenceBundle` | Outcome precedence fixed in PR #67 (`decision.py`). | +| PR decision + dossier | `migration_pr.planner.build_pr_plan`, `eligibility`, `dossier`, `integrity` | Pure; stale-base + integrity checks live in `MigrationPRPublisher`. | +| PR publication | `MigrationPRPublisher` over `MigrationGitHubPublisher` protocol; `GitHubClientMigrationPublisher`; `FakeGitHub` | The GitHub side effect is already behind a protocol. Cloud only has to supply credentials. | +| Snapshot acquisition | `RepositorySnapshotProvider.acquire(clone_url, commit_sha, token=...)` | Untrusted-tree safe; no code execution. | +| Installation tokens | `github.auth.InstallationTokenProvider` | Cloud already uses `build_app_jwt`. | +| Metrics | `ops.metrics` (low-cardinality Prometheus, multiprocess mode) | Extend, do not replace. | + +## 2. What the older cross-repo work gives M10, and what it does not + +`patchfrog.cross_repo_intelligence` + `repository_contract_keys` + +`repository_relations` is **PR-review-centric**: it answers "does an +explicitly linked peer consume a contract the *current PR* changed?" and only +feeds a review-hint. It stays as-is (CLAUDE.md layer R). What M10 reuses from +it is its **trust rule**: relationships are explicit, never inferred from +names/orgs/similarity, and never taken from PR-controlled files. + +What must be *generalized* (not copied): + +- the unit of analysis changes from "current PR" to "upstream change event"; +- the repo set changes from `MAX_CROSS_REPO_PEERS = 4` neighbours to every + enrolled repository in a workspace; +- the output changes from a review signal to a persistent campaign. + +Decision: M10 does **not** write to `repository_relations`. Internal +contracts (M10.5) are modelled as an explicit `DependencyTarget` identity +that the existing `match_dependency` already knows how to match against +consumers' M5 evidence. That avoids a second relation graph. + +## 3. Where state lives + +| State | Home | Why | +|---|---|---| +| Org compatibility graph | **Derived view**, no new table | Built from M5 registry rows grouped by dependency identity. Storing it would duplicate per-repo truth. | +| `CompatibilityCampaign` + per-repo records | Public, new tables `compatibility_campaigns`, `campaign_repository_records` (Alembic `0038`) | Self-host relevant engine truth. Keyed by an opaque `workspace_key` string; the engine has no notion of a Cloud workspace. | +| Watcher snapshot normalization + diff | Public code, no table | Pure functions over a cursor/snapshot value. | +| Cursor / schedule / attempts / backoff | **Cloud** `cloud_upstream_sources` | Operating the service. | +| Workspace policy, quota, jobs, dashboard | **Cloud** | SaaS lifecycle. | + +One Alembic head per repo is preserved: public `0037 -> 0038`; Cloud adds one +revision on its own chain. + +## 4. Where continuous polling belongs + +Public: *what* to watch (`watchers.registry` aggregates active dependency +identities into deduplicated watch requirements), *how* to fetch/normalize/ +fingerprint/diff (`watchers.adapters.*`, `watchers.diff`). +Cloud: *when* (Celery beat tick, due-source selection, jitter, retry/backoff, +per-host rate limiting, cursor persistence, failure visibility). + +## 5. Public vs private, per component + +| Component | Repo | +|---|---| +| Campaign domain, state machines, org blast radius, freshness, dossier, internal-contract identity, orchestration, CLI/demo | public | +| Watcher interface, source adapters, normalization, diff, watch-requirement aggregation | public | +| Beat schedule, source rows/cursors, due selection, retries, rate limits | Cloud | +| Workspace watch/verification/publication policy | Cloud (enforced by a *public* pure gate function so the semantics are inspectable) | +| Installation tokens, GitHub App credentials, executing the PR side effect | Cloud | +| Dashboard, usage counters, notifications/outbox | Cloud | +| Fake GitHub, fake fetcher, E2E fixtures | public (Cloud tests import them via the pinned dependency) | + +## 6. Obsolete / redundant after the pivot (noted, not deleted here) + +- The generic PR-review pipeline remains a secondary safety layer; untouched. +- Cloud's dashboard center of gravity (review history) is demoted, not removed. +- Cloud pins engine `989567c`; M4-M9 are not reachable until the pin moves. + Bumping it is a deliberate, explained change in the Cloud PR. + +## 7. Hard constraints discovered during the audit + +1. M7/M8 work on a **local checkout**. In Cloud, a worker must acquire a + snapshot per repo (installation token -> `RepositorySnapshotProvider`) + before analysis. Tokens must never enter a job payload. +2. M8 needs a bwrap sandbox. Cloud workers without it can only reach + `UNVERIFIED`/`HUMAN_REQUIRED`; this must be reported, never papered over. +3. Cloud and the engine share one Postgres with separate Alembic chains; + Cloud tables reference engine rows only by GitHub ids. +4. Both repos' CI currently expand a disposable private key through workflow + `env`, which prints it in job logs. Fixing this is mandatory (M11.13). +5. Production Postgres volume was filled and production is frozen. This task + makes the code/deploy reproducible and documents a restore plan; it does + not touch production. + +## 8. Planned public package layout + +``` +patchfrog/campaigns/ domain, freshness, graph, blast, internal, state, + evaluate, orchestrate, dossier, store, report +patchfrog/watchers/ domain, fetch, adapters/{pypi,npm,github_releases, + openapi_url,changelog_feed,manual}, diff, registry, + gate (policy), pipeline +``` + +## 9. Version constants + +New: `CAMPAIGN_ENGINE_VERSION = 1`, `WATCHER_ENGINE_VERSION = 1`. +Not bumped: review/prompt/telemetry/quality-cost versions (no review-path +change). `UPSTREAM_CHANGE_VERSION`, `MIGRATION_PR_VERSION`, M8 versions +unchanged -- M10/M11 compose them without changing their semantics. + +## 10. False-positive / cost discipline + +No candidate, no provider call. Every M10/M11 step is deterministic; the +campaign/watcher packages must never import a model provider (structural test, +same pattern as `test_*_never_imports_a_provider`). A watcher change that does +not reach a consumer produces `NOT_AFFECTED`, never a finding. From 5d3d4dd14b545ee28e7d1c609d9c3b8f36b0b6c6 Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 05:22:10 +0300 Subject: [PATCH 02/17] M10: organization blast radius, freshness semantics, workspace policy gate Uncertainty is never rounded to safe: stale, unknown, access-lost and version-level-only repositories are UNKNOWN and keep the organization unsafe. The pure publication gate (watch mode / verification requirement / publication mode) lives here so its semantics are inspectable in the public engine. --- patchfrog/campaigns/blast.py | 79 +++++++++++ patchfrog/campaigns/freshness.py | 50 +++++++ patchfrog/campaigns/policy.py | 114 ++++++++++++++++ ...test_campaign_freshness_policy_internal.py | 127 ++++++++++++++++++ 4 files changed, 370 insertions(+) create mode 100644 patchfrog/campaigns/blast.py create mode 100644 patchfrog/campaigns/freshness.py create mode 100644 patchfrog/campaigns/policy.py create mode 100644 tests/unit/test_campaign_freshness_policy_internal.py diff --git a/patchfrog/campaigns/blast.py b/patchfrog/campaigns/blast.py new file mode 100644 index 0000000..3414c81 --- /dev/null +++ b/patchfrog/campaigns/blast.py @@ -0,0 +1,79 @@ +"""Organization blast radius (M10.2). + +Turns per-repository M6 impact + freshness into one organization-level +classification: affected / not affected / unknown. The decisive rule is +that **uncertainty is never rounded to "safe"**: a repository with stale, +missing or lost discovery, or with only version-level evidence, is +``UNKNOWN`` and keeps :attr:`OrgBlastRadius.safe` false. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from patchfrog.campaigns.domain import ( + Freshness, + OrgBlastRadius, + OrgClass, + UnknownReason, +) +from patchfrog.upstream.workspace import RepositoryImpact, RepositoryImpactStatus + + +@dataclass(frozen=True, slots=True) +class RepositoryClassification: + repository: str + org_class: OrgClass + unknown_reason: UnknownReason | None + reason: str + #: A matching dependency exists in the repository (used or not reached). + has_matching_dependency: bool + + +_UNKNOWN_BY_FRESHNESS = { + Freshness.ACCESS_LOST: (UnknownReason.ACCESS_LOST, "repository access was lost; cannot claim it is unaffected"), + Freshness.STALE: ( + UnknownReason.STALE_DISCOVERY, "dependency discovery is older than the freshness window; rediscovery required", + ), + Freshness.UNKNOWN: (UnknownReason.NO_DISCOVERY, "no successful dependency discovery exists for this repository"), +} + + +def classify_repository( + repository: str, *, freshness: Freshness, impact: RepositoryImpact | None +) -> RepositoryClassification: + if freshness in _UNKNOWN_BY_FRESHNESS: + reason_code, reason = _UNKNOWN_BY_FRESHNESS[freshness] + return RepositoryClassification(repository, OrgClass.UNKNOWN, reason_code, reason, False) + if impact is None: + return RepositoryClassification( + repository, OrgClass.UNKNOWN, UnknownReason.NO_DISCOVERY, "no dependency inventory was available", False + ) + matching = bool(impact.matches) + if impact.status is RepositoryImpactStatus.AFFECTED: + return RepositoryClassification(repository, OrgClass.AFFECTED, None, impact.reason, matching) + if impact.status is RepositoryImpactStatus.UNCERTAIN: + return RepositoryClassification( + repository, OrgClass.UNKNOWN, UnknownReason.INSUFFICIENT_EVIDENCE, impact.reason, matching + ) + return RepositoryClassification(repository, OrgClass.NOT_AFFECTED, None, impact.reason, matching) + + +def build_org_blast_radius(classifications: Sequence[RepositoryClassification]) -> OrgBlastRadius: + ordered = sorted(classifications, key=lambda c: c.repository) + affected = tuple(c.repository for c in ordered if c.org_class is OrgClass.AFFECTED) + unknown = tuple(c.repository for c in ordered if c.org_class is OrgClass.UNKNOWN) + not_affected = tuple(c.repository for c in ordered if c.org_class is OrgClass.NOT_AFFECTED) + relevant = {c.repository for c in ordered if c.org_class is not OrgClass.NOT_AFFECTED or c.has_matching_dependency} + return OrgBlastRadius( + total_enrolled=len(ordered), + potentially_relevant=len(relevant), + affected=affected, + not_affected=not_affected, + unknown=unknown, + unknown_reasons={c.repository: c.unknown_reason.value for c in ordered if c.unknown_reason is not None}, + ) + + +__all__ = ["RepositoryClassification", "build_org_blast_radius", "classify_repository"] diff --git a/patchfrog/campaigns/freshness.py b/patchfrog/campaigns/freshness.py new file mode 100644 index 0000000..cfef502 --- /dev/null +++ b/patchfrog/campaigns/freshness.py @@ -0,0 +1,50 @@ +"""Data-freshness semantics for campaigns (M10.7). + +A blast radius computed from dependency discovery that is old, missing or +no longer reachable must say so. Nothing here reads a clock on its own: +``now`` is always passed in, so every decision is reproducible. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from patchfrog.campaigns.domain import EnrolledRepository, Freshness, RepositoryAccess + +DEFAULT_FRESHNESS_WINDOW = timedelta(days=7) +#: A discovery timestamp this far in the future is a clock/record fault, +#: not evidence of freshness. +CLOCK_SKEW_TOLERANCE = timedelta(minutes=5) + + +@dataclass(frozen=True, slots=True) +class FreshnessPolicy: + window: timedelta = DEFAULT_FRESHNESS_WINDOW + + def __post_init__(self) -> None: + if self.window <= timedelta(0): + raise ValueError("freshness window must be positive") + + +def _aware(value: datetime) -> datetime: + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def assess_freshness( + repository: EnrolledRepository, *, now: datetime, policy: FreshnessPolicy | None = None +) -> Freshness: + policy = policy or FreshnessPolicy() + if repository.access is RepositoryAccess.ACCESS_LOST: + return Freshness.ACCESS_LOST + if repository.last_discovery_at is None: + return Freshness.UNKNOWN + discovered, current = _aware(repository.last_discovery_at), _aware(now) + if discovered - current > CLOCK_SKEW_TOLERANCE: + return Freshness.UNKNOWN + if current - discovered > policy.window: + return Freshness.STALE + return Freshness.FRESH + + +__all__ = ["CLOCK_SKEW_TOLERANCE", "DEFAULT_FRESHNESS_WINDOW", "FreshnessPolicy", "assess_freshness"] diff --git a/patchfrog/campaigns/policy.py b/patchfrog/campaigns/policy.py new file mode 100644 index 0000000..07e1c81 --- /dev/null +++ b/patchfrog/campaigns/policy.py @@ -0,0 +1,114 @@ +"""Workspace compatibility policy -- the pure gate (M11.7). + +The *semantics* of what a workspace allows live here, in the public engine, +so they are inspectable and testable. Storing a workspace's chosen values +and enforcing them at job time is a hosted-service concern (Cloud). + +There are no silent unsafe defaults: an unconfigured workspace detects and +reports, requires fully ``VERIFIED`` evidence, and never publishes without a +human approving it. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from patchfrog.migration_pr.domain import MigrationPREligibility, MigrationPRPolicy + + +class WatchMode(StrEnum): + OFF = "off" + DETECT_ONLY = "detect_only" + MIGRATE = "migrate" + MIGRATE_AND_OPEN_PR = "migrate_and_open_pr" + + +class VerificationRequirement(StrEnum): + REQUIRE_VERIFIED = "require_verified" + #: Explicit opt-in only. A PR opened on this basis is always a draft. + ALLOW_PARTIALLY_VERIFIED = "allow_partially_verified" + + +class PublicationMode(StrEnum): + AUTOMATIC = "automatic" + DRAFT_ONLY = "draft_only" + MANUAL_APPROVAL = "manual_approval" + + +class PublicationAction(StrEnum): + NONE = "none" + PUBLISH = "publish" + PUBLISH_DRAFT = "publish_draft" + AWAIT_APPROVAL = "await_approval" + + +@dataclass(frozen=True, slots=True) +class WorkspacePolicy: + watch_mode: WatchMode = WatchMode.DETECT_ONLY + verification: VerificationRequirement = VerificationRequirement.REQUIRE_VERIFIED + publication: PublicationMode = PublicationMode.MANUAL_APPROVAL + + @property + def analyzes(self) -> bool: + return self.watch_mode is not WatchMode.OFF + + @property + def migrates(self) -> bool: + return self.watch_mode in (WatchMode.MIGRATE, WatchMode.MIGRATE_AND_OPEN_PR) + + @property + def may_publish(self) -> bool: + return self.watch_mode is WatchMode.MIGRATE_AND_OPEN_PR + + def pr_policy(self) -> MigrationPRPolicy: + return MigrationPRPolicy( + allow_partially_verified=self.verification is VerificationRequirement.ALLOW_PARTIALLY_VERIFIED + ) + + +@dataclass(frozen=True, slots=True) +class PublicationDecision: + action: PublicationAction + reason: str + + @property + def writes_to_github(self) -> bool: + return self.action in (PublicationAction.PUBLISH, PublicationAction.PUBLISH_DRAFT) + + @property + def draft(self) -> bool: + return self.action is PublicationAction.PUBLISH_DRAFT + + +def decide_publication(policy: WorkspacePolicy, eligibility: MigrationPREligibility) -> PublicationDecision: + """``eligibility`` must have been computed with :meth:`WorkspacePolicy.pr_policy`; + this function only layers the workspace's *publication* choices on top.""" + + if not policy.may_publish: + return PublicationDecision(PublicationAction.NONE, f"watch mode is {policy.watch_mode.value}: no PR is opened") + if eligibility is MigrationPREligibility.PLAN_ONLY: + return PublicationDecision(PublicationAction.NONE, "human-required migration: report only, never an automatic PR") + if eligibility is MigrationPREligibility.NOT_ELIGIBLE: + return PublicationDecision(PublicationAction.NONE, "verification did not meet the workspace requirement") + partial = eligibility is MigrationPREligibility.OPEN_WITH_OPERATOR_APPROVAL + if policy.publication is PublicationMode.MANUAL_APPROVAL: + return PublicationDecision(PublicationAction.AWAIT_APPROVAL, "workspace requires manual approval before a PR is opened") + if policy.publication is PublicationMode.DRAFT_ONLY: + return PublicationDecision(PublicationAction.PUBLISH_DRAFT, "workspace publishes draft PRs only") + if partial: + return PublicationDecision( + PublicationAction.PUBLISH_DRAFT, "partially verified: opened as a draft even under automatic publication" + ) + return PublicationDecision(PublicationAction.PUBLISH, "verified migration; workspace allows automatic publication") + + +__all__ = [ + "PublicationAction", + "PublicationDecision", + "PublicationMode", + "VerificationRequirement", + "WatchMode", + "WorkspacePolicy", + "decide_publication", +] diff --git a/tests/unit/test_campaign_freshness_policy_internal.py b/tests/unit/test_campaign_freshness_policy_internal.py new file mode 100644 index 0000000..ac20fca --- /dev/null +++ b/tests/unit/test_campaign_freshness_policy_internal.py @@ -0,0 +1,127 @@ +"""M10.5/M10.7/M11.7: freshness windows, the publication gate, internal-contract identity.""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest + +from patchfrog.campaigns.domain import EnrolledRepository, Freshness, RepositoryAccess +from patchfrog.campaigns.freshness import FreshnessPolicy, assess_freshness +from patchfrog.campaigns.internal import ( + InternalContract, + InternalContractError, + InternalContractKind, + contract_target, +) +from patchfrog.campaigns.policy import ( + PublicationAction, + PublicationMode, + VerificationRequirement, + WatchMode, + WorkspacePolicy, + decide_publication, +) +from patchfrog.dependencies.domain import Ecosystem +from patchfrog.migration_pr.domain import MigrationPREligibility +from tests.support.campaigns import NOW + + +def _repo(**kw: object) -> EnrolledRepository: + return EnrolledRepository(full_name="o/r", **kw) # type: ignore[arg-type] + + +def test_freshness_states() -> None: + assert assess_freshness(_repo(last_discovery_at=NOW - timedelta(hours=1)), now=NOW) is Freshness.FRESH + assert assess_freshness(_repo(last_discovery_at=NOW - timedelta(days=8)), now=NOW) is Freshness.STALE + assert assess_freshness(_repo(), now=NOW) is Freshness.UNKNOWN + lost = _repo(access=RepositoryAccess.ACCESS_LOST, last_discovery_at=NOW) + assert assess_freshness(lost, now=NOW) is Freshness.ACCESS_LOST + + +def test_freshness_window_is_configurable_and_boundary_is_inclusive() -> None: + repo = _repo(last_discovery_at=NOW - timedelta(hours=5)) + assert assess_freshness(repo, now=NOW, policy=FreshnessPolicy(window=timedelta(hours=4))) is Freshness.STALE + assert assess_freshness(repo, now=NOW, policy=FreshnessPolicy(window=timedelta(hours=5))) is Freshness.FRESH + with pytest.raises(ValueError): + FreshnessPolicy(window=timedelta(0)) + + +def test_future_discovery_timestamp_is_never_treated_as_fresh() -> None: + assert assess_freshness(_repo(last_discovery_at=NOW + timedelta(days=1)), now=NOW) is Freshness.UNKNOWN + + +def test_naive_timestamps_are_treated_as_utc() -> None: + naive = (NOW - timedelta(hours=1)).replace(tzinfo=None) + assert assess_freshness(_repo(last_discovery_at=naive), now=NOW) is Freshness.FRESH + + +E = MigrationPREligibility + + +def test_safe_defaults_detect_and_never_publish() -> None: + policy = WorkspacePolicy() + assert policy.watch_mode is WatchMode.DETECT_ONLY + assert policy.verification is VerificationRequirement.REQUIRE_VERIFIED + assert policy.publication is PublicationMode.MANUAL_APPROVAL + assert not policy.migrates and not policy.may_publish + assert decide_publication(policy, E.AUTO_OPEN).action is PublicationAction.NONE + assert not policy.pr_policy().allow_partially_verified + + +def test_off_mode_analyzes_nothing() -> None: + assert not WorkspacePolicy(watch_mode=WatchMode.OFF).analyzes + + +@pytest.mark.parametrize( + ("mode", "eligibility", "expected"), + [ + (PublicationMode.AUTOMATIC, E.AUTO_OPEN, PublicationAction.PUBLISH), + (PublicationMode.DRAFT_ONLY, E.AUTO_OPEN, PublicationAction.PUBLISH_DRAFT), + (PublicationMode.MANUAL_APPROVAL, E.AUTO_OPEN, PublicationAction.AWAIT_APPROVAL), + # a partially verified PR is a draft even under automatic publication + (PublicationMode.AUTOMATIC, E.OPEN_WITH_OPERATOR_APPROVAL, PublicationAction.PUBLISH_DRAFT), + (PublicationMode.MANUAL_APPROVAL, E.OPEN_WITH_OPERATOR_APPROVAL, PublicationAction.AWAIT_APPROVAL), + # human-required and not-eligible never publish, whatever the mode + (PublicationMode.AUTOMATIC, E.PLAN_ONLY, PublicationAction.NONE), + (PublicationMode.AUTOMATIC, E.NOT_ELIGIBLE, PublicationAction.NONE), + ], +) +def test_publication_gate(mode: PublicationMode, eligibility: MigrationPREligibility, expected: PublicationAction) -> None: + policy = WorkspacePolicy(watch_mode=WatchMode.MIGRATE_AND_OPEN_PR, publication=mode) + assert decide_publication(policy, eligibility).action is expected + + +def test_migrate_mode_never_publishes() -> None: + policy = WorkspacePolicy(watch_mode=WatchMode.MIGRATE, publication=PublicationMode.AUTOMATIC) + assert policy.migrates and not policy.may_publish + assert decide_publication(policy, E.AUTO_OPEN).action is PublicationAction.NONE + + +def test_partially_verified_requires_explicit_opt_in() -> None: + policy = WorkspacePolicy(verification=VerificationRequirement.ALLOW_PARTIALLY_VERIFIED) + assert policy.pr_policy().allow_partially_verified + + +def test_internal_contract_needs_explicit_identity() -> None: + with pytest.raises(InternalContractError, match="no explicit identity"): + InternalContract("c", InternalContractKind.PACKAGE, "org/lib", contract_target(display_name="looks-related")) + with pytest.raises(InternalContractError): + InternalContract("", InternalContractKind.PACKAGE, "org/lib", + contract_target(package_name="x", ecosystem=Ecosystem.PYPI)) + with pytest.raises(InternalContractError): + InternalContract("c", InternalContractKind.PACKAGE, "", + contract_target(package_name="x", ecosystem=Ecosystem.PYPI)) + # a package name without an ecosystem is not an identity either + with pytest.raises(InternalContractError): + InternalContract("c", InternalContractKind.PACKAGE, "org/lib", contract_target(package_name="x")) + + +def test_internal_contract_identity_forms_are_accepted_and_never_use_provider_key() -> None: + for target in ( + contract_target(package_name="acme-shared", ecosystem=Ecosystem.PYPI), + contract_target(api_hosts=["api.internal.example"]), + contract_target(dependency_key="acme-shared:pypi"), + ): + contract = InternalContract("c", InternalContractKind.OPENAPI, "org/lib", target) + assert contract.target.provider_key is None From a078a7a65aba82a0712e37fbfe1b6f3a4f4e4a27 Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 05:22:24 +0300 Subject: [PATCH 03/17] M10: cross-repo orchestration, campaign persistence, dossier, draft PR support Composes the existing M6->M9 functions per repository with structural isolation (a failure is that repository's FAILED record, never another's), independent per-repo publication under workspace policy, idempotent re-runs, and reconciliation to RESOLVED only through fresh re-evaluation. Adds Alembic 0038, the CampaignStore, the machine-readable dossier, and a backward-compatible draft flag through the PR publisher/client/fake. Acme + internal-contract fixtures and the end-to-end suite (real sandbox verification, fake GitHub). --- .../versions/0038_compatibility_campaigns.py | 93 ++++ patchfrog/campaigns/dossier.py | 166 ++++++ patchfrog/campaigns/evaluate.py | 285 ++++++++++ patchfrog/campaigns/orchestrate.py | 249 +++++++++ patchfrog/campaigns/store.py | 228 ++++++++ patchfrog/github/client.py | 7 +- patchfrog/migration_pr/fake_github.py | 5 +- patchfrog/migration_pr/github_publisher.py | 6 +- patchfrog/migration_pr/publisher.py | 4 +- patchfrog/persistence/models/__init__.py | 6 + patchfrog/persistence/models/campaign.py | 98 ++++ tests/fixtures/campaigns/acme/README.md | 15 + .../campaigns/acme/contract/hints.yaml | 8 + .../fixtures/campaigns/acme/contract/new.yaml | 18 + .../fixtures/campaigns/acme/contract/old.yaml | 19 + .../acme/repos/acme-legacy/legacy/__init__.py | 0 .../acme/repos/acme-legacy/legacy/replies.py | 9 + .../acme/repos/acme-legacy/requirements.txt | 1 + .../repos/acme-legacy/tests/test_replies.py | 5 + .../acme/repos/acme-search/requirements.txt | 1 + .../acme/repos/acme-search/search/__init__.py | 0 .../acme/repos/acme-search/search/index.py | 9 + .../repos/acme-search/tests/test_index.py | 5 + .../acme/repos/acme-web/app/__init__.py | 0 .../acme/repos/acme-web/app/ai/__init__.py | 0 .../acme/repos/acme-web/app/ai/chat.py | 10 + .../acme/repos/acme-web/requirements.txt | 2 + .../acme/repos/acme-web/tests/test_chat.py | 5 + .../acme/repos/acme-worker/app/__init__.py | 0 .../acme/repos/acme-worker/app/summary.py | 10 + .../acme/repos/acme-worker/requirements.txt | 1 + .../repos/acme-worker/tests/test_summary.py | 5 + .../acme/sdk_stub/acme_ai/__init__.py | 50 ++ tests/fixtures/campaigns/internal/README.md | 6 + .../campaigns/internal/contract/new.yaml | 13 + .../campaigns/internal/contract/old.yaml | 13 + .../repos/acme-shared-fork/fork/__init__.py | 0 .../repos/acme-shared-fork/fork/notes.py | 5 + .../platform-lib/acme_shared/__init__.py | 1 + .../repos/platform-lib/pyproject.toml | 3 + .../repos/svc-billing/billing/__init__.py | 0 .../repos/svc-billing/billing/client.py | 7 + .../repos/svc-billing/requirements.txt | 1 + .../repos/svc-orders/orders/__init__.py | 0 .../repos/svc-orders/orders/client.py | 7 + .../repos/svc-orders/requirements.txt | 1 + .../repos/svc-reports/reports/__init__.py | 0 .../repos/svc-reports/reports/health.py | 7 + .../repos/svc-reports/requirements.txt | 1 + .../test_campaign_orchestration.py | 505 ++++++++++++++++++ tests/support/campaigns.py | 58 ++ 51 files changed, 1940 insertions(+), 8 deletions(-) create mode 100644 migrations/versions/0038_compatibility_campaigns.py create mode 100644 patchfrog/campaigns/dossier.py create mode 100644 patchfrog/campaigns/evaluate.py create mode 100644 patchfrog/campaigns/orchestrate.py create mode 100644 patchfrog/campaigns/store.py create mode 100644 patchfrog/persistence/models/campaign.py create mode 100644 tests/fixtures/campaigns/acme/README.md create mode 100644 tests/fixtures/campaigns/acme/contract/hints.yaml create mode 100644 tests/fixtures/campaigns/acme/contract/new.yaml create mode 100644 tests/fixtures/campaigns/acme/contract/old.yaml create mode 100644 tests/fixtures/campaigns/acme/repos/acme-legacy/legacy/__init__.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-legacy/legacy/replies.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-legacy/requirements.txt create mode 100644 tests/fixtures/campaigns/acme/repos/acme-legacy/tests/test_replies.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-search/requirements.txt create mode 100644 tests/fixtures/campaigns/acme/repos/acme-search/search/__init__.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-search/search/index.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-search/tests/test_index.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-web/app/__init__.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-web/app/ai/__init__.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-web/app/ai/chat.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-web/requirements.txt create mode 100644 tests/fixtures/campaigns/acme/repos/acme-web/tests/test_chat.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-worker/app/__init__.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-worker/app/summary.py create mode 100644 tests/fixtures/campaigns/acme/repos/acme-worker/requirements.txt create mode 100644 tests/fixtures/campaigns/acme/repos/acme-worker/tests/test_summary.py create mode 100644 tests/fixtures/campaigns/acme/sdk_stub/acme_ai/__init__.py create mode 100644 tests/fixtures/campaigns/internal/README.md create mode 100644 tests/fixtures/campaigns/internal/contract/new.yaml create mode 100644 tests/fixtures/campaigns/internal/contract/old.yaml create mode 100644 tests/fixtures/campaigns/internal/repos/acme-shared-fork/fork/__init__.py create mode 100644 tests/fixtures/campaigns/internal/repos/acme-shared-fork/fork/notes.py create mode 100644 tests/fixtures/campaigns/internal/repos/platform-lib/acme_shared/__init__.py create mode 100644 tests/fixtures/campaigns/internal/repos/platform-lib/pyproject.toml create mode 100644 tests/fixtures/campaigns/internal/repos/svc-billing/billing/__init__.py create mode 100644 tests/fixtures/campaigns/internal/repos/svc-billing/billing/client.py create mode 100644 tests/fixtures/campaigns/internal/repos/svc-billing/requirements.txt create mode 100644 tests/fixtures/campaigns/internal/repos/svc-orders/orders/__init__.py create mode 100644 tests/fixtures/campaigns/internal/repos/svc-orders/orders/client.py create mode 100644 tests/fixtures/campaigns/internal/repos/svc-orders/requirements.txt create mode 100644 tests/fixtures/campaigns/internal/repos/svc-reports/reports/__init__.py create mode 100644 tests/fixtures/campaigns/internal/repos/svc-reports/reports/health.py create mode 100644 tests/fixtures/campaigns/internal/repos/svc-reports/requirements.txt create mode 100644 tests/integration/test_campaign_orchestration.py create mode 100644 tests/support/campaigns.py diff --git a/migrations/versions/0038_compatibility_campaigns.py b/migrations/versions/0038_compatibility_campaigns.py new file mode 100644 index 0000000..a20a825 --- /dev/null +++ b/migrations/versions/0038_compatibility_campaigns.py @@ -0,0 +1,93 @@ +"""M10 compatibility campaigns + +Revision ID: 0038_compatibility_campaigns +Revises: 0037_migration_pull_requests +Create Date: 2026-10-02 + +""" +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0038_compatibility_campaigns" +down_revision: str | None = "0037_migration_pull_requests" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "compatibility_campaigns", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("identity_key", sa.String(length=64), nullable=False), + sa.Column("workspace_key", sa.String(length=255), nullable=False), + sa.Column("change_fingerprint", sa.String(length=64), nullable=False), + sa.Column("engine_version", sa.Integer(), nullable=False), + sa.Column("dependency_label", sa.String(length=255), nullable=False), + sa.Column("provider_key", sa.String(length=255), nullable=True), + sa.Column("origin", sa.String(length=16), nullable=False), + sa.Column("producer_repository", sa.String(length=512), nullable=True), + sa.Column("old_version", sa.String(length=128), nullable=True), + sa.Column("new_version", sa.String(length=128), nullable=True), + sa.Column("compatibility", sa.String(length=32), nullable=True), + sa.Column("state", sa.String(length=24), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("blast_json", sa.Text(), nullable=False), + sa.Column("unresolved_risks_json", sa.Text(), nullable=False), + sa.Column("discovered_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("identity_key", name="uq_compatibility_campaigns_identity"), + ) + op.create_index("ix_compatibility_campaigns_workspace", "compatibility_campaigns", ["workspace_key", "state"]) + op.create_table( + "campaign_repository_records", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column( + "campaign_id", sa.Uuid(), sa.ForeignKey("compatibility_campaigns.id", ondelete="CASCADE"), nullable=False + ), + sa.Column("repository", sa.String(length=512), nullable=False), + sa.Column("state", sa.String(length=24), nullable=False), + sa.Column("org_class", sa.String(length=16), nullable=False), + sa.Column("freshness", sa.String(length=16), nullable=False), + sa.Column("unknown_reason", sa.String(length=32), nullable=True), + sa.Column("impact_status", sa.String(length=16), nullable=True), + sa.Column("direct_consumers", sa.Integer(), nullable=False), + sa.Column("transitive_consumers", sa.Integer(), nullable=False), + sa.Column("potential_consumers", sa.Integer(), nullable=False), + sa.Column("severity", sa.String(length=32), nullable=False), + sa.Column("migration_status", sa.String(length=24), nullable=True), + sa.Column("migration_strategy_available", sa.Boolean(), nullable=False), + sa.Column("verification_feasibility", sa.String(length=24), nullable=False), + sa.Column("verification_outcome", sa.String(length=24), nullable=True), + sa.Column("publication_readiness", sa.String(length=32), nullable=True), + sa.Column("base_commit_sha", sa.String(length=64), nullable=True), + sa.Column("plan_fingerprint", sa.String(length=64), nullable=True), + sa.Column("patch_fingerprint", sa.String(length=64), nullable=True), + sa.Column("bundle_fingerprint", sa.String(length=64), nullable=True), + sa.Column("pr_status", sa.String(length=32), nullable=True), + sa.Column("pr_number", sa.Integer(), nullable=True), + sa.Column("pr_url", sa.String(length=1024), nullable=True), + sa.Column("residual_risk", sa.String(length=16), nullable=True), + sa.Column("ever_affected", sa.Boolean(), nullable=False), + sa.Column("reasons_json", sa.Text(), nullable=False), + sa.Column("human_actions_json", sa.Text(), nullable=False), + sa.Column("error", sa.String(length=512), nullable=True), + sa.Column("evaluated_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("campaign_id", "repository", name="uq_campaign_repository_records_campaign_repo"), + ) + op.create_index("ix_campaign_repository_records_campaign_id", "campaign_repository_records", ["campaign_id"]) + op.create_index( + "ix_campaign_repository_records_repository", "campaign_repository_records", ["repository", "state"] + ) + + +def downgrade() -> None: + op.drop_index("ix_campaign_repository_records_repository", table_name="campaign_repository_records") + op.drop_index("ix_campaign_repository_records_campaign_id", table_name="campaign_repository_records") + op.drop_table("campaign_repository_records") + op.drop_index("ix_compatibility_campaigns_workspace", table_name="compatibility_campaigns") + op.drop_table("compatibility_campaigns") diff --git a/patchfrog/campaigns/dossier.py b/patchfrog/campaigns/dossier.py new file mode 100644 index 0000000..02561c6 --- /dev/null +++ b/patchfrog/campaigns/dossier.py @@ -0,0 +1,166 @@ +"""Campaign-level evidence dossier (M10.8). + +A machine-readable dict plus a markdown rendering, both derived from a +:class:`CompatibilityCampaign` alone (so they can be rebuilt from persisted +state). Nothing here reads source code, logs or secrets: every value is a +bounded, controlled field already on the campaign. +""" + +from __future__ import annotations + +from typing import Any + +from patchfrog.campaigns.domain import ( + CompatibilityCampaign, + Freshness, + OrgClass, + RepositoryRecord, + RepoState, +) + +DOSSIER_VERSION = 1 + + +def next_action(record: RepositoryRecord) -> str | None: + """The single most useful human/operator action for a repository, or + ``None`` when nothing is needed.""" + + state = record.state + if state in (RepoState.NOT_AFFECTED, RepoState.RESOLVED): + return None + if state is RepoState.ACCESS_LOST: + return "restore repository access under the GitHub App installation, then re-run discovery" + if state is RepoState.STALE: + return "re-run dependency discovery: evidence is older than the freshness window" + if state is RepoState.UNKNOWN: + if record.requires_rediscovery: + return "run dependency discovery: no usable evidence exists for this repository" + return "manual review: only version-level or indirect evidence reaches the changed contract" + if state is RepoState.IMPACTED: + return "affected, but no migration was generated: enable migration in the workspace policy or provide a checkout" + if state is RepoState.HUMAN_REQUIRED: + return record.human_actions[0] if record.human_actions else "a human decision is required for this migration" + if state is RepoState.FAILED: + return "investigate the failure" + (f": {record.error}" if record.error else "") + if state is RepoState.UNVERIFIED: + return "verification produced insufficient evidence; do not merge without manual checks" + if state is RepoState.PARTIALLY_VERIFIED: + return "verification was partial; review the evidence before opening or merging a PR" + if state is RepoState.VERIFIED: + return "approve opening the verified migration PR" + if state is RepoState.PR_OPENED: + return f"review and merge PR #{record.pr_number} (PatchFrog never merges automatically)" + return "migration in progress" + + +def _record_dict(record: RepositoryRecord) -> dict[str, Any]: + return { + "repository": record.repository, + "state": record.state.value, + "classification": record.org_class.value, + "freshness": record.freshness.value, + "unknown_reason": record.unknown_reason.value if record.unknown_reason else None, + "requires_rediscovery": record.requires_rediscovery, + "impact": { + "status": record.impact_status, "direct": record.direct_consumers, + "transitive": record.transitive_consumers, "potential": record.potential_consumers, + "severity": record.severity, + }, + "migration": { + "status": record.migration_status, "strategy_available": record.migration_strategy_available, + "plan_fingerprint": record.plan_fingerprint, "patch_fingerprint": record.patch_fingerprint, + }, + "verification": { + "outcome": record.verification_outcome, "feasibility": record.verification_feasibility, + "bundle_fingerprint": record.bundle_fingerprint, "residual_risk": record.residual_risk, + }, + "publication": { + "readiness": record.publication_readiness, "status": record.pr_status, "pr_number": record.pr_number, + "pr_url": record.pr_url, + }, + "base_commit_sha": record.base_commit_sha, + "reasons": list(record.reasons), + "human_actions": list(record.human_actions), + "next_action": next_action(record), + "error": record.error, + "evaluated_at": record.evaluated_at.isoformat() if record.evaluated_at else None, + } + + +def campaign_to_dict(campaign: CompatibilityCampaign) -> dict[str, Any]: + radius = campaign.org_blast_radius + return { + "dossier_version": DOSSIER_VERSION, + "campaign": { + "id": campaign.campaign_id, "workspace": campaign.workspace_key, "state": campaign.state.value, + "version": campaign.version, "engine_version": campaign.engine_version, + "discovered_at": campaign.discovered_at.isoformat(), + }, + "upstream_change": { + "fingerprint": campaign.change_fingerprint, "dependency": campaign.dependency_label, + "provider": campaign.provider_key, "origin": campaign.origin, + "producer_repository": campaign.producer_repository, "old_version": campaign.old_version, + "new_version": campaign.new_version, "compatibility": campaign.compatibility, + }, + "impact": { + **radius.summary(), "organization_safe": radius.safe, "affected": list(radius.affected), + "not_affected": list(radius.not_affected), "unknown": list(radius.unknown), + "unknown_reasons": dict(radius.unknown_reasons), + }, + "repositories": [_record_dict(r) for r in sorted(campaign.records, key=lambda r: r.repository)], + "state_counts": campaign.state_counts(), + "unresolved_risks": list(campaign.unresolved_risks), + "human_required_actions": [ + {"repository": r.repository, "action": a} + for r in sorted(campaign.records, key=lambda r: r.repository) + if r.state is RepoState.HUMAN_REQUIRED for a in (r.human_actions or ("a human decision is required",)) + ], + "data_freshness": { + r.repository: r.freshness.value for r in sorted(campaign.records, key=lambda r: r.repository) + }, + "pull_requests": [ + {"repository": r.repository, "number": r.pr_number, "url": r.pr_url, "status": r.pr_status} + for r in sorted(campaign.records, key=lambda r: r.repository) if r.pr_number is not None + ], + } + + +def render_campaign_markdown(campaign: CompatibilityCampaign) -> str: + radius = campaign.org_blast_radius + lines = [ + f"# Compatibility campaign: {campaign.dependency_label}", + "", + f"- **State:** `{campaign.state.value}` (v{campaign.version})", + f"- **Upstream change:** {campaign.old_version or '?'} -> {campaign.new_version or '?'} " + f"({campaign.compatibility or 'unclassified'}), fingerprint `{campaign.change_fingerprint[:12]}`", + f"- **Origin:** {campaign.origin}" + (f" (producer: {campaign.producer_repository})" if campaign.producer_repository else ""), + f"- **Organization:** {radius.total_enrolled} enrolled, {len(radius.affected)} affected, " + f"{len(radius.not_affected)} not affected, {len(radius.unknown)} unknown", + ] + if not radius.safe: + lines.append("- **The organization is NOT declared safe.**") + lines += ["", "## Repositories", "", "| Repository | State | Freshness | Verification | PR |", "|---|---|---|---|---|"] + for r in sorted(campaign.records, key=lambda x: x.repository): + pr = f"#{r.pr_number} ({r.pr_status})" if r.pr_number is not None else (r.pr_status or "-") + lines.append( + f"| {r.repository} | `{r.state.value}` | {r.freshness.value} | {r.verification_outcome or '-'} | {pr} |" + ) + actions = [(r.repository, next_action(r)) for r in sorted(campaign.records, key=lambda x: x.repository)] + actions = [(name, a) for name, a in actions if a] + if actions: + lines += ["", "## Required actions", ""] + lines += [f"- **{name}:** {action}" for name, action in actions] + if campaign.unresolved_risks: + lines += ["", "## Unresolved risk", ""] + lines += [f"- {risk}" for risk in campaign.unresolved_risks] + stale = [r.repository for r in campaign.records if r.freshness is not Freshness.FRESH] + if stale: + lines += ["", "## Data freshness", "", "Evidence for these repositories is not fresh and was not used to claim safety: " + + ", ".join(sorted(stale)) + "."] + unknown_unclassified = [r.repository for r in campaign.records if r.org_class is OrgClass.UNKNOWN] + if unknown_unclassified and not stale: + lines += ["", "Some repositories could not be classified from available evidence."] + return "\n".join(lines) + "\n" + + +__all__ = ["DOSSIER_VERSION", "campaign_to_dict", "next_action", "render_campaign_markdown"] diff --git a/patchfrog/campaigns/evaluate.py b/patchfrog/campaigns/evaluate.py new file mode 100644 index 0000000..c8a93ab --- /dev/null +++ b/patchfrog/campaigns/evaluate.py @@ -0,0 +1,285 @@ +"""Evaluate one repository against one upstream change (M10.4). + +This composes the existing M6 -> M7 -> M8 -> M9 functions; it adds no new +analysis. Its job is isolation and honesty: + +* a repository whose evidence is not fresh is **not analyzed at all** -- + stale evidence is never reused, and it is never reported as unaffected; +* any exception while evaluating one repository is captured on *that* + repository's record (state ``FAILED``) and never propagates to others; +* nothing is ever written to a checkout and no provider is ever called. +""" + +from __future__ import annotations + +import re +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass, field, replace +from datetime import datetime +from pathlib import Path +from typing import Any + +from patchfrog.campaigns.blast import RepositoryClassification, classify_repository +from patchfrog.campaigns.domain import ( + MAX_ERROR_CHARS, + EnrolledRepository, + Freshness, + OrgClass, + RepositoryRecord, + RepoState, + bound_reasons, +) +from patchfrog.campaigns.freshness import FreshnessPolicy, assess_freshness +from patchfrog.campaigns.policy import WorkspacePolicy +from patchfrog.campaigns.state import RepoFacts, derive_repo_state +from patchfrog.dependencies.domain import DependencyInventory +from patchfrog.migration.domain import GeneratedPatch, MigrationPlan +from patchfrog.migration.generator import generate_patch +from patchfrog.migration.planner import plan_migration +from patchfrog.migration_pr.domain import MigrationPRPlan +from patchfrog.migration_pr.planner import build_pr_plan +from patchfrog.migration_verification.domain import CheckStatus, MigrationEvidenceBundle +from patchfrog.migration_verification.service import run_migration_verification +from patchfrog.upstream.domain import CompatibilityClass, ExternalChangeEvent +from patchfrog.upstream.hints import EMPTY_HINTS, ChangeHints +from patchfrog.upstream.workspace import ( + RegistryDependency, + RepositoryImpact, + analyze_inventory, + analyze_repository, + registry_impact, +) + +VerifyFn = Callable[..., Awaitable[MigrationEvidenceBundle]] + +_CREDENTIAL_URL = re.compile(r"(https?://)[^/\s:@]+:[^/\s@]+@") +_TOKEN_SHAPES = re.compile(r"\b(gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_\-]{16,}|AIza[0-9A-Za-z_\-]{20,})\b") + + +def safe_error(exc: BaseException) -> str: + """A bounded, credential-scrubbed one-line description of an exception.""" + + text = f"{type(exc).__name__}: {exc}".replace("\n", " ") + text = _CREDENTIAL_URL.sub(r"\1***@", text) + text = _TOKEN_SHAPES.sub("***", text) + return text[:MAX_ERROR_CHARS] + + +@dataclass(frozen=True, slots=True) +class RepositoryInput: + """What the caller can supply for one enrolled repository. + + ``root`` is a checkout the caller acquired at ``commit_sha`` (Cloud does + this with an installation token; the CLI points at a local path). + Without it, only registry evidence can be used: impact can be + classified but no patch can be generated.""" + + repository: EnrolledRepository + root: Path | None = None + inventory: DependencyInventory | None = None + registry_rows: tuple[RegistryDependency, ...] = () + commit_sha: str | None = None + extra_workspace_paths: tuple[Path, ...] = () + #: The record from an earlier run of this campaign, if any. + previous: RepositoryRecord | None = None + + +@dataclass(frozen=True, slots=True) +class RepositoryEvaluation: + """The record plus the rich objects a publisher/dossier may need. Only + ``record`` is persisted.""" + + record: RepositoryRecord + classification: RepositoryClassification + impact: RepositoryImpact | None = None + inventory: DependencyInventory | None = None + plan: MigrationPlan | None = None + patch: GeneratedPatch | None = None + bundle: MigrationEvidenceBundle | None = None + pr_plan: MigrationPRPlan | None = None + human_actions: tuple[str, ...] = field(default_factory=tuple) + + +def _impact( + event: ExternalChangeEvent, entry: RepositoryInput, hints: ChangeHints +) -> tuple[RepositoryImpact | None, DependencyInventory | None]: + name = entry.repository.full_name + sha = entry.commit_sha or entry.repository.last_discovery_commit_sha + if entry.root is not None and entry.inventory is not None: + return analyze_inventory(entry.inventory, event, hints=hints, root=entry.root), entry.inventory + if entry.root is not None: + return analyze_repository(entry.root, event, hints=hints, repository=name, commit_sha=sha) + if entry.inventory is not None: + return analyze_inventory(entry.inventory, event, hints=hints), entry.inventory + if entry.registry_rows: + workspace = registry_impact(entry.registry_rows, event, hints=hints, repositories=[name]) + return (workspace.repositories[0] if workspace.repositories else None), None + return None, None + + +def _severity(event: ExternalChangeEvent, impact: RepositoryImpact) -> str: + if impact.direct_count > 0: + return event.classification.compatibility.value + if impact.potential_count > 0: + return CompatibilityClass.POTENTIALLY_BREAKING.value + return "none" + + +def _human_actions(plan: MigrationPlan) -> tuple[str, ...]: + return tuple( + f"{step.target.location}: {step.required_change}"[:300] for step in plan.unresolved_steps + )[:10] + + +def _base_record( + entry: RepositoryInput, *, freshness: Freshness, classification: RepositoryClassification, now: datetime +) -> RepositoryRecord: + previous = entry.previous + return RepositoryRecord( + repository=entry.repository.full_name, state=RepoState.UNKNOWN, org_class=classification.org_class, + freshness=freshness, unknown_reason=classification.unknown_reason, + base_commit_sha=entry.commit_sha or entry.repository.last_discovery_commit_sha, + ever_affected=bool(previous and previous.ever_affected), reasons=bound_reasons([classification.reason]), + evaluated_at=now, + ) + + +async def evaluate_repository( + event: ExternalChangeEvent, + entry: RepositoryInput, + *, + now: datetime, + policy: WorkspacePolicy | None = None, + freshness_policy: FreshnessPolicy | None = None, + hints: ChangeHints = EMPTY_HINTS, + verify: VerifyFn = run_migration_verification, +) -> RepositoryEvaluation: + policy = policy or WorkspacePolicy() + freshness = assess_freshness(entry.repository, now=now, policy=freshness_policy) + try: + return await _evaluate(event, entry, freshness=freshness, now=now, policy=policy, hints=hints, verify=verify) + except Exception as exc: + classification = RepositoryClassification( + entry.repository.full_name, OrgClass.UNKNOWN, None, "evaluation failed", False + ) + base = _base_record(entry, freshness=freshness, classification=classification, now=now) + record = _replace( + base, state=RepoState.FAILED, error=safe_error(exc), + reasons=bound_reasons(["evaluation raised an error; this repository is isolated and unresolved"]), + ) + return RepositoryEvaluation(record=record, classification=classification) + + +def _replace(record: RepositoryRecord, **changes: Any) -> RepositoryRecord: + return replace(record, **changes) + + +async def _evaluate( + event: ExternalChangeEvent, + entry: RepositoryInput, + *, + freshness: Freshness, + now: datetime, + policy: WorkspacePolicy, + hints: ChangeHints, + verify: VerifyFn, +) -> RepositoryEvaluation: + name = entry.repository.full_name + if freshness is not Freshness.FRESH: + classification = classify_repository(name, freshness=freshness, impact=None) + record = _base_record(entry, freshness=freshness, classification=classification, now=now) + state = derive_repo_state(RepoFacts(freshness=freshness, org_class=classification.org_class, + ever_affected=record.ever_affected)) + return RepositoryEvaluation(record=_replace(record, state=state), classification=classification) + + impact, inventory = _impact(event, entry, hints) + classification = classify_repository(name, freshness=freshness, impact=impact) + record = _base_record(entry, freshness=freshness, classification=classification, now=now) + if impact is not None: + transitive = sum(len(r.transitive) for r in impact.blast_radii) + record = _replace( + record, impact_status=impact.status.value, direct_consumers=impact.direct_count, + potential_consumers=impact.potential_count, transitive_consumers=transitive, + severity=_severity(event, impact), + base_commit_sha=impact.commit_sha or record.base_commit_sha, + ) + ever_affected = record.ever_affected or classification.org_class is OrgClass.AFFECTED + record = _replace(record, ever_affected=ever_affected) + + if classification.org_class is not OrgClass.AFFECTED: + state = derive_repo_state(RepoFacts(freshness=freshness, org_class=classification.org_class, + ever_affected=record.ever_affected)) + return RepositoryEvaluation( + record=_replace(record, state=state), classification=classification, impact=impact, inventory=inventory + ) + + assert impact is not None + facts = RepoFacts(freshness=freshness, org_class=OrgClass.AFFECTED, ever_affected=True) + if not policy.migrates or entry.root is None or inventory is None: + why = "watch policy is detect_only: no migration is generated" if not policy.migrates else ( + "no repository checkout was available; a migration cannot be generated from registry evidence alone" + ) + return RepositoryEvaluation( + record=_replace(record, state=derive_repo_state(facts), reasons=bound_reasons([*record.reasons, why])), + classification=classification, impact=impact, inventory=inventory, + ) + + plan = plan_migration(event, impact, inventory, hints=hints) + patch = generate_patch(plan, entry.root) if plan.automatic_steps else None + base_sha = record.base_commit_sha or "" + bundle = await verify( + plan=plan, patch=patch, blast_radii=impact.blast_radii, root=entry.root, commit_sha=base_sha, + extra_workspace_paths=entry.extra_workspace_paths, + ) + pr_plan = build_pr_plan( + event=event, plan=plan, patch=patch, bundle=bundle, blast_radii=impact.blast_radii, + repository=name, base_commit_sha=base_sha, policy=policy.pr_policy(), + ) + facts = RepoFacts( + freshness=freshness, org_class=OrgClass.AFFECTED, ever_affected=True, migration_status=plan.status.value, + has_automatic_steps=bool(plan.automatic_steps), patch_generated=patch is not None and patch.is_candidate, + verification_outcome=bundle.outcome.value, + ) + sandbox = any(e.status in (CheckStatus.PASSED, CheckStatus.FAILED) for e in bundle.step_evidence) + record = _replace( + record, state=derive_repo_state(facts), migration_status=plan.status.value, + migration_strategy_available=bool(plan.automatic_steps), + verification_feasibility="sandbox" if sandbox else "static_only", + verification_outcome=bundle.outcome.value, publication_readiness=pr_plan.eligibility.value, + plan_fingerprint=plan.fingerprint(""), patch_fingerprint=patch.fingerprint if patch else None, + bundle_fingerprint=bundle.bundle_fingerprint, residual_risk=bundle.residual_risk.value, + reasons=bound_reasons([*record.reasons, *bundle.outcome_reasons]), human_actions=_human_actions(plan), + ) + return RepositoryEvaluation( + record=record, classification=classification, impact=impact, inventory=inventory, plan=plan, patch=patch, + bundle=bundle, pr_plan=pr_plan, human_actions=_human_actions(plan), + ) + + +async def evaluate_all( + event: ExternalChangeEvent, + entries: Sequence[RepositoryInput], + *, + now: datetime, + policy: WorkspacePolicy | None = None, + freshness_policy: FreshnessPolicy | None = None, + hints: ChangeHints = EMPTY_HINTS, + verify: VerifyFn = run_migration_verification, +) -> tuple[RepositoryEvaluation, ...]: + results = [ + await evaluate_repository( + event, entry, now=now, policy=policy, freshness_policy=freshness_policy, hints=hints, verify=verify + ) + for entry in sorted(entries, key=lambda e: e.repository.full_name) + ] + return tuple(results) + + +__all__ = [ + "RepositoryEvaluation", + "RepositoryInput", + "VerifyFn", + "evaluate_all", + "evaluate_repository", + "safe_error", +] diff --git a/patchfrog/campaigns/orchestrate.py b/patchfrog/campaigns/orchestrate.py new file mode 100644 index 0000000..31c3e97 --- /dev/null +++ b/patchfrog/campaigns/orchestrate.py @@ -0,0 +1,249 @@ +"""Cross-repository migration orchestration (M10.4) -- pure, no persistence. + +``find enrolled repositories -> evaluate each (M6 -> M7 -> M8 -> M9 plan) -> +publish each independently under workspace policy -> aggregate into one +campaign``. + +Isolation is structural: every repository is evaluated and published inside +its own ``try``; a failure becomes that repository's ``FAILED`` record and +cannot touch any other. Each repository keeps its own base SHA, patch +fingerprint, verification evidence, PR and publication state. + +Re-running with the same inputs yields a campaign equal to the previous one +(same identity, same ``version``) -- no spam. Persistence is +:mod:`patchfrog.campaigns.store`'s job; hosted scheduling is Cloud's. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field, replace +from datetime import datetime +from typing import Protocol + +from patchfrog.campaigns.blast import build_org_blast_radius +from patchfrog.campaigns.domain import ( + BLOCKING_STATES, + CampaignState, + CompatibilityCampaign, + OrgClass, + RepositoryRecord, + RepoState, + bound_reasons, + campaign_identity_key, +) +from patchfrog.campaigns.evaluate import ( + RepositoryEvaluation, + RepositoryInput, + VerifyFn, + evaluate_all, + safe_error, +) +from patchfrog.campaigns.freshness import FreshnessPolicy +from patchfrog.campaigns.internal import InternalContract +from patchfrog.campaigns.policy import ( + PublicationAction, + PublicationDecision, + WorkspacePolicy, + decide_publication, +) +from patchfrog.campaigns.state import RepoFacts, derive_campaign_state, derive_repo_state +from patchfrog.migration.domain import GeneratedPatch +from patchfrog.migration_pr.domain import MigrationPRPlan, MigrationPullRequest +from patchfrog.migration_pr.publisher import MigrationPRPublicationMode, MigrationPRPublisher +from patchfrog.migration_verification.service import run_migration_verification +from patchfrog.upstream.domain import ExternalChangeEvent +from patchfrog.upstream.hints import EMPTY_HINTS, ChangeHints + + +class PullRequestPublisher(Protocol): + async def __call__( + self, *, plan: MigrationPRPlan, patch: GeneratedPatch | None, draft: bool + ) -> MigrationPullRequest: ... + + +def pr_publisher_from(publisher: MigrationPRPublisher, *, base_branch: str = "main") -> PullRequestPublisher: + async def publish(*, plan: MigrationPRPlan, patch: GeneratedPatch | None, draft: bool) -> MigrationPullRequest: + return await publisher.publish( + plan=plan, patch=patch, mode=MigrationPRPublicationMode.PUBLISH, base_branch=base_branch, draft=draft + ) + + return publish + + +@dataclass(frozen=True, slots=True) +class CampaignRunResult: + campaign: CompatibilityCampaign + evaluations: tuple[RepositoryEvaluation, ...] + decisions: Mapping[str, PublicationDecision] = field(default_factory=dict) + publications: Mapping[str, MigrationPullRequest] = field(default_factory=dict) + #: Repositories excluded from scope on purpose, with the reason. + excluded: Mapping[str, str] = field(default_factory=dict) + #: ``None`` for a first run; otherwise whether this run changed the campaign. + changed: bool = True + + +def _signature(records: Sequence[RepositoryRecord]) -> tuple[tuple[object, ...], ...]: + return tuple( + tuple(v for k, v in sorted(vars_of(r).items()) if k != "evaluated_at") + for r in sorted(records, key=lambda x: x.repository) + ) + + +def vars_of(record: RepositoryRecord) -> dict[str, object]: + values = {name: getattr(record, name) for name in record.__slots__} + # "opened" on the first run and "updated" on every identical re-run is the same fact: one open PR. + if values["pr_status"] == "updated": + values["pr_status"] = "opened" + return values + + +def _apply_publication(evaluation: RepositoryEvaluation, pr: MigrationPullRequest) -> RepositoryRecord: + record = evaluation.record + facts = RepoFacts( + freshness=record.freshness, org_class=record.org_class, ever_affected=record.ever_affected, + migration_status=record.migration_status, has_automatic_steps=record.migration_strategy_available, + patch_generated=record.patch_fingerprint is not None, verification_outcome=record.verification_outcome, + pr_status=pr.status.value, pr_number=pr.number, + ) + reasons = list(record.reasons) + if pr.reason: + reasons.append(f"publication: {pr.reason}") + return replace( + record, state=derive_repo_state(facts), pr_status=pr.status.value, pr_number=pr.number, pr_url=pr.html_url, + reasons=bound_reasons(reasons), + ) + + +async def _publish_phase( + evaluations: Sequence[RepositoryEvaluation], + *, + policy: WorkspacePolicy, + publish: PullRequestPublisher | None, +) -> tuple[list[RepositoryEvaluation], dict[str, PublicationDecision], dict[str, MigrationPullRequest]]: + decisions: dict[str, PublicationDecision] = {} + publications: dict[str, MigrationPullRequest] = {} + out: list[RepositoryEvaluation] = [] + for evaluation in evaluations: + plan = evaluation.pr_plan + if plan is None: + out.append(evaluation) + continue + name = evaluation.record.repository + decision = decide_publication(policy, plan.eligibility) + decisions[name] = decision + record = replace( + evaluation.record, reasons=bound_reasons([*evaluation.record.reasons, f"publication policy: {decision.reason}"]) + ) + evaluation = replace(evaluation, record=record) + if decision.writes_to_github and publish is not None: + try: + pr = await publish(plan=plan, patch=evaluation.patch, draft=decision.draft) + except Exception as exc: + evaluation = replace( + evaluation, + record=replace( + evaluation.record, state=RepoState.FAILED, error=safe_error(exc), + reasons=bound_reasons([*evaluation.record.reasons, "publication raised an error"]), + ), + ) + else: + publications[name] = pr + evaluation = replace(evaluation, record=_apply_publication(evaluation, pr)) + out.append(evaluation) + return out, decisions, publications + + +def _unresolved_risks(records: Sequence[RepositoryRecord], unknown_count: int) -> tuple[str, ...]: + risks: list[str] = [] + for record in sorted(records, key=lambda r: r.repository): + if record.state in BLOCKING_STATES: + detail = ( + record.human_actions[0] if record.human_actions + else record.error or (record.reasons[0] if record.reasons else record.state.value) + ) + risks.append(f"{record.repository}: {record.state.value} -- {detail}"[:400]) + if unknown_count: + risks.append(f"the organization cannot be declared safe: {unknown_count} repositories could not be classified") + return tuple(risks) + + +async def run_campaign( + *, + workspace_key: str, + event: ExternalChangeEvent, + entries: Sequence[RepositoryInput], + now: datetime, + policy: WorkspacePolicy | None = None, + freshness_policy: FreshnessPolicy | None = None, + hints: ChangeHints = EMPTY_HINTS, + verify: VerifyFn = run_migration_verification, + publish: PullRequestPublisher | None = None, + previous: CompatibilityCampaign | None = None, + internal_contract: InternalContract | None = None, +) -> CampaignRunResult: + policy = policy or WorkspacePolicy() + identity = campaign_identity_key(workspace_key=workspace_key, change_fingerprint=event.fingerprint) + + excluded: dict[str, str] = {} + scoped: list[RepositoryInput] = [] + for entry in entries: + name = entry.repository.full_name + if internal_contract is not None and name == internal_contract.producer_repository: + excluded[name] = f"producer of internal contract {internal_contract.contract_id!r}; not a consumer" + continue + prior = previous.record_for(name) if previous else None + scoped.append(replace(entry, previous=prior) if prior is not None else entry) + + if not policy.analyzes: + campaign = CompatibilityCampaign( + campaign_id=identity, workspace_key=workspace_key, change_fingerprint=event.fingerprint, + dependency_label=event.target.label, provider_key=event.target.provider_key, + origin="internal" if internal_contract else "external", + discovered_at=previous.discovered_at if previous else now, state=CampaignState.DETECTED, + org_blast_radius=build_org_blast_radius([]), records=(), + unresolved_risks=("watch mode is off: nothing was analyzed",), version=previous.version if previous else 1, + producer_repository=internal_contract.producer_repository if internal_contract else None, + old_version=event.old.version, new_version=event.new.version, + compatibility=event.classification.compatibility.value, + ) + return CampaignRunResult(campaign=campaign, evaluations=(), excluded=excluded, changed=previous is None) + + evaluations = await evaluate_all( + event, scoped, now=now, policy=policy, freshness_policy=freshness_policy, hints=hints, verify=verify + ) + published, decisions, publications = await _publish_phase(evaluations, policy=policy, publish=publish) + + records = tuple(e.record for e in published) + blast = build_org_blast_radius([e.classification for e in published]) + state = derive_campaign_state(r.state for r in records) + unknown = sum(1 for r in records if r.org_class is OrgClass.UNKNOWN) + version = 1 + changed = True + if previous is not None: + same = _signature(previous.records) == _signature(records) and previous.state is state + changed = not same + version = previous.version if same else previous.version + 1 + campaign = CompatibilityCampaign( + campaign_id=identity, workspace_key=workspace_key, change_fingerprint=event.fingerprint, + dependency_label=event.target.label, provider_key=event.target.provider_key, + origin="internal" if internal_contract else "external", + discovered_at=previous.discovered_at if previous else now, state=state, org_blast_radius=blast, + records=records, unresolved_risks=_unresolved_risks(records, unknown), version=version, + producer_repository=internal_contract.producer_repository if internal_contract else None, + old_version=event.old.version, new_version=event.new.version, + compatibility=event.classification.compatibility.value, + ) + return CampaignRunResult( + campaign=campaign, evaluations=tuple(published), decisions=decisions, publications=publications, + excluded=excluded, changed=changed, + ) + + +__all__ = [ + "CampaignRunResult", + "PublicationAction", + "PullRequestPublisher", + "pr_publisher_from", + "run_campaign", +] diff --git a/patchfrog/campaigns/store.py b/patchfrog/campaigns/store.py new file mode 100644 index 0000000..da4d866 --- /dev/null +++ b/patchfrog/campaigns/store.py @@ -0,0 +1,228 @@ +"""Idempotent persistence for compatibility campaigns (M10.9). + +One campaign row per identity key (workspace + upstream change + campaign +engine version); one record row per repository per campaign. ``upsert`` is +safe to call repeatedly with the same campaign: it reconciles in place, so +re-detecting a change never creates a second campaign. Writes for one +identity are serialized with a transaction-scoped advisory lock (a no-op on +SQLite), the same pattern every other identity-keyed table here uses. + +Scope semantics: a campaign's records are exactly the repositories passed to +the latest run (the currently enrolled set). A repository that is no longer +enrolled is removed from the campaign; one that is enrolled but unreadable +stays, as ``ACCESS_LOST``. +""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from datetime import UTC, datetime + +from sqlalchemy import delete, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from patchfrog.campaigns.domain import ( + CampaignState, + CompatibilityCampaign, + Freshness, + OrgBlastRadius, + OrgClass, + RepositoryRecord, + RepoState, + UnknownReason, +) +from patchfrog.persistence.models.campaign import ( + CampaignRepositoryRecordModel, + CompatibilityCampaignModel, +) + + +async def _advisory_lock(session: AsyncSession, key: str) -> None: + if session.bind is None or session.bind.dialect.name != "postgresql": + return + digest = hashlib.sha256(key.encode()).digest()[:8] + await session.execute( + text("SELECT pg_advisory_xact_lock(:key)"), {"key": int.from_bytes(digest, "big") & 0x7FFFFFFFFFFFFFFF} + ) + + +def _aware(value: datetime | None) -> datetime | None: + if value is None: + return None + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _blast_json(radius: OrgBlastRadius) -> str: + return json.dumps( + { + "total_enrolled": radius.total_enrolled, "potentially_relevant": radius.potentially_relevant, + "affected": list(radius.affected), "not_affected": list(radius.not_affected), + "unknown": list(radius.unknown), "unknown_reasons": dict(radius.unknown_reasons), + }, + sort_keys=True, + ) + + +def _blast_from_json(raw: str) -> OrgBlastRadius: + data = json.loads(raw) + return OrgBlastRadius( + total_enrolled=int(data["total_enrolled"]), potentially_relevant=int(data["potentially_relevant"]), + affected=tuple(data["affected"]), not_affected=tuple(data["not_affected"]), unknown=tuple(data["unknown"]), + unknown_reasons=dict(data.get("unknown_reasons", {})), + ) + + +def _fill_record(row: CampaignRepositoryRecordModel, record: RepositoryRecord) -> None: + row.state = record.state.value + row.org_class = record.org_class.value + row.freshness = record.freshness.value + row.unknown_reason = record.unknown_reason.value if record.unknown_reason else None + row.impact_status = record.impact_status + row.direct_consumers = record.direct_consumers + row.transitive_consumers = record.transitive_consumers + row.potential_consumers = record.potential_consumers + row.severity = record.severity + row.migration_status = record.migration_status + row.migration_strategy_available = record.migration_strategy_available + row.verification_feasibility = record.verification_feasibility + row.verification_outcome = record.verification_outcome + row.publication_readiness = record.publication_readiness + row.base_commit_sha = record.base_commit_sha + row.plan_fingerprint = record.plan_fingerprint + row.patch_fingerprint = record.patch_fingerprint + row.bundle_fingerprint = record.bundle_fingerprint + row.pr_status = record.pr_status + row.pr_number = record.pr_number + row.pr_url = record.pr_url + row.residual_risk = record.residual_risk + row.ever_affected = record.ever_affected + row.reasons_json = json.dumps(list(record.reasons)) + row.human_actions_json = json.dumps(list(record.human_actions)) + row.error = record.error[:512] if record.error else None + row.evaluated_at = record.evaluated_at + + +def _record_from_row(row: CampaignRepositoryRecordModel) -> RepositoryRecord: + return RepositoryRecord( + repository=row.repository, state=RepoState(row.state), org_class=OrgClass(row.org_class), + freshness=Freshness(row.freshness), unknown_reason=UnknownReason(row.unknown_reason) if row.unknown_reason else None, + impact_status=row.impact_status, direct_consumers=row.direct_consumers, + transitive_consumers=row.transitive_consumers, potential_consumers=row.potential_consumers, + severity=row.severity, migration_status=row.migration_status, + migration_strategy_available=row.migration_strategy_available, + verification_feasibility=row.verification_feasibility, verification_outcome=row.verification_outcome, + publication_readiness=row.publication_readiness, base_commit_sha=row.base_commit_sha, + plan_fingerprint=row.plan_fingerprint, patch_fingerprint=row.patch_fingerprint, + bundle_fingerprint=row.bundle_fingerprint, pr_status=row.pr_status, pr_number=row.pr_number, + pr_url=row.pr_url, residual_risk=row.residual_risk, ever_affected=row.ever_affected, + reasons=tuple(json.loads(row.reasons_json)), human_actions=tuple(json.loads(row.human_actions_json)), + error=row.error, evaluated_at=_aware(row.evaluated_at), + ) + + +class CampaignStore: + async def get_row(self, session: AsyncSession, *, identity_key: str) -> CompatibilityCampaignModel | None: + return ( + await session.execute( + select(CompatibilityCampaignModel).where(CompatibilityCampaignModel.identity_key == identity_key) + ) + ).scalar_one_or_none() + + async def load(self, session: AsyncSession, *, identity_key: str) -> CompatibilityCampaign | None: + row = await self.get_row(session, identity_key=identity_key) + return await self._to_domain(session, row) if row is not None else None + + async def list_for_workspace( + self, session: AsyncSession, *, workspace_key: str, limit: int = 100 + ) -> list[CompatibilityCampaign]: + rows = ( + await session.execute( + select(CompatibilityCampaignModel) + .where(CompatibilityCampaignModel.workspace_key == workspace_key) + .order_by(CompatibilityCampaignModel.updated_at.desc(), CompatibilityCampaignModel.identity_key) + .limit(limit) + ) + ).scalars().all() + return [await self._to_domain(session, row) for row in rows] + + async def upsert( + self, session: AsyncSession, campaign: CompatibilityCampaign, *, recorded_at: datetime | None = None + ) -> tuple[CompatibilityCampaignModel, bool]: + """Returns ``(row, created)``.""" + + now = recorded_at or datetime.now(UTC) + await _advisory_lock(session, f"campaign:{campaign.campaign_id}") + row = await self.get_row(session, identity_key=campaign.campaign_id) + created = row is None + if row is None: + row = CompatibilityCampaignModel( + id=uuid.uuid4(), identity_key=campaign.campaign_id, workspace_key=campaign.workspace_key, + change_fingerprint=campaign.change_fingerprint, engine_version=campaign.engine_version, + dependency_label=campaign.dependency_label[:255], provider_key=campaign.provider_key, + origin=campaign.origin, producer_repository=campaign.producer_repository, + old_version=campaign.old_version, new_version=campaign.new_version, + compatibility=campaign.compatibility, state=campaign.state.value, version=campaign.version, + blast_json=_blast_json(campaign.org_blast_radius), + unresolved_risks_json=json.dumps(list(campaign.unresolved_risks)), + discovered_at=campaign.discovered_at, created_at=now, updated_at=now, + ) + session.add(row) + await session.flush() + else: + if row.state != campaign.state.value or campaign.version > row.version: + row.updated_at = now + row.state = campaign.state.value + row.version = max(row.version, campaign.version) + row.blast_json = _blast_json(campaign.org_blast_radius) + row.unresolved_risks_json = json.dumps(list(campaign.unresolved_risks)) + + existing = { + r.repository: r + for r in ( + await session.execute( + select(CampaignRepositoryRecordModel).where(CampaignRepositoryRecordModel.campaign_id == row.id) + ) + ).scalars().all() + } + keep = {r.repository for r in campaign.records} + for record in campaign.records: + record_row = existing.get(record.repository) + if record_row is None: + record_row = CampaignRepositoryRecordModel( + id=uuid.uuid4(), campaign_id=row.id, repository=record.repository[:512] + ) + session.add(record_row) + _fill_record(record_row, record) + stale = [name for name in existing if name not in keep] + if stale: + await session.execute( + delete(CampaignRepositoryRecordModel).where( + CampaignRepositoryRecordModel.campaign_id == row.id, + CampaignRepositoryRecordModel.repository.in_(stale), + ) + ) + await session.flush() + return row, created + + async def _to_domain(self, session: AsyncSession, row: CompatibilityCampaignModel) -> CompatibilityCampaign: + record_rows = ( + await session.execute( + select(CampaignRepositoryRecordModel) + .where(CampaignRepositoryRecordModel.campaign_id == row.id) + .order_by(CampaignRepositoryRecordModel.repository) + ) + ).scalars().all() + return CompatibilityCampaign( + campaign_id=row.identity_key, workspace_key=row.workspace_key, change_fingerprint=row.change_fingerprint, + dependency_label=row.dependency_label, provider_key=row.provider_key, origin=row.origin, + discovered_at=_aware(row.discovered_at) or row.discovered_at, state=CampaignState(row.state), + org_blast_radius=_blast_from_json(row.blast_json), records=tuple(_record_from_row(r) for r in record_rows), + unresolved_risks=tuple(json.loads(row.unresolved_risks_json)), version=row.version, + engine_version=row.engine_version, producer_repository=row.producer_repository, + old_version=row.old_version, new_version=row.new_version, compatibility=row.compatibility, + ) + + +__all__ = ["CampaignStore"] diff --git a/patchfrog/github/client.py b/patchfrog/github/client.py index cbff26a..b5692ea 100644 --- a/patchfrog/github/client.py +++ b/patchfrog/github/client.py @@ -216,9 +216,12 @@ async def create_commit( return sha async def create_pull_request( - self, *, installation_id: int, owner: str, repository: str, title: str, body: str, head: str, base: str + self, *, installation_id: int, owner: str, repository: str, title: str, body: str, head: str, base: str, + draft: bool = False, ) -> PullRequestMetadata: - payload = {"title": title, "body": body, "head": head, "base": base} + payload: dict[str, object] = {"title": title, "body": body, "head": head, "base": base} + if draft: + payload["draft"] = True data = await self._post_json( installation_id=installation_id, path=f"/repos/{owner}/{repository}/pulls", json_body=payload, ) diff --git a/patchfrog/migration_pr/fake_github.py b/patchfrog/migration_pr/fake_github.py index 89bba9b..cebdd2f 100644 --- a/patchfrog/migration_pr/fake_github.py +++ b/patchfrog/migration_pr/fake_github.py @@ -57,6 +57,8 @@ def __init__(self, *, refs: dict[str, str] | None = None) -> None: self.create_ref_calls: list[str] = [] self.update_ref_calls: list[str] = [] self.create_pull_request_calls: list[str] = [] + #: ``draft`` flag of each ``create_pull_request`` call, in order. + self.create_pull_request_drafts: list[bool] = [] self.update_pull_request_calls: list[int] = [] def set_ref(self, *, owner: str, repository: str, ref: str, sha: str) -> None: @@ -97,9 +99,10 @@ async def create_commit( return commit_sha async def create_pull_request( - self, *, owner: str, repository: str, title: str, body: str, head: str, base: str + self, *, owner: str, repository: str, title: str, body: str, head: str, base: str, draft: bool = False ) -> PullRequestMetadata: self.create_pull_request_calls.append(head) + self.create_pull_request_drafts.append(draft) head_sha = self._refs.get(f"{owner}/{repository}:heads/{head}", "") pr = _FakePullRequest( number=self._next_pr_number, title=title, body=body, head_ref=head, base_ref=base, head_sha=head_sha, diff --git a/patchfrog/migration_pr/github_publisher.py b/patchfrog/migration_pr/github_publisher.py index 862207c..8ca5d31 100644 --- a/patchfrog/migration_pr/github_publisher.py +++ b/patchfrog/migration_pr/github_publisher.py @@ -37,7 +37,7 @@ async def create_commit( ) -> str: ... async def create_pull_request( - self, *, owner: str, repository: str, title: str, body: str, head: str, base: str + self, *, owner: str, repository: str, title: str, body: str, head: str, base: str, draft: bool = False ) -> PullRequestMetadata: ... async def list_pull_requests( @@ -99,11 +99,11 @@ async def create_commit( ) async def create_pull_request( - self, *, owner: str, repository: str, title: str, body: str, head: str, base: str + self, *, owner: str, repository: str, title: str, body: str, head: str, base: str, draft: bool = False ) -> PullRequestMetadata: return await self._client.create_pull_request( installation_id=self._installation_id, owner=owner, repository=repository, title=title, body=body, - head=head, base=base, + head=head, base=base, draft=draft, ) async def list_pull_requests( diff --git a/patchfrog/migration_pr/publisher.py b/patchfrog/migration_pr/publisher.py index b590542..d55b4ff 100644 --- a/patchfrog/migration_pr/publisher.py +++ b/patchfrog/migration_pr/publisher.py @@ -73,7 +73,7 @@ def __init__( async def publish( self, *, plan: MigrationPRPlan, patch: GeneratedPatch | None, mode: MigrationPRPublicationMode, - base_branch: str = "main", + base_branch: str = "main", draft: bool = False, ) -> MigrationPullRequest: identity_key = plan.linkage.identity_key() @@ -170,7 +170,7 @@ async def publish( else: created = await self._publisher.create_pull_request( owner=owner, repository=repository, title=plan.pr_title, body=plan.pr_body, - head=plan.branch_name, base=base_branch, + head=plan.branch_name, base=base_branch, draft=draft, ) result = _result(plan, status=MigrationPRStatus.OPENED, number=created.number, html_url=created.html_url) diff --git a/patchfrog/persistence/models/__init__.py b/patchfrog/persistence/models/__init__.py index 21d2a1b..0cab260 100644 --- a/patchfrog/persistence/models/__init__.py +++ b/patchfrog/persistence/models/__init__.py @@ -9,6 +9,10 @@ FindingStatus, ) from patchfrog.persistence.models.base import Base +from patchfrog.persistence.models.campaign import ( + CampaignRepositoryRecordModel, + CompatibilityCampaignModel, +) from patchfrog.persistence.models.code_index import ( CallReferenceModel, FileIndexStatus, @@ -83,6 +87,8 @@ "Base", "BetaState", "CallReferenceModel", + "CampaignRepositoryRecordModel", + "CompatibilityCampaignModel", "ContextBundleModel", "ContextBundleStatus", "ContextItemModel", diff --git a/patchfrog/persistence/models/campaign.py b/patchfrog/persistence/models/campaign.py new file mode 100644 index 0000000..95eb541 --- /dev/null +++ b/patchfrog/persistence/models/campaign.py @@ -0,0 +1,98 @@ +"""Compatibility campaign persistence (M10). + +Two tables. ``compatibility_campaigns`` holds one row per +(workspace, upstream change, campaign engine version) -- the identity the +spec requires so that re-detecting the same change reuses one campaign. +``campaign_repository_records`` holds one row per repository per campaign. + +Engine truth only: ``workspace_key`` is an opaque string supplied by the +caller (a hosted workspace id, an installation id, an org login). The +engine never interprets it. Every column is a bounded, controlled value -- +no source text, no logs, no secrets. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import Uuid + +from patchfrog.persistence.models.base import Base + + +class CompatibilityCampaignModel(Base): + __tablename__ = "compatibility_campaigns" + __table_args__ = ( + UniqueConstraint("identity_key", name="uq_compatibility_campaigns_identity"), + Index("ix_compatibility_campaigns_workspace", "workspace_key", "state"), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + identity_key: Mapped[str] = mapped_column(String(64)) + workspace_key: Mapped[str] = mapped_column(String(255)) + change_fingerprint: Mapped[str] = mapped_column(String(64)) + engine_version: Mapped[int] = mapped_column(Integer) + dependency_label: Mapped[str] = mapped_column(String(255)) + provider_key: Mapped[str | None] = mapped_column(String(255), nullable=True) + origin: Mapped[str] = mapped_column(String(16)) + producer_repository: Mapped[str | None] = mapped_column(String(512), nullable=True) + old_version: Mapped[str | None] = mapped_column(String(128), nullable=True) + new_version: Mapped[str | None] = mapped_column(String(128), nullable=True) + compatibility: Mapped[str | None] = mapped_column(String(32), nullable=True) + #: ``CampaignState`` value. + state: Mapped[str] = mapped_column(String(24)) + version: Mapped[int] = mapped_column(Integer, default=1) + #: JSON: org blast radius lists/reasons (derived, kept for fast reads). + blast_json: Mapped[str] = mapped_column(Text) + #: JSON list of bounded strings. + unresolved_risks_json: Mapped[str] = mapped_column(Text, default="[]") + discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + +class CampaignRepositoryRecordModel(Base): + __tablename__ = "campaign_repository_records" + __table_args__ = ( + UniqueConstraint("campaign_id", "repository", name="uq_campaign_repository_records_campaign_repo"), + Index("ix_campaign_repository_records_repository", "repository", "state"), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + campaign_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("compatibility_campaigns.id", ondelete="CASCADE"), index=True + ) + repository: Mapped[str] = mapped_column(String(512)) + state: Mapped[str] = mapped_column(String(24)) + org_class: Mapped[str] = mapped_column(String(16)) + freshness: Mapped[str] = mapped_column(String(16)) + unknown_reason: Mapped[str | None] = mapped_column(String(32), nullable=True) + impact_status: Mapped[str | None] = mapped_column(String(16), nullable=True) + direct_consumers: Mapped[int] = mapped_column(Integer, default=0) + transitive_consumers: Mapped[int] = mapped_column(Integer, default=0) + potential_consumers: Mapped[int] = mapped_column(Integer, default=0) + severity: Mapped[str] = mapped_column(String(32), default="none") + migration_status: Mapped[str | None] = mapped_column(String(24), nullable=True) + migration_strategy_available: Mapped[bool] = mapped_column(Boolean, default=False) + verification_feasibility: Mapped[str] = mapped_column(String(24), default="not_applicable") + verification_outcome: Mapped[str | None] = mapped_column(String(24), nullable=True) + publication_readiness: Mapped[str | None] = mapped_column(String(32), nullable=True) + base_commit_sha: Mapped[str | None] = mapped_column(String(64), nullable=True) + plan_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True) + patch_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True) + bundle_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True) + pr_status: Mapped[str | None] = mapped_column(String(32), nullable=True) + pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True) + pr_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) + residual_risk: Mapped[str | None] = mapped_column(String(16), nullable=True) + ever_affected: Mapped[bool] = mapped_column(Boolean, default=False) + reasons_json: Mapped[str] = mapped_column(Text, default="[]") + human_actions_json: Mapped[str] = mapped_column(Text, default="[]") + error: Mapped[str | None] = mapped_column(String(512), nullable=True) + evaluated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +__all__ = ["CampaignRepositoryRecordModel", "CompatibilityCampaignModel"] diff --git a/tests/fixtures/campaigns/acme/README.md b/tests/fixtures/campaigns/acme/README.md new file mode 100644 index 0000000..b106d92 --- /dev/null +++ b/tests/fixtures/campaigns/acme/README.md @@ -0,0 +1,15 @@ +# Acme campaign fixtures + +A deterministic, fictional workspace used by the M10/M11 tests and by +`patchfrog campaigns demo`. The "upstream" is the same fictional `acme-ai` SDK the +M7-M9 demos use (`client.chat.create(prompt=...)` -> `client.responses.create(input=...)`); +it never describes a real vendor's API. + +| Repository | Role | +|---|---| +| `acme-web` | direct SDK consumer, fully automatic migration, targeted test proves it -> `VERIFIED` | +| `acme-worker` | uses a removed argument (`stream=False`) -> `HUMAN_REQUIRED` | +| `acme-search` | uses `acme-ai` but only `embeddings.create`, which does not change -> `NOT_AFFECTED` | +| `acme-legacy` | already on 2.0, but its dependency discovery is *stale* -> never claimed unaffected | + +`sdk_stub/` is a runnable stand-in for the 2.0 SDK given only to verification. diff --git a/tests/fixtures/campaigns/acme/contract/hints.yaml b/tests/fixtures/campaigns/acme/contract/hints.yaml new file mode 100644 index 0000000..4cf3b4e --- /dev/null +++ b/tests/fixtures/campaigns/acme/contract/hints.yaml @@ -0,0 +1,8 @@ +patchfrog_change_hints: 1 +provenance: "acme-ai 2.0 migration guide (fictional demo SDK)" +symbol_renames: + - {old: chat.create, new: responses.create} +parameter_renames: + - {symbol: chat.create, old: prompt, new: input} +return_field_renames: + - {symbol: chat.create, old: text, new: output_text} diff --git a/tests/fixtures/campaigns/acme/contract/new.yaml b/tests/fixtures/campaigns/acme/contract/new.yaml new file mode 100644 index 0000000..e0c94aa --- /dev/null +++ b/tests/fixtures/campaigns/acme/contract/new.yaml @@ -0,0 +1,18 @@ +patchfrog_sdk_surface: 1 +package: acme-ai +ecosystem: pypi +version: "2.0.0" +modules: [acme_ai] +symbols: + Client: {} + responses.create: + params: + model: {required: true, type: string} + input: {required: true, type: string} + temperature: {type: number} + returns: + fields: {output_text: string, id: string} + embeddings.create: + params: + model: {required: true, type: string} + input: {required: true, type: string} diff --git a/tests/fixtures/campaigns/acme/contract/old.yaml b/tests/fixtures/campaigns/acme/contract/old.yaml new file mode 100644 index 0000000..bd92c02 --- /dev/null +++ b/tests/fixtures/campaigns/acme/contract/old.yaml @@ -0,0 +1,19 @@ +patchfrog_sdk_surface: 1 +package: acme-ai +ecosystem: pypi +version: "1.4.0" +modules: [acme_ai] +symbols: + Client: {} + chat.create: + params: + model: {required: true, type: string} + prompt: {required: true, type: string} + stream: {type: boolean} + temperature: {type: number} + returns: + fields: {text: string, id: string} + embeddings.create: + params: + model: {required: true, type: string} + input: {required: true, type: string} diff --git a/tests/fixtures/campaigns/acme/repos/acme-legacy/legacy/__init__.py b/tests/fixtures/campaigns/acme/repos/acme-legacy/legacy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/campaigns/acme/repos/acme-legacy/legacy/replies.py b/tests/fixtures/campaigns/acme/repos/acme-legacy/legacy/replies.py new file mode 100644 index 0000000..6694018 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-legacy/legacy/replies.py @@ -0,0 +1,9 @@ +"""Already migrated to the 2.0 SDK; the *registry snapshot* for this repo is what is old.""" + +import acme_ai + +_client = acme_ai.Client() + + +def reply(message: str) -> str: + return _client.responses.create(model="acme-large", input=message).output_text diff --git a/tests/fixtures/campaigns/acme/repos/acme-legacy/requirements.txt b/tests/fixtures/campaigns/acme/repos/acme-legacy/requirements.txt new file mode 100644 index 0000000..af60697 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-legacy/requirements.txt @@ -0,0 +1 @@ +acme-ai==2.0.0 diff --git a/tests/fixtures/campaigns/acme/repos/acme-legacy/tests/test_replies.py b/tests/fixtures/campaigns/acme/repos/acme-legacy/tests/test_replies.py new file mode 100644 index 0000000..5a5f742 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-legacy/tests/test_replies.py @@ -0,0 +1,5 @@ +import legacy.replies + + +def test_module_loads() -> None: + assert legacy.replies.reply diff --git a/tests/fixtures/campaigns/acme/repos/acme-search/requirements.txt b/tests/fixtures/campaigns/acme/repos/acme-search/requirements.txt new file mode 100644 index 0000000..f03d0c7 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-search/requirements.txt @@ -0,0 +1 @@ +acme-ai==1.4.0 diff --git a/tests/fixtures/campaigns/acme/repos/acme-search/search/__init__.py b/tests/fixtures/campaigns/acme/repos/acme-search/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/campaigns/acme/repos/acme-search/search/index.py b/tests/fixtures/campaigns/acme/repos/acme-search/search/index.py new file mode 100644 index 0000000..828ac59 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-search/search/index.py @@ -0,0 +1,9 @@ +"""Search indexing. Only uses embeddings, which the upgrade does not change.""" + +import acme_ai + +_client = acme_ai.Client() + + +def embed(text: str) -> str: + return _client.embeddings.create(model="acme-embed", input=text).id diff --git a/tests/fixtures/campaigns/acme/repos/acme-search/tests/test_index.py b/tests/fixtures/campaigns/acme/repos/acme-search/tests/test_index.py new file mode 100644 index 0000000..3fd89f5 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-search/tests/test_index.py @@ -0,0 +1,5 @@ +import search.index + + +def test_module_loads() -> None: + assert search.index.embed diff --git a/tests/fixtures/campaigns/acme/repos/acme-web/app/__init__.py b/tests/fixtures/campaigns/acme/repos/acme-web/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/campaigns/acme/repos/acme-web/app/ai/__init__.py b/tests/fixtures/campaigns/acme/repos/acme-web/app/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/campaigns/acme/repos/acme-web/app/ai/chat.py b/tests/fixtures/campaigns/acme/repos/acme-web/app/ai/chat.py new file mode 100644 index 0000000..201f9f1 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-web/app/ai/chat.py @@ -0,0 +1,10 @@ +"""Chat replies for the support widget.""" + +import acme_ai + +client = acme_ai.Client() + + +def generate_reply(message: str) -> str: + result = client.chat.create(model="acme-large", prompt=message, temperature=0.2) + return result.text diff --git a/tests/fixtures/campaigns/acme/repos/acme-web/requirements.txt b/tests/fixtures/campaigns/acme/repos/acme-web/requirements.txt new file mode 100644 index 0000000..201c758 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-web/requirements.txt @@ -0,0 +1,2 @@ +acme-ai==1.4.0 +httpx==0.27.0 diff --git a/tests/fixtures/campaigns/acme/repos/acme-web/tests/test_chat.py b/tests/fixtures/campaigns/acme/repos/acme-web/tests/test_chat.py new file mode 100644 index 0000000..2634d83 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-web/tests/test_chat.py @@ -0,0 +1,5 @@ +from app.ai.chat import generate_reply + + +def test_generate_reply_is_text() -> None: + assert isinstance(generate_reply("hi"), str) diff --git a/tests/fixtures/campaigns/acme/repos/acme-worker/app/__init__.py b/tests/fixtures/campaigns/acme/repos/acme-worker/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/campaigns/acme/repos/acme-worker/app/summary.py b/tests/fixtures/campaigns/acme/repos/acme-worker/app/summary.py new file mode 100644 index 0000000..6117e96 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-worker/app/summary.py @@ -0,0 +1,10 @@ +"""Nightly summaries. Uses a streaming flag the 2.0 SDK removed.""" + +import acme_ai + +_client = acme_ai.Client() + + +def summarize(text: str) -> str: + response = _client.chat.create(model="acme-small", prompt="Summarize: " + text, stream=False) + return response.text diff --git a/tests/fixtures/campaigns/acme/repos/acme-worker/requirements.txt b/tests/fixtures/campaigns/acme/repos/acme-worker/requirements.txt new file mode 100644 index 0000000..f03d0c7 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-worker/requirements.txt @@ -0,0 +1 @@ +acme-ai==1.4.0 diff --git a/tests/fixtures/campaigns/acme/repos/acme-worker/tests/test_summary.py b/tests/fixtures/campaigns/acme/repos/acme-worker/tests/test_summary.py new file mode 100644 index 0000000..cedd0e3 --- /dev/null +++ b/tests/fixtures/campaigns/acme/repos/acme-worker/tests/test_summary.py @@ -0,0 +1,5 @@ +import app.summary + + +def test_module_loads() -> None: + assert app.summary.summarize diff --git a/tests/fixtures/campaigns/acme/sdk_stub/acme_ai/__init__.py b/tests/fixtures/campaigns/acme/sdk_stub/acme_ai/__init__.py new file mode 100644 index 0000000..1350f49 --- /dev/null +++ b/tests/fixtures/campaigns/acme/sdk_stub/acme_ai/__init__.py @@ -0,0 +1,50 @@ +"""A tiny, fully local, fictional stand-in for the "acme-ai" SDK's 2.0 +surface -- given only to Migration *Verification* (M8), never to +dependency discovery/planning, so it cannot change what M6/M7 detect as +an external dependency. See +:data:`patchfrog.cli_changes.DEMO_SDK_STUB_ROOT`. + +Deliberately implements only the 2.0 (``responses.create``) shape, never +``chat.create`` -- this simulates the environment already having the new +SDK installed. Code still written against the 1.4 surface +(``client.chat.create(...)``) genuinely raises ``AttributeError`` here, +exactly like the real upstream removal it stands in for; migrated code +(``client.responses.create(...)``) genuinely runs and returns a value. +This is what lets the M8 baseline-vs-patched comparison observe a real +``baseline_fail_patched_pass`` result instead of only a syntactic one. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Response: + output_text: str + id: str + + +@dataclass(frozen=True, slots=True) +class Embedding: + id: str + vector: tuple[float, ...] + + +class _Responses: + def create(self, *, model: str, input: str, temperature: float | None = None) -> Response: + return Response(output_text=f"echo: {input}", id="resp_demo_1") + + +class _Embeddings: + def create(self, *, model: str, input: str) -> Embedding: + return Embedding(id="emb_demo_1", vector=(0.0, 0.0, 0.0)) + + +class Client: + def __init__(self) -> None: + self.responses = _Responses() + self.embeddings = _Embeddings() + + +__all__ = ["Client", "Embedding", "Response"] diff --git a/tests/fixtures/campaigns/internal/README.md b/tests/fixtures/campaigns/internal/README.md new file mode 100644 index 0000000..7366df2 --- /dev/null +++ b/tests/fixtures/campaigns/internal/README.md @@ -0,0 +1,6 @@ +# Internal-contract fixtures (M10.5) + +`platform-lib` produces the internal package `acme-shared`; `svc-orders` and `svc-billing` +call the removed `records.fetch`; `svc-reports` only uses the unchanged `health.check`; +`acme-shared-fork` merely *sounds* related -- it has no dependency evidence and must never +be matched by name. diff --git a/tests/fixtures/campaigns/internal/contract/new.yaml b/tests/fixtures/campaigns/internal/contract/new.yaml new file mode 100644 index 0000000..9eabb4f --- /dev/null +++ b/tests/fixtures/campaigns/internal/contract/new.yaml @@ -0,0 +1,13 @@ +patchfrog_sdk_surface: 1 +package: acme-shared +ecosystem: pypi +version: "2.0.0" +modules: [acme_shared] +symbols: + Client: {} + records.get: + params: + record_id: {required: true, type: string} + returns: + fields: {payload: string} + health.check: {} diff --git a/tests/fixtures/campaigns/internal/contract/old.yaml b/tests/fixtures/campaigns/internal/contract/old.yaml new file mode 100644 index 0000000..1dcebda --- /dev/null +++ b/tests/fixtures/campaigns/internal/contract/old.yaml @@ -0,0 +1,13 @@ +patchfrog_sdk_surface: 1 +package: acme-shared +ecosystem: pypi +version: "1.0.0" +modules: [acme_shared] +symbols: + Client: {} + records.fetch: + params: + record_id: {required: true, type: string} + returns: + fields: {payload: string} + health.check: {} diff --git a/tests/fixtures/campaigns/internal/repos/acme-shared-fork/fork/__init__.py b/tests/fixtures/campaigns/internal/repos/acme-shared-fork/fork/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/campaigns/internal/repos/acme-shared-fork/fork/notes.py b/tests/fixtures/campaigns/internal/repos/acme-shared-fork/fork/notes.py new file mode 100644 index 0000000..95ef65c --- /dev/null +++ b/tests/fixtures/campaigns/internal/repos/acme-shared-fork/fork/notes.py @@ -0,0 +1,5 @@ +"""Looks related by name only. It has no dependency on acme-shared at all.""" + + +def note() -> str: + return "records.fetch is mentioned in a comment only" diff --git a/tests/fixtures/campaigns/internal/repos/platform-lib/acme_shared/__init__.py b/tests/fixtures/campaigns/internal/repos/platform-lib/acme_shared/__init__.py new file mode 100644 index 0000000..38fab03 --- /dev/null +++ b/tests/fixtures/campaigns/internal/repos/platform-lib/acme_shared/__init__.py @@ -0,0 +1 @@ +"""The internal package whose contract changes. This repository *produces* it.""" diff --git a/tests/fixtures/campaigns/internal/repos/platform-lib/pyproject.toml b/tests/fixtures/campaigns/internal/repos/platform-lib/pyproject.toml new file mode 100644 index 0000000..55e1336 --- /dev/null +++ b/tests/fixtures/campaigns/internal/repos/platform-lib/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "acme-shared" +version = "1.0.0" diff --git a/tests/fixtures/campaigns/internal/repos/svc-billing/billing/__init__.py b/tests/fixtures/campaigns/internal/repos/svc-billing/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/campaigns/internal/repos/svc-billing/billing/client.py b/tests/fixtures/campaigns/internal/repos/svc-billing/billing/client.py new file mode 100644 index 0000000..ea0bace --- /dev/null +++ b/tests/fixtures/campaigns/internal/repos/svc-billing/billing/client.py @@ -0,0 +1,7 @@ +import acme_shared + +_client = acme_shared.Client() + + +def load(record_id: str) -> str: + return _client.records.fetch(record_id=record_id).payload diff --git a/tests/fixtures/campaigns/internal/repos/svc-billing/requirements.txt b/tests/fixtures/campaigns/internal/repos/svc-billing/requirements.txt new file mode 100644 index 0000000..90b7e9a --- /dev/null +++ b/tests/fixtures/campaigns/internal/repos/svc-billing/requirements.txt @@ -0,0 +1 @@ +acme-shared==1.0.0 diff --git a/tests/fixtures/campaigns/internal/repos/svc-orders/orders/__init__.py b/tests/fixtures/campaigns/internal/repos/svc-orders/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/campaigns/internal/repos/svc-orders/orders/client.py b/tests/fixtures/campaigns/internal/repos/svc-orders/orders/client.py new file mode 100644 index 0000000..ea0bace --- /dev/null +++ b/tests/fixtures/campaigns/internal/repos/svc-orders/orders/client.py @@ -0,0 +1,7 @@ +import acme_shared + +_client = acme_shared.Client() + + +def load(record_id: str) -> str: + return _client.records.fetch(record_id=record_id).payload diff --git a/tests/fixtures/campaigns/internal/repos/svc-orders/requirements.txt b/tests/fixtures/campaigns/internal/repos/svc-orders/requirements.txt new file mode 100644 index 0000000..90b7e9a --- /dev/null +++ b/tests/fixtures/campaigns/internal/repos/svc-orders/requirements.txt @@ -0,0 +1 @@ +acme-shared==1.0.0 diff --git a/tests/fixtures/campaigns/internal/repos/svc-reports/reports/__init__.py b/tests/fixtures/campaigns/internal/repos/svc-reports/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/campaigns/internal/repos/svc-reports/reports/health.py b/tests/fixtures/campaigns/internal/repos/svc-reports/reports/health.py new file mode 100644 index 0000000..b1c966f --- /dev/null +++ b/tests/fixtures/campaigns/internal/repos/svc-reports/reports/health.py @@ -0,0 +1,7 @@ +import acme_shared + +_client = acme_shared.Client() + + +def is_up() -> bool: + return _client.health.check() is None diff --git a/tests/fixtures/campaigns/internal/repos/svc-reports/requirements.txt b/tests/fixtures/campaigns/internal/repos/svc-reports/requirements.txt new file mode 100644 index 0000000..90b7e9a --- /dev/null +++ b/tests/fixtures/campaigns/internal/repos/svc-reports/requirements.txt @@ -0,0 +1 @@ +acme-shared==1.0.0 diff --git a/tests/integration/test_campaign_orchestration.py b/tests/integration/test_campaign_orchestration.py new file mode 100644 index 0000000..5acf79b --- /dev/null +++ b/tests/integration/test_campaign_orchestration.py @@ -0,0 +1,505 @@ +"""M10: cross-repository campaigns against the real engine on the Acme fixture +workspace (four repositories, one upstream SDK change). Verification runs in +the real sandbox; GitHub is the in-memory fake; no provider is ever called.""" + +from __future__ import annotations + +import ast +import shutil +from pathlib import Path + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from patchfrog.campaigns.domain import ( + CampaignState, + EnrolledRepository, + Freshness, + OrgClass, + RepositoryAccess, + RepoState, + UnknownReason, + campaign_identity_key, +) +from patchfrog.campaigns.dossier import campaign_to_dict, render_campaign_markdown +from patchfrog.campaigns.evaluate import RepositoryInput +from patchfrog.campaigns.graph import build_org_graph +from patchfrog.campaigns.internal import InternalContract, InternalContractKind, contract_target +from patchfrog.campaigns.orchestrate import ( + CampaignRunResult, + PullRequestPublisher, + pr_publisher_from, + run_campaign, +) +from patchfrog.campaigns.policy import PublicationMode, WatchMode, WorkspacePolicy +from patchfrog.campaigns.store import CampaignStore +from patchfrog.dependencies.discovery import discover_dependencies +from patchfrog.dependencies.domain import DependencyInventory, Ecosystem +from patchfrog.migration_pr.fake_github import FakeMigrationGitHubPublisher +from patchfrog.migration_pr.publisher import MigrationPRPublisher +from patchfrog.upstream.events import build_contract_change, load_contract_file +from patchfrog.upstream.workspace import adapters_for_target +from tests.support.campaigns import ( + ACME, + FRESH_AT, + INTERNAL, + NOW, + SHA, + STALE_AT, + acme_entries, + acme_event, + acme_input, +) + +MIGRATE = WorkspacePolicy(watch_mode=WatchMode.MIGRATE) +WS = "workspace-1" + + +async def _run(entries: list[RepositoryInput] | None = None, **kwargs: object) -> CampaignRunResult: + event, hints = acme_event() + return await run_campaign( + workspace_key=WS, event=event, entries=entries if entries is not None else acme_entries(), now=NOW, + hints=hints, **kwargs, # type: ignore[arg-type] + ) + + +_BASELINE: CampaignRunResult | None = None + + +async def _baseline() -> CampaignRunResult: + """The default migrate-mode run, computed once: real sandbox verification is + the slow part and these results are immutable data.""" + + global _BASELINE + if _BASELINE is None: + _BASELINE = await _run(policy=MIGRATE) + return _BASELINE + + +def _states(result: CampaignRunResult) -> dict[str, RepoState]: + return {r.repository.removeprefix("acme/"): r.state for r in result.campaign.records} + + +async def test_mixed_outcomes_are_never_reported_resolved() -> None: + result = await _baseline() + assert _states(result) == { + "acme-web": RepoState.VERIFIED, "acme-worker": RepoState.HUMAN_REQUIRED, + "acme-search": RepoState.NOT_AFFECTED, "acme-legacy": RepoState.STALE, + } + campaign = result.campaign + assert campaign.state is CampaignState.ACTION_REQUIRED + assert not campaign.org_blast_radius.safe + assert campaign.org_blast_radius.summary() == { + "total_enrolled": 4, "potentially_relevant": 4, "affected": 2, "not_affected": 1, "unknown": 1, + } + assert campaign.org_blast_radius.unknown_reasons == {"acme/acme-legacy": UnknownReason.STALE_DISCOVERY.value} + assert any("cannot be declared safe" in risk for risk in campaign.unresolved_risks) + + +async def test_per_repository_evidence_is_independent() -> None: + result = await _baseline() + web, worker = (result.campaign.record_for(f"acme/{n}") for n in ("acme-web", "acme-worker")) + assert web is not None and worker is not None + assert web.verification_outcome == "verified" and web.publication_readiness == "auto_open" + assert web.verification_feasibility == "sandbox" and web.migration_strategy_available + assert worker.verification_outcome == "human_required" and worker.publication_readiness == "plan_only" + assert worker.human_actions and "stream" in worker.human_actions[0] + assert web.patch_fingerprint and web.patch_fingerprint != worker.patch_fingerprint + assert web.bundle_fingerprint != worker.bundle_fingerprint + assert web.severity == "breaking" and web.direct_consumers >= 1 and web.transitive_consumers == 0 + + +async def test_unaffected_repo_with_the_dependency_is_not_affected_but_relevant() -> None: + result = await _baseline() + search = result.campaign.record_for("acme/acme-search") + assert search is not None + assert search.org_class is OrgClass.NOT_AFFECTED and search.impact_status == "unaffected" + assert search.patch_fingerprint is None and search.direct_consumers == 0 + + +async def test_stale_repository_is_never_analyzed_even_when_a_checkout_exists() -> None: + # The checkout would classify as affected if it were looked at; stale evidence must not be reused. + stale_with_root = acme_input("acme-worker", discovered_at=STALE_AT) + result = await _run([stale_with_root], policy=MIGRATE) + record = result.campaign.records[0] + assert record.state is RepoState.STALE and record.org_class is OrgClass.UNKNOWN + assert record.direct_consumers == 0 and record.patch_fingerprint is None and record.verification_outcome is None + assert record.requires_rediscovery + + +async def test_never_discovered_repository_is_unknown_not_unaffected() -> None: + result = await _run([acme_input("acme-search", discovered_at=None, root=None)], policy=MIGRATE) + record = result.campaign.records[0] + assert record.state is RepoState.UNKNOWN and record.unknown_reason is UnknownReason.NO_DISCOVERY + assert result.campaign.state is CampaignState.ACTION_REQUIRED + + +async def test_access_lost_is_recorded_and_never_claimed_unaffected() -> None: + entry = acme_input("acme-search", access=RepositoryAccess.ACCESS_LOST) + result = await _run([entry, acme_input("acme-web")], policy=MIGRATE) + states = _states(result) + assert states["acme-search"] is RepoState.ACCESS_LOST + record = result.campaign.record_for("acme/acme-search") + assert record is not None and record.freshness is Freshness.ACCESS_LOST + assert record.org_class is OrgClass.UNKNOWN and "acme/acme-search" in result.campaign.org_blast_radius.unknown + assert result.campaign.state is not CampaignState.RESOLVED + + +async def test_detect_only_policy_classifies_but_generates_nothing() -> None: + result = await _run() # default policy: detect_only + states = _states(result) + assert states["acme-web"] is RepoState.IMPACTED and states["acme-worker"] is RepoState.IMPACTED + for evaluation in result.evaluations: + assert evaluation.plan is None and evaluation.patch is None and evaluation.bundle is None + assert result.campaign.state is CampaignState.ACTION_REQUIRED + assert result.decisions == {} + + +async def test_registry_only_evidence_cannot_generate_a_patch() -> None: + # Affected per the checkout-free path is impossible without rows; with no root and no evidence it is unknown. + entry = RepositoryInput( + repository=EnrolledRepository("acme/acme-web", last_discovery_at=FRESH_AT), root=None, commit_sha=SHA + ) + result = await _run([entry], policy=MIGRATE) + assert result.campaign.records[0].state is RepoState.UNKNOWN + assert result.campaign.records[0].unknown_reason is UnknownReason.NO_DISCOVERY + + +async def test_one_repository_failing_does_not_poison_the_others() -> None: + from patchfrog.migration_verification.service import run_migration_verification + + async def verify(**kwargs: object) -> object: + if "acme-web" in str(getattr(kwargs["plan"], "repository", "")): + raise RuntimeError("sandbox exploded at https://user:hunter2@example.com/x ghp_" + "a" * 30) + return await run_migration_verification(**kwargs) # type: ignore[arg-type] + + result = await _run(policy=MIGRATE, verify=verify) + web = result.campaign.record_for("acme/acme-web") + assert web is not None and web.state is RepoState.FAILED + assert web.error is not None and "hunter2" not in web.error and "ghp_" not in web.error + states = _states(result) + assert states["acme-worker"] is RepoState.HUMAN_REQUIRED and states["acme-search"] is RepoState.NOT_AFFECTED + assert result.campaign.state is CampaignState.ACTION_REQUIRED + + +async def test_rerun_is_idempotent_and_does_not_bump_the_version() -> None: + first = await _baseline() + second = await _run(policy=MIGRATE, previous=first.campaign) + assert second.campaign.campaign_id == first.campaign.campaign_id + assert second.campaign.version == first.campaign.version == 1 + assert not second.changed + assert [r.state for r in second.campaign.records] == [r.state for r in first.campaign.records] + + +def test_campaign_identity_is_workspace_change_and_engine_version() -> None: + a = campaign_identity_key(workspace_key="w1", change_fingerprint="f") + assert a == campaign_identity_key(workspace_key="w1", change_fingerprint="f") + assert a != campaign_identity_key(workspace_key="w2", change_fingerprint="f") + assert a != campaign_identity_key(workspace_key="w1", change_fingerprint="g") + assert a != campaign_identity_key(workspace_key="w1", change_fingerprint="f", engine_version=2) + + +# -- publication ---------------------------------------------------------------------------- + + +def _fake_github() -> FakeMigrationGitHubPublisher: + fake = FakeMigrationGitHubPublisher() + for repo in ("acme-web", "acme-worker", "acme-search", "acme-legacy"): + fake.set_ref(owner="acme", repository=repo, ref="heads/main", sha=SHA) + return fake + + +def _publisher( + session_factory: async_sessionmaker[AsyncSession], fake: FakeMigrationGitHubPublisher +) -> PullRequestPublisher: + return pr_publisher_from(MigrationPRPublisher(session_factory=session_factory, publisher=fake, installation_id=1)) + + +async def test_automatic_publication_opens_exactly_one_pr_and_never_for_human_required( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + fake = _fake_github() + policy = WorkspacePolicy(watch_mode=WatchMode.MIGRATE_AND_OPEN_PR, publication=PublicationMode.AUTOMATIC) + first = await _run(policy=policy, publish=_publisher(session_factory, fake)) + assert len(fake.create_pull_request_calls) == 1 + assert fake.create_pull_request_calls[0].startswith("patchfrog/migrate/acme-ai/") + assert fake.create_pull_request_drafts == [False] + web = first.campaign.record_for("acme/acme-web") + assert web is not None and web.state is RepoState.PR_OPENED and web.pr_number == 100 and web.pr_url + worker = first.campaign.record_for("acme/acme-worker") + assert worker is not None and worker.pr_number is None and worker.state is RepoState.HUMAN_REQUIRED + assert set(first.publications) == {"acme/acme-web"} + + again = await _run(policy=policy, publish=_publisher(session_factory, fake), previous=first.campaign) + assert len(fake.create_pull_request_calls) == 1 # no duplicate PR + assert again.campaign.record_for("acme/acme-web").pr_number == 100 # type: ignore[union-attr] + assert again.campaign.version == first.campaign.version + + +async def test_draft_only_and_manual_approval_modes(session_factory: async_sessionmaker[AsyncSession]) -> None: + draft_fake = _fake_github() + draft = WorkspacePolicy(watch_mode=WatchMode.MIGRATE_AND_OPEN_PR, publication=PublicationMode.DRAFT_ONLY) + await _run(policy=draft, publish=_publisher(session_factory, draft_fake)) + assert draft_fake.create_pull_request_drafts == [True] + + manual_fake = _fake_github() + manual = WorkspacePolicy(watch_mode=WatchMode.MIGRATE_AND_OPEN_PR, publication=PublicationMode.MANUAL_APPROVAL) + result = await _run(policy=manual, publish=_publisher(session_factory, manual_fake)) + assert manual_fake.create_pull_request_calls == [] and manual_fake.create_ref_calls == [] + assert result.decisions["acme/acme-web"].action.value == "await_approval" + assert result.campaign.record_for("acme/acme-web").state is RepoState.VERIFIED # type: ignore[union-attr] + + +async def test_migrate_mode_never_touches_github(session_factory: async_sessionmaker[AsyncSession]) -> None: + fake = _fake_github() + await _run(policy=MIGRATE, publish=_publisher(session_factory, fake)) + assert fake.create_ref_calls == [] and fake.create_pull_request_calls == [] + + +async def test_stale_base_is_rejected_and_never_published(session_factory: async_sessionmaker[AsyncSession]) -> None: + fake = _fake_github() + fake.set_ref(owner="acme", repository="acme-web", ref="heads/main", sha="b" * 40) # base moved + policy = WorkspacePolicy(watch_mode=WatchMode.MIGRATE_AND_OPEN_PR, publication=PublicationMode.AUTOMATIC) + result = await _run(policy=policy, publish=_publisher(session_factory, fake)) + web = result.campaign.record_for("acme/acme-web") + assert web is not None and web.pr_status == "stale_requires_regeneration" and web.pr_number is None + assert web.state is RepoState.VERIFIED and fake.create_pull_request_calls == [] + assert any("regenerate" in r for r in web.reasons) + + +async def test_publication_error_is_isolated_to_its_repository( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async def boom(**_: object) -> object: + raise RuntimeError("github 502") + + policy = WorkspacePolicy(watch_mode=WatchMode.MIGRATE_AND_OPEN_PR, publication=PublicationMode.AUTOMATIC) + result = await _run(policy=policy, publish=boom) + web = result.campaign.record_for("acme/acme-web") + assert web is not None and web.state is RepoState.FAILED and web.error + assert _states(result)["acme-worker"] is RepoState.HUMAN_REQUIRED + + +# -- reconciliation to RESOLVED ----------------------------------------------------------------- + + +def _copy_repo(tmp_path: Path, name: str) -> Path: + destination = tmp_path / name + shutil.copytree(ACME / "repos" / name, destination) + return destination + + +async def test_campaign_reconciles_to_resolved_when_every_repo_is_actually_resolved(tmp_path: Path) -> None: + first = await _baseline() + assert first.campaign.state is CampaignState.ACTION_REQUIRED + + # 1) repo A's verified patch lands (a human merged the PR). + web_root = _copy_repo(tmp_path, "acme-web") + web_patch = next(e for e in first.evaluations if e.record.repository == "acme/acme-web").patch + assert web_patch is not None + for relative, content in web_patch.new_contents.items(): + (web_root / relative).write_text(content) + step1 = await _run( + [acme_input("acme-web", root=web_root), acme_input("acme-worker"), acme_input("acme-search"), + acme_input("acme-legacy", discovered_at=STALE_AT, root=None)], + policy=MIGRATE, previous=first.campaign, + ) + assert _states(step1)["acme-web"] is RepoState.RESOLVED + assert step1.campaign.state is CampaignState.PARTIALLY_RESOLVED + assert step1.campaign.version == first.campaign.version + 1 + + # 2) a human fixes repo B (applies the automatic edits, then removes the argument by hand). + worker_root = _copy_repo(tmp_path, "acme-worker") + worker_patch = next(e for e in first.evaluations if e.record.repository == "acme/acme-worker").patch + assert worker_patch is not None + for relative, content in worker_patch.new_contents.items(): + (worker_root / relative).write_text(content) + summary = worker_root / "app" / "summary.py" + summary.write_text(summary.read_text().replace(", stream=False", "")) + + # 3) repo D is rediscovered (fresh evidence, and it really is unaffected). + final = await _run( + [acme_input("acme-web", root=web_root), acme_input("acme-worker", root=worker_root), + acme_input("acme-search"), acme_input("acme-legacy", discovered_at=FRESH_AT)], + policy=MIGRATE, previous=step1.campaign, + ) + assert _states(final) == { + "acme-web": RepoState.RESOLVED, "acme-worker": RepoState.RESOLVED, + "acme-search": RepoState.NOT_AFFECTED, "acme-legacy": RepoState.NOT_AFFECTED, + } + assert final.campaign.state is CampaignState.RESOLVED + assert final.campaign.org_blast_radius.safe + assert not final.campaign.unresolved_risks + + +# -- persistence + dossier -------------------------------------------------------------------- + + +async def test_store_round_trip_and_idempotent_upsert(session_factory: async_sessionmaker[AsyncSession]) -> None: + result = await _baseline() + store = CampaignStore() + async with session_factory() as session: + _row, created = await store.upsert(session, result.campaign, recorded_at=NOW) + await session.commit() + assert created + async with session_factory() as session: + _, created_again = await store.upsert(session, result.campaign, recorded_at=NOW) + await session.commit() + loaded = await store.load(session, identity_key=result.campaign.campaign_id) + assert not created_again and loaded is not None + assert loaded.state is result.campaign.state and loaded.version == result.campaign.version + assert [(r.repository, r.state) for r in loaded.records] == sorted( + (r.repository, r.state) for r in result.campaign.records + ) + assert loaded.org_blast_radius == result.campaign.org_blast_radius + assert campaign_to_dict(loaded)["impact"] == campaign_to_dict(result.campaign)["impact"] + async with session_factory() as session: + assert len(await store.list_for_workspace(session, workspace_key=WS)) == 1 + + +async def test_store_drops_repositories_no_longer_in_scope(session_factory: async_sessionmaker[AsyncSession]) -> None: + full = await _baseline() + smaller = await _run([acme_input("acme-web"), acme_input("acme-search")], policy=MIGRATE, previous=full.campaign) + store = CampaignStore() + async with session_factory() as session: + await store.upsert(session, full.campaign) + await store.upsert(session, smaller.campaign) + await session.commit() + loaded = await store.load(session, identity_key=full.campaign.campaign_id) + assert loaded is not None and sorted(loaded.repositories_in_scope) == ["acme/acme-search", "acme/acme-web"] + + +async def test_same_change_in_two_workspaces_makes_two_campaigns( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + event, hints = acme_event() + store = CampaignStore() + for workspace in ("ws-a", "ws-b"): + run = await run_campaign(workspace_key=workspace, event=event, entries=[acme_input("acme-search")], now=NOW, + hints=hints, policy=MIGRATE) + async with session_factory() as session: + await store.upsert(session, run.campaign) + await session.commit() + async with session_factory() as session: + assert len(await store.list_for_workspace(session, workspace_key="ws-a")) == 1 + assert len(await store.list_for_workspace(session, workspace_key="ws-b")) == 1 + + +async def test_dossier_is_machine_readable_and_honest() -> None: + result = await _baseline() + dossier = campaign_to_dict(result.campaign) + assert dossier["campaign"]["state"] == "action_required" + assert dossier["impact"]["organization_safe"] is False + assert dossier["impact"]["unknown"] == ["acme/acme-legacy"] + by_repo = {r["repository"]: r for r in dossier["repositories"]} + assert by_repo["acme/acme-legacy"]["requires_rediscovery"] is True + assert by_repo["acme/acme-web"]["next_action"] == "approve opening the verified migration PR" + assert by_repo["acme/acme-search"]["next_action"] is None + assert [a["repository"] for a in dossier["human_required_actions"]] == ["acme/acme-worker"] + assert dossier["data_freshness"]["acme/acme-legacy"] == "stale" + text = render_campaign_markdown(result.campaign) + assert "NOT declared safe" in text and "acme/acme-legacy" in text and "Required actions" in text + + +# -- org graph ------------------------------------------------------------------------------ + + +async def test_org_graph_shares_one_identity_across_repositories() -> None: + event, _ = acme_event() + entries: list[tuple[EnrolledRepository, Freshness, DependencyInventory | None]] = [] + for name in ("acme-web", "acme-worker", "acme-search", "acme-legacy"): + inventory = discover_dependencies(ACME / "repos" / name, repository=f"acme/{name}", + adapters=adapters_for_target(event.target)) + fresh = Freshness.STALE if name == "acme-legacy" else Freshness.FRESH + enrolled = EnrolledRepository(f"acme/{name}", last_discovery_at=FRESH_AT) + entries.append((enrolled, fresh, inventory)) + entries.append((EnrolledRepository("acme/empty"), Freshness.UNKNOWN, None)) + graph = build_org_graph(WS, entries) + identity = graph.identity("acme-ai") + assert identity is not None and identity.origin == "external" + assert identity.repositories == ("acme/acme-legacy", "acme/acme-search", "acme/acme-web", "acme/acme-worker") + empty = next(r for r in graph.repositories if r.full_name == "acme/empty") + assert not empty.has_inventory and empty.freshness is Freshness.UNKNOWN + assert any("acme/acme-legacy: evidence is stale" in n for n in graph.notes) + assert graph.to_dict()["identities"][0]["identity"] == "acme-ai" + + +# -- internal contracts (M10.5) --------------------------------------------------------------- + + +def _internal_inputs() -> tuple[InternalContract, list[RepositoryInput]]: + contract = InternalContract( + "acme-shared", InternalContractKind.PACKAGE, "acme/platform-lib", + contract_target(package_name="acme-shared", ecosystem=Ecosystem.PYPI, modules=["acme_shared"], + display_name="acme-shared"), + ) + entries = [ + RepositoryInput( + repository=EnrolledRepository(f"acme/{name}", last_discovery_at=FRESH_AT, last_discovery_commit_sha=SHA), + root=INTERNAL / "repos" / name, commit_sha=SHA, + ) + for name in ("platform-lib", "svc-orders", "svc-billing", "svc-reports", "acme-shared-fork") + ] + return contract, entries + + +async def test_internal_contract_change_maps_only_explicit_consumers() -> None: + contract, entries = _internal_inputs() + event = build_contract_change( + load_contract_file(INTERNAL / "contract" / "old.yaml"), load_contract_file(INTERNAL / "contract" / "new.yaml"), + target=contract.target, + ) + result = await run_campaign( + workspace_key=WS, event=event, entries=entries, now=NOW, policy=MIGRATE, internal_contract=contract, + ) + states = {r.repository.removeprefix("acme/"): r.state for r in result.campaign.records} + assert "platform-lib" not in states # the producer is the origin, not a consumer + assert result.excluded == {"acme/platform-lib": "producer of internal contract 'acme-shared'; not a consumer"} + assert states["svc-orders"] is RepoState.HUMAN_REQUIRED and states["svc-billing"] is RepoState.HUMAN_REQUIRED + assert states["svc-reports"] is RepoState.NOT_AFFECTED + # a repository that merely sounds related, with no dependency evidence, is not matched by name + fork = result.campaign.record_for("acme/acme-shared-fork") + assert fork is not None and fork.impact_status == "unaffected" and fork.state is RepoState.NOT_AFFECTED + assert fork.direct_consumers == 0 + assert result.campaign.origin == "internal" and result.campaign.producer_repository == "acme/platform-lib" + assert result.campaign.state is CampaignState.ACTION_REQUIRED + + +async def test_internal_contract_graph_groups_under_the_explicit_identity() -> None: + contract, entries = _internal_inputs() + event = build_contract_change( + load_contract_file(INTERNAL / "contract" / "old.yaml"), load_contract_file(INTERNAL / "contract" / "new.yaml"), + target=contract.target, + ) + graph_entries = [] + for entry in entries: + inventory = discover_dependencies(entry.root, repository=entry.repository.full_name, # type: ignore[arg-type] + adapters=adapters_for_target(event.target)) + graph_entries.append((entry.repository, Freshness.FRESH, inventory)) + graph = build_org_graph(WS, graph_entries, internal_contracts=[contract]) + node = graph.identity("internal:acme-shared") + assert node is not None and node.origin == "internal" and node.producer_repository == "acme/platform-lib" + assert node.repositories == ("acme/svc-billing", "acme/svc-orders", "acme/svc-reports") + assert "acme/acme-shared-fork" not in node.repositories + + +# -- structural invariants ---------------------------------------------------------------------- + +_CAMPAIGNS = Path(__file__).resolve().parents[2] / "patchfrog" / "campaigns" + + +def test_campaigns_package_never_imports_a_provider() -> None: + for path in _CAMPAIGNS.glob("*.py"): + for node in ast.walk(ast.parse(path.read_text())): + if isinstance(node, ast.ImportFrom) and node.module: + assert not node.module.startswith( + ("patchfrog.review", "patchfrog.routing", "anthropic", "openai", "google") + ), f"{path.name} imports {node.module}" + if isinstance(node, ast.Import): + assert not any(a.name.split(".")[0] in ("anthropic", "openai", "google") for a in node.names) + + +def test_campaigns_package_has_no_fixture_specific_strings() -> None: + for path in _CAMPAIGNS.glob("*.py"): + text = path.read_text().lower() + for word in ("acme", "chat.create", "responses.create", "stripe", "openai"): + assert word not in text.replace("an openai", ""), f"{path.name} mentions {word!r}" diff --git a/tests/support/campaigns.py b/tests/support/campaigns.py new file mode 100644 index 0000000..febd55d --- /dev/null +++ b/tests/support/campaigns.py @@ -0,0 +1,58 @@ +"""Shared builders for the M10/M11 campaign tests and demo: the fictional Acme +workspace (``tests/fixtures/campaigns/acme``) and the internal-contract +workspace (``.../internal``). Everything is built by the real engine from +fixture files -- no hand-built domain objects stand in for analysis.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from patchfrog.campaigns.domain import EnrolledRepository, RepositoryAccess +from patchfrog.campaigns.evaluate import RepositoryInput +from patchfrog.upstream.domain import ExternalChangeEvent +from patchfrog.upstream.events import build_contract_change, load_contract_file +from patchfrog.upstream.hints import ChangeHints, load_hints + +FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" / "campaigns" +ACME = FIXTURES / "acme" +INTERNAL = FIXTURES / "internal" +NOW = datetime(2026, 10, 2, 12, 0, tzinfo=UTC) +FRESH_AT = NOW - timedelta(hours=2) +STALE_AT = NOW - timedelta(days=30) +SHA = "a" * 40 + + +def acme_event() -> tuple[ExternalChangeEvent, ChangeHints]: + hints = load_hints((ACME / "contract" / "hints.yaml").read_text()) + event = build_contract_change( + load_contract_file(ACME / "contract" / "old.yaml"), load_contract_file(ACME / "contract" / "new.yaml"), + hints=hints, + ) + return event, hints + + +def acme_input( + name: str, *, discovered_at: datetime | None = FRESH_AT, root: Path | str | None = "repo", + access: RepositoryAccess = RepositoryAccess.AVAILABLE, sha: str = SHA, repos_root: Path | None = None, +) -> RepositoryInput: + base = repos_root or (ACME / "repos") + resolved = (base / name) if root == "repo" else root + assert resolved is None or isinstance(resolved, Path) + return RepositoryInput( + repository=EnrolledRepository( + full_name=f"acme/{name}", access=access, last_discovery_at=discovered_at, last_discovery_commit_sha=sha, + ), + root=resolved, commit_sha=sha, extra_workspace_paths=(ACME / "sdk_stub",), + ) + + +def acme_entries() -> list[RepositoryInput]: + """The spec's four-repository scenario.""" + + return [ + acme_input("acme-web"), + acme_input("acme-worker"), + acme_input("acme-search"), + acme_input("acme-legacy", discovered_at=STALE_AT, root=None), + ] From 2cf7fcf88ad8136f917d5460ad1833ee65d4e449 Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 05:27:59 +0300 Subject: [PATCH 04/17] M11: upstream watcher domain, source adapters, snapshot diff, watch registry, URL safety Public-engine half of continuous watching: normalized snapshots, an opaque JSON cursor, deterministic version/contract comparison with first-observation baselines and duplicate suppression, adapters for PyPI, npm, GitHub Releases, OpenAPI URLs and structured changelog feeds (no scraping), a manual-snapshot path, a SSRF-safe fetcher, typed errors with pure backoff, and the watch registry that de-duplicates fetches across workspaces. Hosted scheduling is deliberately not here. --- patchfrog/watchers/__init__.py | 26 ++ patchfrog/watchers/adapters/__init__.py | 27 ++ patchfrog/watchers/adapters/base.py | 60 ++++ .../watchers/adapters/github_releases.py | 77 ++++ patchfrog/watchers/adapters/manual.py | 30 ++ patchfrog/watchers/adapters/registries.py | 120 +++++++ patchfrog/watchers/adapters/spec_and_feed.py | 169 +++++++++ patchfrog/watchers/diff.py | 147 ++++++++ patchfrog/watchers/domain.py | 207 +++++++++++ patchfrog/watchers/fetch.py | 221 ++++++++++++ patchfrog/watchers/gate.py | 38 ++ patchfrog/watchers/net.py | 81 +++++ patchfrog/watchers/pipeline.py | 57 +++ patchfrog/watchers/registry.py | 115 ++++++ tests/unit/test_watchers_diff_registry.py | 332 ++++++++++++++++++ tests/unit/test_watchers_sources.py | 229 ++++++++++++ 16 files changed, 1936 insertions(+) create mode 100644 patchfrog/watchers/__init__.py create mode 100644 patchfrog/watchers/adapters/__init__.py create mode 100644 patchfrog/watchers/adapters/base.py create mode 100644 patchfrog/watchers/adapters/github_releases.py create mode 100644 patchfrog/watchers/adapters/manual.py create mode 100644 patchfrog/watchers/adapters/registries.py create mode 100644 patchfrog/watchers/adapters/spec_and_feed.py create mode 100644 patchfrog/watchers/diff.py create mode 100644 patchfrog/watchers/domain.py create mode 100644 patchfrog/watchers/fetch.py create mode 100644 patchfrog/watchers/gate.py create mode 100644 patchfrog/watchers/net.py create mode 100644 patchfrog/watchers/pipeline.py create mode 100644 patchfrog/watchers/registry.py create mode 100644 tests/unit/test_watchers_diff_registry.py create mode 100644 tests/unit/test_watchers_sources.py diff --git a/patchfrog/watchers/__init__.py b/patchfrog/watchers/__init__.py new file mode 100644 index 0000000..099df5d --- /dev/null +++ b/patchfrog/watchers/__init__.py @@ -0,0 +1,26 @@ +"""Continuous upstream watching (M11): detection, normalization and diff +intelligence. Hosted scheduling lives in PatchFrog Cloud.""" + +from __future__ import annotations + +from patchfrog.watchers.domain import ( + WATCHER_ENGINE_VERSION, + DetectedUpstreamChange, + WatcherCursor, + WatcherSnapshot, + WatcherSource, + WatcherSourceKind, + WatchOutcome, + WatchOutcomeKind, +) + +__all__ = [ + "WATCHER_ENGINE_VERSION", + "DetectedUpstreamChange", + "WatchOutcome", + "WatchOutcomeKind", + "WatcherCursor", + "WatcherSnapshot", + "WatcherSource", + "WatcherSourceKind", +] diff --git a/patchfrog/watchers/adapters/__init__.py b/patchfrog/watchers/adapters/__init__.py new file mode 100644 index 0000000..b95b511 --- /dev/null +++ b/patchfrog/watchers/adapters/__init__.py @@ -0,0 +1,27 @@ +"""Upstream source adapters (M11.2).""" + +from __future__ import annotations + +from patchfrog.watchers.adapters.base import SourceAdapter +from patchfrog.watchers.adapters.github_releases import GitHubReleasesAdapter +from patchfrog.watchers.adapters.registries import NpmAdapter, PyPIAdapter +from patchfrog.watchers.adapters.spec_and_feed import ChangelogFeedAdapter, OpenApiUrlAdapter +from patchfrog.watchers.domain import WatcherSourceKind + + +def default_adapters() -> dict[WatcherSourceKind, SourceAdapter]: + adapters: list[SourceAdapter] = [ + PyPIAdapter(), NpmAdapter(), GitHubReleasesAdapter(), OpenApiUrlAdapter(), ChangelogFeedAdapter(), + ] + return {adapter.kind: adapter for adapter in adapters} + + +__all__ = [ + "ChangelogFeedAdapter", + "GitHubReleasesAdapter", + "NpmAdapter", + "OpenApiUrlAdapter", + "PyPIAdapter", + "SourceAdapter", + "default_adapters", +] diff --git a/patchfrog/watchers/adapters/base.py b/patchfrog/watchers/adapters/base.py new file mode 100644 index 0000000..6e60900 --- /dev/null +++ b/patchfrog/watchers/adapters/base.py @@ -0,0 +1,60 @@ +"""Shared helpers for watcher source adapters.""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Mapping +from datetime import datetime +from typing import Any, Protocol + +from patchfrog.upstream.package_version import ParsedVersion, parse_version +from patchfrog.watchers.domain import ( + WatcherCursor, + WatcherSnapshot, + WatcherSource, + WatcherSourceKind, +) +from patchfrog.watchers.fetch import Fetcher, PermanentFetchError + + +class SourceAdapter(Protocol): + kind: WatcherSourceKind + + async def fetch( + self, source: WatcherSource, cursor: WatcherCursor, fetcher: Fetcher, *, now: datetime + ) -> WatcherSnapshot: ... + + +def version_key(parsed: ParsedVersion) -> tuple[int, int, int, int, str]: + """A total order in which a final release outranks its own pre-releases.""" + + return (parsed.major, parsed.minor, parsed.patch, 1 if parsed.prerelease is None else 0, parsed.prerelease or "") + + +def pick_latest(versions: Iterable[str], *, include_prereleases: bool) -> str | None: + best: tuple[tuple[int, int, int, int, str], str] | None = None + for raw in versions: + parsed = parse_version(raw) + if parsed is None or (parsed.prerelease is not None and not include_prereleases): + continue + key = version_key(parsed) + if best is None or key > best[0]: + best = (key, raw) + return best[1] if best else None + + +def json_object(body: bytes, *, what: str) -> Mapping[str, Any]: + try: + data = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PermanentFetchError(f"{what}: response is not valid JSON") from exc + if not isinstance(data, dict): + raise PermanentFetchError(f"{what}: unexpected JSON shape") + return data + + +def conditional_headers(cursor: WatcherCursor) -> dict[str, str]: + return {"If-None-Match": cursor.etag} if cursor.etag else {} + + +__all__ = ["SourceAdapter", "conditional_headers", "json_object", "pick_latest", "version_key"] diff --git a/patchfrog/watchers/adapters/github_releases.py b/patchfrog/watchers/adapters/github_releases.py new file mode 100644 index 0000000..2c3fc64 --- /dev/null +++ b/patchfrog/watchers/adapters/github_releases.py @@ -0,0 +1,77 @@ +"""GitHub Releases adapter (M11.2): the documented REST releases endpoint.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from urllib.parse import quote + +from patchfrog.upstream.events import ContractLoadError, release_from_metadata +from patchfrog.watchers.adapters.base import conditional_headers, pick_latest +from patchfrog.watchers.domain import ( + SnapshotKind, + WatcherCursor, + WatcherSnapshot, + WatcherSource, + WatcherSourceKind, +) +from patchfrog.watchers.fetch import Fetcher, PermanentFetchError, raise_for_response + +GITHUB_API = "https://api.github.com" +_PER_PAGE = 30 + + +class GitHubReleasesAdapter: + kind = WatcherSourceKind.GITHUB_RELEASES + + def __init__(self, *, api_base_url: str = GITHUB_API) -> None: + self._base = api_base_url.rstrip("/") + + async def fetch( + self, source: WatcherSource, cursor: WatcherCursor, fetcher: Fetcher, *, now: datetime + ) -> WatcherSnapshot: + locator = source.normalized_locator + owner, _, repo = locator.partition("/") + if not owner or not repo or "/" in repo: + raise PermanentFetchError(f"GitHub releases source must be 'owner/repo', got {source.locator!r}") + url = f"{self._base}/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/releases?per_page={_PER_PAGE}" + headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} + headers.update(conditional_headers(cursor)) + response = await fetcher.get(url, headers=headers) + raise_for_response(response, what=f"GitHub releases {locator}") + if response.not_modified: + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, etag=cursor.etag, not_modified=True + ) + import json + + try: + data: Any = json.loads(response.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PermanentFetchError("GitHub releases: response is not valid JSON") from exc + if not isinstance(data, list): + raise PermanentFetchError("GitHub releases: unexpected response shape") + by_version: dict[str, dict[str, Any]] = {} + for item in data: + if not isinstance(item, dict) or item.get("draft"): + continue + if item.get("prerelease") and not source.include_prereleases: + continue + try: + release = release_from_metadata(item) + except ContractLoadError: + continue + by_version.setdefault(release.version, item) + latest = pick_latest(by_version, include_prereleases=source.include_prereleases) + if latest is None: + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, etag=response.etag, + notes=("no usable release found",), + ) + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, latest_version=latest, + release=release_from_metadata(by_version[latest]), etag=response.etag, + ) + + +__all__ = ["GITHUB_API", "GitHubReleasesAdapter"] diff --git a/patchfrog/watchers/adapters/manual.py b/patchfrog/watchers/adapters/manual.py new file mode 100644 index 0000000..162327c --- /dev/null +++ b/patchfrog/watchers/adapters/manual.py @@ -0,0 +1,30 @@ +"""Manually submitted upstream snapshots (M11.2): the "I know about this +change" path. Nothing is fetched; the caller supplies a contract document or a +version, and it flows through the same snapshot -> diff -> event pipeline.""" + +from __future__ import annotations + +from datetime import datetime + +from patchfrog.upstream.domain import DependencyRelease +from patchfrog.upstream.events import LoadedContract +from patchfrog.watchers.domain import SnapshotKind, WatcherSnapshot, WatcherSource + + +def snapshot_from_contract(source: WatcherSource, contract: LoadedContract, *, now: datetime) -> WatcherSnapshot: + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.CONTRACT, fetched_at=now, latest_version=contract.version, + contract=contract, + ) + + +def snapshot_from_version( + source: WatcherSource, version: str, *, now: datetime, release: DependencyRelease | None = None +) -> WatcherSnapshot: + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, latest_version=version, + release=release or DependencyRelease(version=version[:64]), + ) + + +__all__ = ["snapshot_from_contract", "snapshot_from_version"] diff --git a/patchfrog/watchers/adapters/registries.py b/patchfrog/watchers/adapters/registries.py new file mode 100644 index 0000000..805e94f --- /dev/null +++ b/patchfrog/watchers/adapters/registries.py @@ -0,0 +1,120 @@ +"""Package-registry adapters: PyPI and npm (M11.2). Both read the registries' +documented public JSON APIs -- no scraping.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from urllib.parse import quote + +from patchfrog.upstream.domain import DependencyRelease +from patchfrog.upstream.package_version import parse_version +from patchfrog.watchers.adapters.base import conditional_headers, json_object, pick_latest +from patchfrog.watchers.domain import ( + SnapshotKind, + WatcherCursor, + WatcherSnapshot, + WatcherSource, + WatcherSourceKind, +) +from patchfrog.watchers.fetch import Fetcher, PermanentFetchError, raise_for_response + +PYPI_BASE = "https://pypi.org" +NPM_BASE = "https://registry.npmjs.org" + + +def _not_modified(source: WatcherSource, now: datetime, etag: str | None) -> WatcherSnapshot: + return WatcherSnapshot(source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, etag=etag, not_modified=True) + + +class PyPIAdapter: + kind = WatcherSourceKind.PYPI + + def __init__(self, *, base_url: str = PYPI_BASE) -> None: + self._base = base_url.rstrip("/") + + async def fetch( + self, source: WatcherSource, cursor: WatcherCursor, fetcher: Fetcher, *, now: datetime + ) -> WatcherSnapshot: + name = quote(source.normalized_locator, safe="") + url = f"{self._base}/pypi/{name}/json" + response = await fetcher.get(url, headers=conditional_headers(cursor)) + raise_for_response(response, what=f"PyPI {source.normalized_locator}") + if response.not_modified: + return _not_modified(source, now, cursor.etag) + data = json_object(response.body, what="PyPI") + releases = data.get("releases") + if not isinstance(releases, dict): + raise PermanentFetchError("PyPI: response has no 'releases'") + usable: dict[str, str | None] = {} + for version, files in releases.items(): + if not isinstance(files, list) or not files: + continue + live = [f for f in files if isinstance(f, dict) and not f.get("yanked")] + if not live: + continue + times = sorted(str(f["upload_time_iso_8601"]) for f in live if f.get("upload_time_iso_8601")) + usable[str(version)] = times[0] if times else None + latest = pick_latest(usable, include_prereleases=source.include_prereleases) + if latest is None: + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, etag=response.etag, + notes=("no usable release found",), + ) + release = DependencyRelease( + version=latest[:64], published_at=usable[latest], + url=f"{self._base}/project/{name}/{quote(latest, safe='')}/", + prerelease=bool((parsed := parse_version(latest)) and parsed.prerelease), + ) + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, latest_version=latest, release=release, + etag=response.etag, + ) + + +class NpmAdapter: + kind = WatcherSourceKind.NPM + + def __init__(self, *, base_url: str = NPM_BASE) -> None: + self._base = base_url.rstrip("/") + + async def fetch( + self, source: WatcherSource, cursor: WatcherCursor, fetcher: Fetcher, *, now: datetime + ) -> WatcherSnapshot: + name = source.normalized_locator + # scoped packages keep a literal "@" and encode the single "/" (the registry's documented form) + url = f"{self._base}/{name.replace('/', '%2F', 1) if name.startswith('@') else quote(name, safe='')}" + response = await fetcher.get(url, headers=conditional_headers(cursor)) + raise_for_response(response, what=f"npm {name}") + if response.not_modified: + return _not_modified(source, now, cursor.etag) + data = json_object(response.body, what="npm") + versions = data.get("versions") + if not isinstance(versions, dict): + raise PermanentFetchError("npm: response has no 'versions'") + tags: Any = data.get("dist-tags") + tagged = tags.get("latest") if isinstance(tags, dict) else None + live = {v for v, meta in versions.items() if not (isinstance(meta, dict) and meta.get("deprecated"))} + latest = None + if not source.include_prereleases and isinstance(tagged, str) and tagged in live: + latest = pick_latest([tagged], include_prereleases=False) + if latest is None: + latest = pick_latest(live, include_prereleases=source.include_prereleases) + if latest is None: + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, etag=response.etag, + notes=("no usable release found",), + ) + times: Any = data.get("time") + published = times.get(latest) if isinstance(times, dict) else None + release = DependencyRelease( + version=latest[:64], published_at=str(published)[:64] if published else None, + url=f"https://www.npmjs.com/package/{name}/v/{quote(latest, safe='')}", + ) + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, latest_version=latest, release=release, + etag=response.etag, + ) + + +__all__ = ["NPM_BASE", "PYPI_BASE", "NpmAdapter", "PyPIAdapter"] diff --git a/patchfrog/watchers/adapters/spec_and_feed.py b/patchfrog/watchers/adapters/spec_and_feed.py new file mode 100644 index 0000000..14d425e --- /dev/null +++ b/patchfrog/watchers/adapters/spec_and_feed.py @@ -0,0 +1,169 @@ +"""OpenAPI-URL and changelog-feed adapters (M11.2). + +*OpenAPI*: fetches a spec document and builds the same normalized contract +M5/M6 use, so a revision diffs structurally. Remote ``$ref`` documents are +**not** resolved (only the fetched document is read). + +*Changelog feed*: reads Atom, RSS or JSON Feed only -- structured feeds, never +HTML scraping. A feed entry whose title carries a version becomes a version +signal; one without a version is ignored (a changelog sentence is not proof of +anything). The XML parser rejects DTDs/entities outright. +""" + +from __future__ import annotations + +import hashlib +import json +import xml.etree.ElementTree as ET +from datetime import datetime +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +from patchfrog.dependencies.openapi import load_spec +from patchfrog.upstream.domain import DependencyRelease +from patchfrog.upstream.events import ( + ContractLoadError, + parse_contract_document, + release_from_metadata, +) +from patchfrog.watchers.adapters.base import conditional_headers, pick_latest +from patchfrog.watchers.domain import ( + SnapshotKind, + WatcherCursor, + WatcherSnapshot, + WatcherSource, + WatcherSourceKind, +) +from patchfrog.watchers.fetch import Fetcher, PermanentFetchError, raise_for_response + +MAX_SPEC_BYTES = 5_000_000 +MAX_FEED_BYTES = 1_000_000 +MAX_FEED_ENTRIES = 50 + + +def _display_ref(url: str) -> str: + parts = urlsplit(url) + return urlunsplit((parts.scheme, parts.hostname or "", parts.path[:256], "", "")) + + +class OpenApiUrlAdapter: + kind = WatcherSourceKind.OPENAPI_URL + + async def fetch( + self, source: WatcherSource, cursor: WatcherCursor, fetcher: Fetcher, *, now: datetime + ) -> WatcherSnapshot: + url = source.normalized_locator + response = await fetcher.get(url, headers=conditional_headers(cursor), max_bytes=MAX_SPEC_BYTES) + raise_for_response(response, what=f"OpenAPI {_display_ref(url)}") + if response.not_modified: + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.CONTRACT, fetched_at=now, etag=cursor.etag, not_modified=True + ) + try: + text = response.body.decode("utf-8") + except UnicodeDecodeError as exc: + raise PermanentFetchError("OpenAPI: response is not UTF-8 text") from exc + spec = load_spec(text) + if spec is None: + raise PermanentFetchError("OpenAPI: the document is not an OpenAPI 3.x / Swagger 2.0 specification") + try: + contract = parse_contract_document(spec, ref=_display_ref(url)) + except ContractLoadError as exc: + raise PermanentFetchError(str(exc)) from exc + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.CONTRACT, fetched_at=now, latest_version=contract.version, + contract=contract, etag=response.etag, + ) + + +def _entries_from_json_feed(data: Any) -> list[dict[str, str]]: + items = data.get("items") if isinstance(data, dict) else None + out: list[dict[str, str]] = [] + for item in items if isinstance(items, list) else []: + if isinstance(item, dict): + out.append( + { + "title": str(item.get("title") or ""), "url": str(item.get("url") or ""), + "published": str(item.get("date_published") or ""), + "body": str(item.get("content_text") or item.get("content_html") or ""), + } + ) + return out + + +def _local(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _entries_from_xml(body: bytes) -> list[dict[str, str]]: + head = body[:4096].lower() + if b" WatcherSnapshot: + url = source.normalized_locator + response = await fetcher.get(url, headers=conditional_headers(cursor), max_bytes=MAX_FEED_BYTES) + raise_for_response(response, what=f"feed {_display_ref(url)}") + if response.not_modified: + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, etag=cursor.etag, not_modified=True + ) + stripped = response.body.lstrip() + if stripped.startswith(b"{"): + try: + entries = _entries_from_json_feed(json.loads(stripped.decode("utf-8"))) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PermanentFetchError("feed: not valid JSON Feed") from exc + else: + entries = _entries_from_xml(stripped) + releases: dict[str, DependencyRelease] = {} + for entry in entries[:MAX_FEED_ENTRIES]: + try: + release = release_from_metadata( + {"tag": entry["title"], "published_at": entry["published"] or None, "url": entry["url"], + "notes": entry["body"]} + ) + except ContractLoadError: + continue + releases.setdefault(release.version, release) + latest = pick_latest(releases, include_prereleases=source.include_prereleases) + if latest is None: + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, etag=response.etag, + notes=("no version-bearing entry found",), + ) + digest = hashlib.sha256(latest.encode()).hexdigest()[:12] + return WatcherSnapshot( + source_key=source.key, kind=SnapshotKind.VERSION, fetched_at=now, latest_version=latest, + release=releases[latest], etag=response.etag, notes=(f"feed latest {digest}",), + ) + + +__all__ = ["MAX_FEED_BYTES", "MAX_SPEC_BYTES", "ChangelogFeedAdapter", "OpenApiUrlAdapter"] diff --git a/patchfrog/watchers/diff.py b/patchfrog/watchers/diff.py new file mode 100644 index 0000000..294b662 --- /dev/null +++ b/patchfrog/watchers/diff.py @@ -0,0 +1,147 @@ +"""Snapshot comparison: ``(source, new snapshot, prior cursor) -> outcome`` +(M11.3). + +Deterministic and side-effect free. The same inputs always yield the same +outcome, and the emitted event's fingerprint is the M6 event fingerprint, so +the same upstream change observed by two pollers (or twice) is one event. + +First observation records a **baseline** and emits nothing: a watcher never +replays history it was not watching. +""" + +from __future__ import annotations + +import json +from dataclasses import replace + +from patchfrog.upstream.domain import ExternalChangeEvent, ExternalChangeSource +from patchfrog.upstream.events import ( + ContractLoadError, + LoadedContract, + build_contract_change, + build_version_change, + contract_from_registry_snapshot, +) +from patchfrog.upstream.hints import EMPTY_HINTS, ChangeHints +from patchfrog.upstream.package_version import parse_version +from patchfrog.watchers.adapters.base import version_key +from patchfrog.watchers.domain import ( + MAX_CURSOR_CONTRACT_BYTES, + MAX_EMITTED_FINGERPRINTS, + DetectedUpstreamChange, + SnapshotKind, + WatcherCursor, + WatcherSnapshot, + WatcherSource, + WatcherSourceKind, + WatchOutcome, + WatchOutcomeKind, +) + +_VERSION_SOURCE = { + WatcherSourceKind.PYPI: ExternalChangeSource.PACKAGE_VERSION, + WatcherSourceKind.NPM: ExternalChangeSource.PACKAGE_VERSION, + WatcherSourceKind.MANUAL: ExternalChangeSource.PACKAGE_VERSION, + WatcherSourceKind.GITHUB_RELEASES: ExternalChangeSource.GITHUB_RELEASE, + WatcherSourceKind.CHANGELOG_FEED: ExternalChangeSource.CHANGELOG, +} + + +def _contract_json(contract: LoadedContract) -> str | None: + rendered = json.dumps(contract.normalized, sort_keys=True, separators=(",", ":"), default=str) + return rendered if len(rendered.encode()) <= MAX_CURSOR_CONTRACT_BYTES else None + + +def _remember(cursor: WatcherCursor, fingerprint: str) -> tuple[str, ...]: + return (*[f for f in cursor.emitted if f != fingerprint], fingerprint)[-MAX_EMITTED_FINGERPRINTS:] + + +def _detected(source: WatcherSource, event: ExternalChangeEvent) -> DetectedUpstreamChange: + return DetectedUpstreamChange( + source_key=source.key, source_kind=source.kind, event=event, previous_version=event.old.version, + new_version=event.new.version, + ) + + +def detect_change( + source: WatcherSource, snapshot: WatcherSnapshot, cursor: WatcherCursor, *, hints: ChangeHints = EMPTY_HINTS +) -> WatchOutcome: + base = replace( + cursor, observations=cursor.observations + 1, last_observed_at=snapshot.fetched_at, + etag=snapshot.etag or cursor.etag, + ) + if snapshot.not_modified: + return WatchOutcome(WatchOutcomeKind.UNCHANGED, base, notes=("upstream reported not modified",)) + if snapshot.kind is SnapshotKind.VERSION: + return _detect_version(source, snapshot, base) + return _detect_contract(source, snapshot, base, hints) + + +def _detect_version(source: WatcherSource, snapshot: WatcherSnapshot, cursor: WatcherCursor) -> WatchOutcome: + latest = snapshot.latest_version + if latest is None: + return WatchOutcome(WatchOutcomeKind.UNCHANGED, cursor, notes=snapshot.notes or ("no version observed",)) + new = parse_version(latest) + if new is None: + return WatchOutcome(WatchOutcomeKind.SKIPPED, cursor, notes=(f"unparseable version {latest[:40]!r}",)) + if cursor.last_version is None: + return WatchOutcome(WatchOutcomeKind.BASELINE, replace(cursor, last_version=latest)) + old = parse_version(cursor.last_version) + if old is None: + return WatchOutcome(WatchOutcomeKind.BASELINE, replace(cursor, last_version=latest), + notes=("previous version was unparseable; baseline reset",)) + if version_key(new) == version_key(old): + return WatchOutcome(WatchOutcomeKind.UNCHANGED, cursor) + if version_key(new) < version_key(old): + return WatchOutcome(WatchOutcomeKind.UNCHANGED, cursor, + notes=("upstream latest decreased (yank/rollback); cursor kept at the higher version",)) + event = build_version_change( + source.target, cursor.last_version, latest, source=_VERSION_SOURCE[source.kind], release=snapshot.release, + observed_at=snapshot.fetched_at, + ) + return _emit(source, cursor, event, new_version=latest) + + +def _detect_contract( + source: WatcherSource, snapshot: WatcherSnapshot, cursor: WatcherCursor, hints: ChangeHints +) -> WatchOutcome: + contract = snapshot.contract + if contract is None: + return WatchOutcome(WatchOutcomeKind.SKIPPED, cursor, notes=("contract snapshot carried no contract",)) + remembered = replace( + cursor, last_contract_fingerprint=contract.fingerprint, last_contract_json=_contract_json(contract), + last_contract_ref=contract.ref, last_contract_version=contract.version, last_version=contract.version or cursor.last_version, + ) + if cursor.last_contract_fingerprint is None: + return WatchOutcome(WatchOutcomeKind.BASELINE, remembered) + if cursor.last_contract_fingerprint == contract.fingerprint: + return WatchOutcome(WatchOutcomeKind.UNCHANGED, replace(cursor, last_contract_version=contract.version)) + if cursor.last_contract_json is None: + return WatchOutcome( + WatchOutcomeKind.SKIPPED, remembered, + notes=("the previous contract was too large to keep; the change cannot be diffed structurally",), + ) + try: + old = contract_from_registry_snapshot( + cursor.last_contract_json, fingerprint=cursor.last_contract_fingerprint, + ref=cursor.last_contract_ref or "previous", version=cursor.last_contract_version, + ) + event = build_contract_change( + old, contract, hints=hints, target=source.target, + source=ExternalChangeSource.MANUAL_CONTRACT_PAIR if source.kind is WatcherSourceKind.MANUAL else None, + release=snapshot.release, observed_at=snapshot.fetched_at, + ) + except ContractLoadError as exc: + return WatchOutcome(WatchOutcomeKind.SKIPPED, remembered, notes=(f"cannot diff: {exc}"[:300],)) + return _emit(source, remembered, event, new_version=contract.version) + + +def _emit(source: WatcherSource, cursor: WatcherCursor, event: ExternalChangeEvent, *, new_version: str | None) -> WatchOutcome: + advanced = replace(cursor, last_version=new_version or cursor.last_version) + if event.fingerprint in cursor.emitted: + return WatchOutcome(WatchOutcomeKind.DUPLICATE_SUPPRESSED, advanced, notes=("this change was already emitted",)) + advanced = replace(advanced, emitted=_remember(cursor, event.fingerprint)) + return WatchOutcome(WatchOutcomeKind.CHANGED, advanced, change=_detected(source, event)) + + +__all__ = ["detect_change"] diff --git a/patchfrog/watchers/domain.py b/patchfrog/watchers/domain.py new file mode 100644 index 0000000..f1f45d6 --- /dev/null +++ b/patchfrog/watchers/domain.py @@ -0,0 +1,207 @@ +"""Pure domain model for continuous upstream watching (M11.3). + +A *watcher* turns "something upstream might have changed" into at most one +:class:`~patchfrog.upstream.domain.ExternalChangeEvent` -- the same event +type M6 builds from manual input -- so a detected change flows into exactly +the M6 -> M10 -> M7 -> M8 -> M9 pipeline a hand-fed change does. + +Responsibilities that live here (public engine): fetch -> normalize -> +fingerprint -> compare with the prior snapshot -> emit a candidate change -> +avoid duplicates. Responsibilities that do **not**: *when* to poll, retry +scheduling, cursor storage, rate-limit state across workers -- those operate +the hosted service (Cloud). + +A :class:`WatcherCursor` is the only state a watcher needs between polls. It +serializes to a small JSON document the hosting service stores opaquely. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import StrEnum +from typing import Any + +from patchfrog.upstream.domain import ( + DependencyRelease, + DependencyTarget, + ExternalChangeEvent, + normalize_package_name, +) +from patchfrog.upstream.events import LoadedContract + +#: Bumped when normalization, snapshot comparison or cursor semantics change. +WATCHER_ENGINE_VERSION = 1 +CURSOR_FORMAT_VERSION = 1 +#: Fingerprints of recently emitted events, kept to suppress duplicates. +MAX_EMITTED_FINGERPRINTS = 50 +#: A stored previous contract larger than this is kept as a fingerprint only. +MAX_CURSOR_CONTRACT_BYTES = 256_000 + + +class WatcherSourceKind(StrEnum): + PYPI = "pypi" + NPM = "npm" + GITHUB_RELEASES = "github_releases" + OPENAPI_URL = "openapi_url" + CHANGELOG_FEED = "changelog_feed" + MANUAL = "manual" + + +#: Source kinds whose evidence is a version only (no structural contract). +VERSION_ONLY_KINDS = frozenset( + {WatcherSourceKind.PYPI, WatcherSourceKind.NPM, WatcherSourceKind.GITHUB_RELEASES, WatcherSourceKind.CHANGELOG_FEED} +) + + +@dataclass(frozen=True, slots=True) +class WatcherSource: + """One upstream thing to watch, plus the dependency identity a change to + it refers to (what consumers are matched against).""" + + kind: WatcherSourceKind + locator: str + target: DependencyTarget + include_prereleases: bool = False + + @property + def normalized_locator(self) -> str: + text = self.locator.strip() + if self.kind is WatcherSourceKind.PYPI: + return normalize_package_name(text) + if self.kind is WatcherSourceKind.NPM: + return text.lower() + if self.kind is WatcherSourceKind.GITHUB_RELEASES: + return text.strip("/").lower() + if self.kind in (WatcherSourceKind.OPENAPI_URL, WatcherSourceKind.CHANGELOG_FEED): + return text.split("#", 1)[0] + return text + + @property + def key(self) -> str: + """Stable identity for de-duplicating fetches across workspaces.""" + + return f"{self.kind.value}:{self.normalized_locator}" + + +class SnapshotKind(StrEnum): + VERSION = "version" + CONTRACT = "contract" + + +@dataclass(frozen=True, slots=True) +class WatcherSnapshot: + """The normalized result of one successful fetch.""" + + source_key: str + kind: SnapshotKind + fetched_at: datetime + latest_version: str | None = None + release: DependencyRelease | None = None + #: In memory only; only its fingerprint (and bounded JSON) enter the cursor. + contract: LoadedContract | None = None + etag: str | None = None + #: The upstream said "not modified" -- nothing to compare. + not_modified: bool = False + notes: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class WatcherCursor: + """Everything a watcher remembers between polls.""" + + source_key: str + last_version: str | None = None + last_contract_fingerprint: str | None = None + last_contract_json: str | None = None + last_contract_ref: str | None = None + last_contract_version: str | None = None + etag: str | None = None + emitted: tuple[str, ...] = () + observations: int = 0 + last_observed_at: datetime | None = None + + @classmethod + def empty(cls, source_key: str) -> WatcherCursor: + return cls(source_key=source_key) + + @property + def has_baseline(self) -> bool: + return self.last_version is not None or self.last_contract_fingerprint is not None + + def to_json(self) -> str: + return json.dumps( + { + "v": CURSOR_FORMAT_VERSION, "source_key": self.source_key, "last_version": self.last_version, + "last_contract_fingerprint": self.last_contract_fingerprint, + "last_contract_json": self.last_contract_json, "last_contract_ref": self.last_contract_ref, + "last_contract_version": self.last_contract_version, "etag": self.etag, + "emitted": list(self.emitted), "observations": self.observations, + "last_observed_at": self.last_observed_at.isoformat() if self.last_observed_at else None, + }, + sort_keys=True, + ) + + @classmethod + def from_json(cls, raw: str) -> WatcherCursor: + data: dict[str, Any] = json.loads(raw) + if data.get("v") != CURSOR_FORMAT_VERSION: + raise ValueError(f"unsupported watcher cursor format {data.get('v')!r}") + observed = data.get("last_observed_at") + return cls( + source_key=str(data["source_key"]), last_version=data.get("last_version"), + last_contract_fingerprint=data.get("last_contract_fingerprint"), + last_contract_json=data.get("last_contract_json"), last_contract_ref=data.get("last_contract_ref"), + last_contract_version=data.get("last_contract_version"), etag=data.get("etag"), + emitted=tuple(data.get("emitted", ())), observations=int(data.get("observations", 0)), + last_observed_at=(datetime.fromisoformat(observed).astimezone(UTC) if observed else None), + ) + + +@dataclass(frozen=True, slots=True) +class DetectedUpstreamChange: + source_key: str + source_kind: WatcherSourceKind + event: ExternalChangeEvent + previous_version: str | None + new_version: str | None + + @property + def fingerprint(self) -> str: + return self.event.fingerprint + + +class WatchOutcomeKind(StrEnum): + #: First observation: baseline recorded, nothing emitted (history is never replayed). + BASELINE = "baseline" + UNCHANGED = "unchanged" + CHANGED = "changed" + DUPLICATE_SUPPRESSED = "duplicate_suppressed" + #: A change was seen but cannot become an event (e.g. unparseable version). + SKIPPED = "skipped" + + +@dataclass(frozen=True, slots=True) +class WatchOutcome: + kind: WatchOutcomeKind + cursor: WatcherCursor + change: DetectedUpstreamChange | None = None + notes: tuple[str, ...] = field(default_factory=tuple) + + +__all__ = [ + "CURSOR_FORMAT_VERSION", + "MAX_CURSOR_CONTRACT_BYTES", + "MAX_EMITTED_FINGERPRINTS", + "VERSION_ONLY_KINDS", + "WATCHER_ENGINE_VERSION", + "DetectedUpstreamChange", + "SnapshotKind", + "WatchOutcome", + "WatchOutcomeKind", + "WatcherCursor", + "WatcherSnapshot", + "WatcherSource", + "WatcherSourceKind", +] diff --git a/patchfrog/watchers/fetch.py b/patchfrog/watchers/fetch.py new file mode 100644 index 0000000..f514cd1 --- /dev/null +++ b/patchfrog/watchers/fetch.py @@ -0,0 +1,221 @@ +"""HTTP fetching for watchers: a small protocol, typed errors, a safe real +implementation and an in-memory fake (M11.2/M11.4). + +No watcher adapter talks to the network directly; they receive a +:class:`Fetcher`. Tests use :class:`FakeFetcher`; the hosted service uses +:class:`HttpxFetcher`. Neither ever logs a header value. +""" + +from __future__ import annotations + +import random +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Protocol +from urllib.parse import urljoin, urlsplit + +import httpx + +from patchfrog.watchers.net import Resolver, UnsafeUrlError, validate_public_url + +DEFAULT_MAX_BYTES = 5_000_000 +DEFAULT_TIMEOUT_SECONDS = 15.0 +MAX_REDIRECTS = 3 +USER_AGENT = "patchfrog-upstream-watcher/1 (+https://github.com/kadireren7/patchfrog)" + + +class WatcherError(Exception): + #: Whether retrying later can help. + retryable: bool = False + + +class RateLimitedError(WatcherError): + retryable = True + + def __init__(self, message: str, *, retry_after_seconds: float | None = None) -> None: + super().__init__(message) + self.retry_after_seconds = retry_after_seconds + + +class TransientFetchError(WatcherError): + retryable = True + + +class PermanentFetchError(WatcherError): + retryable = False + + +class UnsafeSourceError(PermanentFetchError): + pass + + +@dataclass(frozen=True, slots=True) +class FetchResponse: + status: int + body: bytes = b"" + etag: str | None = None + #: Seconds, from ``Retry-After`` / rate-limit reset headers, if given. + retry_after_seconds: float | None = None + rate_limit_remaining: int | None = None + + @property + def not_modified(self) -> bool: + return self.status == 304 + + +class Fetcher(Protocol): + async def get( + self, url: str, *, headers: Mapping[str, str] | None = None, max_bytes: int = DEFAULT_MAX_BYTES + ) -> FetchResponse: ... + + +def raise_for_response(response: FetchResponse, *, what: str) -> None: + """Maps an HTTP status to a typed error. 2xx and 304 return normally.""" + + status = response.status + if 200 <= status < 300 or status == 304: + return + if status == 429 or (status == 403 and response.rate_limit_remaining == 0): + raise RateLimitedError(f"{what}: rate limited (HTTP {status})", retry_after_seconds=response.retry_after_seconds) + if status in (408, 425) or 500 <= status < 600: + raise TransientFetchError(f"{what}: upstream error (HTTP {status})") + raise PermanentFetchError(f"{what}: HTTP {status}") + + +def backoff_delay_seconds( + attempt: int, + *, + base: float = 30.0, + cap: float = 3600.0, + retry_after: float | None = None, + jitter: Callable[[], float] = random.random, +) -> float: + """Exponential backoff with full jitter on the upper half; a server's + ``Retry-After`` is a floor. ``attempt`` starts at 1. Pure given ``jitter``.""" + + if attempt < 1: + raise ValueError("attempt starts at 1") + ceiling = min(cap, base * float(2 ** (attempt - 1))) + delay = ceiling / 2 + (ceiling / 2) * jitter() + if retry_after is not None: + delay = max(delay, min(retry_after, cap)) + return min(delay, cap) + + +class HttpxFetcher: + """Real fetcher. Every request (and every redirect hop) is validated as a + public URL; responses are size-capped; no cookies; ``auth_headers`` are + only ever sent to the exact host they were registered for.""" + + def __init__( + self, + *, + client: httpx.AsyncClient | None = None, + auth_headers: Mapping[str, Mapping[str, str]] | None = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + allow_http: bool = False, + resolver: Resolver | None = None, + ) -> None: + self._client = client or httpx.AsyncClient(timeout=timeout, follow_redirects=False) + self._auth = {host.lower(): dict(h) for host, h in (auth_headers or {}).items()} + self._allow_http = allow_http + self._resolver = resolver + + async def get( + self, url: str, *, headers: Mapping[str, str] | None = None, max_bytes: int = DEFAULT_MAX_BYTES + ) -> FetchResponse: + current = url + for _hop in range(MAX_REDIRECTS + 1): + try: + await validate_public_url(current, allow_http=self._allow_http, resolver=self._resolver) + except UnsafeUrlError as exc: + raise UnsafeSourceError(f"refusing to fetch: {exc}") from exc + host = (urlsplit(current).hostname or "").lower() + request_headers = {"User-Agent": USER_AGENT, "Accept": "application/json, application/yaml, */*"} + request_headers.update(headers or {}) + request_headers.update(self._auth.get(host, {})) + try: + async with self._client.stream("GET", current, headers=request_headers) as response: + if response.status_code in (301, 302, 303, 307, 308): + location = response.headers.get("location") + if not location: + raise PermanentFetchError("redirect without a location") + current = urljoin(current, location) + continue + chunks: list[bytes] = [] + size = 0 + async for chunk in response.aiter_bytes(): + size += len(chunk) + if size > max_bytes: + raise PermanentFetchError(f"response larger than {max_bytes} bytes") + chunks.append(chunk) + return FetchResponse( + status=response.status_code, body=b"".join(chunks), etag=response.headers.get("etag"), + retry_after_seconds=_retry_after(response.headers), + rate_limit_remaining=_int_header(response.headers.get("x-ratelimit-remaining")), + ) + except httpx.TimeoutException as exc: + raise TransientFetchError("request timed out") from exc + except httpx.TransportError as exc: + raise TransientFetchError(f"transport error: {exc.__class__.__name__}") from exc + raise PermanentFetchError("too many redirects") + + async def aclose(self) -> None: + await self._client.aclose() + + +def _int_header(value: str | None) -> int | None: + try: + return int(value) if value is not None else None + except ValueError: + return None + + +def _retry_after(headers: Mapping[str, str]) -> float | None: + raw = headers.get("retry-after") + if raw is not None: + try: + return max(0.0, float(raw)) + except ValueError: + return None + return None + + +@dataclass +class FakeFetcher: + """Deterministic in-memory fetcher: ``routes`` maps a URL to a response + or an exception to raise. Records every call (URL and header *names*).""" + + routes: dict[str, FetchResponse | Exception] = field(default_factory=dict) + calls: list[str] = field(default_factory=list) + header_names: list[tuple[str, ...]] = field(default_factory=list) + + async def get( + self, url: str, *, headers: Mapping[str, str] | None = None, max_bytes: int = DEFAULT_MAX_BYTES + ) -> FetchResponse: + self.calls.append(url) + self.header_names.append(tuple(sorted(headers or {}))) + outcome = self.routes.get(url) + if outcome is None: + return FetchResponse(status=404) + if isinstance(outcome, Exception): + raise outcome + if len(outcome.body) > max_bytes: + raise PermanentFetchError(f"response larger than {max_bytes} bytes") + return outcome + + +__all__ = [ + "DEFAULT_MAX_BYTES", + "FakeFetcher", + "FetchResponse", + "Fetcher", + "HttpxFetcher", + "PermanentFetchError", + "RateLimitedError", + "TransientFetchError", + "UnsafeSourceError", + "WatcherError", + "backoff_delay_seconds", + "raise_for_response", +] diff --git a/patchfrog/watchers/gate.py b/patchfrog/watchers/gate.py new file mode 100644 index 0000000..d7b51d0 --- /dev/null +++ b/patchfrog/watchers/gate.py @@ -0,0 +1,38 @@ +"""Campaign launch gate (M11.5/M11.11). + +A detected change becomes a campaign only if it carries enough *evidence of +impact*. By default that means ``BREAKING`` -- structural proof (an OpenAPI +diff, an SDK-surface diff). A bare version bump of a package is +``REVIEW_REQUIRED`` at most (the evidence is a version number, not a proven +incompatibility) and is recorded as an event without opening a campaign, +unless a workspace explicitly lowers the bar. Weak signals never become +standalone work. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from patchfrog.upstream.domain import ChangeRisk, ExternalChangeEvent + +_RANK = {ChangeRisk.SAFE: 0, ChangeRisk.LOW_RISK: 1, ChangeRisk.REVIEW_REQUIRED: 2, ChangeRisk.BREAKING: 3} +DEFAULT_MIN_RISK = ChangeRisk.BREAKING + + +@dataclass(frozen=True, slots=True) +class LaunchDecision: + launch: bool + reason: str + + +def should_launch_campaign(event: ExternalChangeEvent, *, min_risk: ChangeRisk = DEFAULT_MIN_RISK) -> LaunchDecision: + risk = event.classification.risk + if _RANK[risk] >= _RANK[min_risk]: + return LaunchDecision(True, f"change risk {risk.value} meets the campaign threshold ({min_risk.value})") + return LaunchDecision( + False, + f"change risk {risk.value} is below the campaign threshold ({min_risk.value}); recorded, no campaign opened", + ) + + +__all__ = ["DEFAULT_MIN_RISK", "LaunchDecision", "should_launch_campaign"] diff --git a/patchfrog/watchers/net.py b/patchfrog/watchers/net.py new file mode 100644 index 0000000..d121461 --- /dev/null +++ b/patchfrog/watchers/net.py @@ -0,0 +1,81 @@ +"""Outbound-URL safety for hosted watchers (M11.2). + +Watcher sources include user-configured URLs (OpenAPI specs, changelog +feeds). A hosted service that fetches arbitrary URLs is an SSRF primitive +unless it refuses internal targets. :func:`validate_public_url` is the one +gate every fetch passes through. + +Known limitation (documented in ``docs/watchers.md``): a hostname is +resolved here and again by the HTTP client; a hostile DNS server could answer +differently the second time. Deployments should *also* restrict the watcher +process's egress at the network layer. +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import socket +from collections.abc import Awaitable, Callable +from urllib.parse import urlsplit + +#: ports a watcher may fetch from. +ALLOWED_PORTS = frozenset({80, 443, 8080, 8443}) + +Resolver = Callable[[str, int], Awaitable[list[str]]] + + +class UnsafeUrlError(ValueError): + pass + + +async def _system_resolver(host: str, port: int) -> list[str]: + loop = asyncio.get_running_loop() + infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM) + return sorted({str(info[4][0]) for info in infos}) + + +def _is_public(address: str) -> bool: + try: + ip = ipaddress.ip_address(address.split("%", 1)[0]) + except ValueError: + return False + return ip.is_global and not ip.is_multicast + + +async def validate_public_url(url: str, *, allow_http: bool = False, resolver: Resolver | None = None) -> str: + """Returns the URL unchanged if it is safe to fetch; raises + :class:`UnsafeUrlError` otherwise.""" + + try: + parts = urlsplit(url.strip()) + port = parts.port + except ValueError as exc: + raise UnsafeUrlError("malformed URL") from exc + if parts.scheme not in (("https", "http") if allow_http else ("https",)): + raise UnsafeUrlError(f"scheme {parts.scheme!r} is not allowed") + if parts.username or parts.password: + raise UnsafeUrlError("credentials in a URL are not allowed") + host = parts.hostname + if not host: + raise UnsafeUrlError("URL has no host") + effective_port = port or (443 if parts.scheme == "https" else 80) + if effective_port not in ALLOWED_PORTS: + raise UnsafeUrlError(f"port {effective_port} is not allowed") + if host.lower() in ("localhost",) or host.lower().endswith((".local", ".internal", ".localhost")): + raise UnsafeUrlError("internal hostnames are not allowed") + try: + ipaddress.ip_address(host) + except ValueError: + addresses = await (resolver or _system_resolver)(host, effective_port) + else: + addresses = [host] + if not addresses: + raise UnsafeUrlError("host did not resolve") + for address in addresses: + if not _is_public(address): + raise UnsafeUrlError("host resolves to a non-public address") + return url + + +__all__ = ["ALLOWED_PORTS", "Resolver", "UnsafeUrlError", "validate_public_url"] diff --git a/patchfrog/watchers/pipeline.py b/patchfrog/watchers/pipeline.py new file mode 100644 index 0000000..006e55f --- /dev/null +++ b/patchfrog/watchers/pipeline.py @@ -0,0 +1,57 @@ +"""One poll of one source: fetch -> normalize -> compare -> outcome (M11.5). + +``poll_source`` is the engine half of "scheduled watcher -> snapshot -> +fingerprint comparison -> change event". The hosted service decides *when* +to call it, persists the returned cursor, retries on +:class:`~patchfrog.watchers.fetch.WatcherError` and hands a detected change to +the campaign pipeline (:func:`patchfrog.campaigns.orchestrate.run_campaign`). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime + +from patchfrog.upstream.hints import EMPTY_HINTS, ChangeHints +from patchfrog.watchers.adapters import SourceAdapter, default_adapters +from patchfrog.watchers.diff import detect_change +from patchfrog.watchers.domain import ( + WatcherCursor, + WatcherSnapshot, + WatcherSource, + WatcherSourceKind, + WatchOutcome, +) +from patchfrog.watchers.fetch import Fetcher, PermanentFetchError + + +async def poll_source( + source: WatcherSource, + cursor: WatcherCursor | None, + *, + fetcher: Fetcher, + now: datetime, + adapters: Mapping[WatcherSourceKind, SourceAdapter] | None = None, + hints: ChangeHints = EMPTY_HINTS, +) -> WatchOutcome: + """Raises a :class:`~patchfrog.watchers.fetch.WatcherError` subclass on a + fetch failure -- the cursor is then unchanged and the caller schedules a retry.""" + + registry = adapters if adapters is not None else default_adapters() + adapter = registry.get(source.kind) + if adapter is None: + raise PermanentFetchError(f"no adapter for source kind {source.kind.value!r} (manual sources are submitted, not polled)") + current = cursor or WatcherCursor.empty(source.key) + snapshot = await adapter.fetch(source, current, fetcher, now=now) + return detect_change(source, snapshot, current, hints=hints) + + +def ingest_snapshot( + source: WatcherSource, snapshot: WatcherSnapshot, cursor: WatcherCursor | None, *, hints: ChangeHints = EMPTY_HINTS +) -> WatchOutcome: + """The manual path: a caller-supplied snapshot through the same comparison.""" + + return detect_change(source, snapshot, cursor or WatcherCursor.empty(source.key), hints=hints) + + +__all__ = ["ingest_snapshot", "poll_source"] diff --git a/patchfrog/watchers/registry.py b/patchfrog/watchers/registry.py new file mode 100644 index 0000000..2c3240f --- /dev/null +++ b/patchfrog/watchers/registry.py @@ -0,0 +1,115 @@ +"""Watch registry (M11.1): what *should* be watched. + +Derived from the dependency identities workspaces actually use -- never "every +API on the internet". Pure functions over plain data (the hosted service +feeds them rows read from the M5 registry), so the aggregation and +de-duplication rules are inspectable and testable here. + +Two inputs: + +* automatic sources, derived from a discovered dependency's ecosystem and + package (a PyPI or npm package gets a registry watcher); +* configured sources a workspace added explicitly (a GitHub repository's + releases, an OpenAPI URL, a changelog feed). + +Sources are de-duplicated by :attr:`WatcherSource.key`, so fifty workspaces +using one package cause one upstream fetch; the resulting change is fanned +out to the subscribing workspaces, each of which then evaluates it against +its own dependency evidence (an irrelevant change simply matches nothing). +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +from patchfrog.dependencies.domain import Ecosystem +from patchfrog.upstream.domain import DependencyTarget, normalize_package_name +from patchfrog.watchers.domain import WatcherSource, WatcherSourceKind + +#: Dependency kinds (``ExternalDependencyKind`` values) that have a package identity. +PACKAGE_KINDS = frozenset({"sdk", "package"}) + + +@dataclass(frozen=True, slots=True) +class DependencySubscription: + """One active dependency of one repository, as plain data.""" + + workspace_key: str + repository: str + kind: str + ecosystem: str + package_name: str | None + + +@dataclass(frozen=True, slots=True) +class ConfiguredSource: + workspace_key: str + source: WatcherSource + + +@dataclass(frozen=True, slots=True) +class WatchRequirement: + source: WatcherSource + subscribers: tuple[str, ...] + #: ``dependency`` (derived) or ``configured`` (added explicitly). + origin: str + + +def automatic_source(subscription: DependencySubscription) -> WatcherSource | None: + if subscription.kind not in PACKAGE_KINDS or not subscription.package_name: + return None + name = subscription.package_name.strip() + if subscription.ecosystem == Ecosystem.PYPI.value: + kind, ecosystem = WatcherSourceKind.PYPI, Ecosystem.PYPI + name = normalize_package_name(name) + elif subscription.ecosystem == Ecosystem.NPM.value: + kind, ecosystem = WatcherSourceKind.NPM, Ecosystem.NPM + else: + return None + return WatcherSource( + kind=kind, locator=name, target=DependencyTarget(ecosystem=ecosystem, package_name=name, display_name=name) + ) + + +def required_watchers( + subscriptions: Iterable[DependencySubscription], configured: Iterable[ConfiguredSource] = () +) -> tuple[WatchRequirement, ...]: + sources: dict[str, WatcherSource] = {} + subscribers: dict[str, set[str]] = {} + origins: dict[str, str] = {} + for subscription in subscriptions: + source = automatic_source(subscription) + if source is None: + continue + sources.setdefault(source.key, source) + subscribers.setdefault(source.key, set()).add(subscription.workspace_key) + origins.setdefault(source.key, "dependency") + for item in configured: + key = item.source.key + # an explicitly configured source carries a more specific target than a derived one + sources[key] = item.source if origins.get(key) != "configured" else sources[key] + subscribers.setdefault(key, set()).add(item.workspace_key) + origins[key] = "configured" + return tuple( + WatchRequirement(source=sources[key], subscribers=tuple(sorted(subscribers[key])), origin=origins[key]) + for key in sorted(sources) + ) + + +def subscribers_for(requirements: Iterable[WatchRequirement], source_key: str) -> tuple[str, ...]: + for requirement in requirements: + if requirement.source.key == source_key: + return requirement.subscribers + return () + + +__all__ = [ + "PACKAGE_KINDS", + "ConfiguredSource", + "DependencySubscription", + "WatchRequirement", + "automatic_source", + "required_watchers", + "subscribers_for", +] diff --git a/tests/unit/test_watchers_diff_registry.py b/tests/unit/test_watchers_diff_registry.py new file mode 100644 index 0000000..7cb40fd --- /dev/null +++ b/tests/unit/test_watchers_diff_registry.py @@ -0,0 +1,332 @@ +"""M11.1/M11.3/M11.5: snapshot diff, cursor/idempotency, duplicate suppression, +the watch registry, the launch gate and URL safety.""" + +from __future__ import annotations + +import ast +import json +from dataclasses import replace +from datetime import timedelta +from pathlib import Path + +import httpx +import pytest + +from patchfrog.dependencies.domain import Ecosystem +from patchfrog.upstream.domain import ChangeRisk, DependencyTarget +from patchfrog.upstream.events import parse_contract_document +from patchfrog.watchers.adapters.manual import snapshot_from_contract, snapshot_from_version +from patchfrog.watchers.diff import detect_change +from patchfrog.watchers.domain import ( + MAX_EMITTED_FINGERPRINTS, + WatcherCursor, + WatcherSource, + WatcherSourceKind, + WatchOutcomeKind, +) +from patchfrog.watchers.fetch import ( + FakeFetcher, + FetchResponse, + HttpxFetcher, + PermanentFetchError, + UnsafeSourceError, +) +from patchfrog.watchers.gate import should_launch_campaign +from patchfrog.watchers.net import UnsafeUrlError, validate_public_url +from patchfrog.watchers.pipeline import ingest_snapshot, poll_source +from patchfrog.watchers.registry import ( + ConfiguredSource, + DependencySubscription, + required_watchers, + subscribers_for, +) +from tests.support.campaigns import NOW + +PYPI = WatcherSource( + WatcherSourceKind.PYPI, "acme-sdk", DependencyTarget(ecosystem=Ecosystem.PYPI, package_name="acme-sdk") +) +K = WatchOutcomeKind + + +def _versions(*versions: str) -> FetchResponse: + return FetchResponse(status=200, body=json.dumps( + {"releases": {v: [{"upload_time_iso_8601": "2026-01-01T00:00:00Z"}] for v in versions}} + ).encode()) + + +async def _poll(fetcher: FakeFetcher, cursor: WatcherCursor | None, *, hours: int = 0): # type: ignore[no-untyped-def] + return await poll_source(PYPI, cursor, fetcher=fetcher, now=NOW + timedelta(hours=hours)) + + +URL = "https://pypi.org/pypi/acme-sdk/json" + + +async def test_first_observation_is_a_baseline_and_never_replays_history() -> None: + outcome = await _poll(FakeFetcher({URL: _versions("1.0.0", "1.4.0", "2.0.0")}), None) + assert outcome.kind is K.BASELINE and outcome.change is None + assert outcome.cursor.last_version == "2.0.0" and outcome.cursor.observations == 1 + + +async def test_a_new_release_emits_one_version_change_then_is_idempotent() -> None: + base = await _poll(FakeFetcher({URL: _versions("1.4.0")}), None) + fetcher = FakeFetcher({URL: _versions("1.4.0", "2.0.0")}) + changed = await _poll(fetcher, base.cursor, hours=1) + assert changed.kind is K.CHANGED and changed.change is not None + event = changed.change.event + assert (event.old.version, event.new.version) == ("1.4.0", "2.0.0") + assert event.classification.risk is ChangeRisk.REVIEW_REQUIRED # a version bump alone proves nothing structural + assert changed.change.source_key == "pypi:acme-sdk" and changed.cursor.last_version == "2.0.0" + again = await _poll(fetcher, changed.cursor, hours=2) + assert again.kind is K.UNCHANGED and again.change is None # same upstream state: no second event + + +async def test_patch_and_minor_bumps_are_low_risk() -> None: + base = await _poll(FakeFetcher({URL: _versions("1.4.0")}), None) + outcome = await _poll(FakeFetcher({URL: _versions("1.4.0", "1.4.1")}), base.cursor) + assert outcome.change is not None and outcome.change.event.classification.risk in (ChangeRisk.SAFE, ChangeRisk.LOW_RISK) + + +async def test_duplicate_events_are_suppressed_even_if_the_cursor_regresses() -> None: + base = await _poll(FakeFetcher({URL: _versions("1.4.0")}), None) + fetcher = FakeFetcher({URL: _versions("1.4.0", "2.0.0")}) + first = await _poll(fetcher, base.cursor) + assert first.kind is K.CHANGED + # a worker restores an older cursor but remembers what it already emitted + stale_cursor = replace(base.cursor, emitted=first.cursor.emitted) + second = await _poll(fetcher, stale_cursor) + assert second.kind is K.DUPLICATE_SUPPRESSED and second.change is None + assert second.cursor.last_version == "2.0.0" + + +async def test_a_decreasing_latest_never_regresses_the_cursor() -> None: + cursor = WatcherCursor(source_key=PYPI.key, last_version="2.0.0") + outcome = await _poll(FakeFetcher({URL: _versions("1.9.0")}), cursor) + assert outcome.kind is K.UNCHANGED and outcome.cursor.last_version == "2.0.0" + + +async def test_unparseable_versions_are_skipped_not_guessed() -> None: + outcome = detect_change(PYPI, snapshot_from_version(PYPI, "nightly-build", now=NOW), + WatcherCursor(source_key=PYPI.key, last_version="1.0.0")) + assert outcome.kind is K.SKIPPED and outcome.change is None + + +async def test_not_modified_keeps_the_cursor_and_counts_the_observation() -> None: + cursor = WatcherCursor(source_key=PYPI.key, last_version="1.0.0", etag='"e"') + outcome = await _poll(FakeFetcher({URL: FetchResponse(status=304)}), cursor) + assert outcome.kind is K.UNCHANGED and outcome.cursor.observations == 1 and outcome.cursor.last_version == "1.0.0" + + +def test_emitted_fingerprints_are_bounded() -> None: + cursor = WatcherCursor(source_key="k", last_version="1.0.0") + for i in range(MAX_EMITTED_FINGERPRINTS + 20): + snap = snapshot_from_version(PYPI, f"1.{i + 1}.0", now=NOW) + cursor = detect_change(PYPI, snap, replace(cursor, last_version=f"1.{i}.0")).cursor + assert len(cursor.emitted) == MAX_EMITTED_FINGERPRINTS + + +def test_cursor_round_trips_through_json_and_rejects_unknown_formats() -> None: + cursor = WatcherCursor( + source_key="pypi:x", last_version="1.0.0", etag='"e"', emitted=("a", "b"), observations=3, last_observed_at=NOW, + last_contract_fingerprint="fp", last_contract_json="{}", last_contract_ref="r", last_contract_version="1", + ) + assert WatcherCursor.from_json(cursor.to_json()) == cursor + with pytest.raises(ValueError, match="unsupported"): + WatcherCursor.from_json(json.dumps({"v": 99, "source_key": "x"})) + + +def _openapi(extra_param: bool) -> dict[str, object]: + params: list[dict[str, object]] = [{"name": "limit", "in": "query", "required": False, "schema": {"type": "integer"}}] + if extra_param: + params.append({"name": "tenant", "in": "query", "required": True, "schema": {"type": "string"}}) + return {"openapi": "3.0.0", "info": {"title": "T", "version": "2.0.0" if extra_param else "1.0.0"}, + "paths": {"/v1/items": {"get": {"parameters": params, "responses": {"200": {"description": "ok"}}}}}} + + +def test_openapi_revision_becomes_a_breaking_structural_event() -> None: + source = WatcherSource(WatcherSourceKind.OPENAPI_URL, "https://api.example.com/openapi.json", + DependencyTarget(api_hosts=("api.example.com",))) + old = parse_contract_document(_openapi(False), ref="spec") + new = parse_contract_document(_openapi(True), ref="spec") + base = ingest_snapshot(source, snapshot_from_contract(source, old, now=NOW), None) + assert base.kind is K.BASELINE and base.cursor.last_contract_json + same = ingest_snapshot(source, snapshot_from_contract(source, old, now=NOW), base.cursor) + assert same.kind is K.UNCHANGED + changed = ingest_snapshot(source, snapshot_from_contract(source, new, now=NOW), base.cursor) + assert changed.kind is K.CHANGED and changed.change is not None + event = changed.change.event + assert event.classification.risk is ChangeRisk.BREAKING and event.classification.has_structural_evidence + assert any(i.kind.value == "parameter_added_required" for i in event.diff) + assert changed.cursor.last_contract_fingerprint == new.fingerprint + # polling the same new contract again emits nothing + assert ingest_snapshot(source, snapshot_from_contract(source, new, now=NOW), changed.cursor).kind is K.UNCHANGED + # and the same change observed from a restored cursor is suppressed, not duplicated + replay = ingest_snapshot(source, snapshot_from_contract(source, new, now=NOW), + replace(base.cursor, emitted=changed.cursor.emitted)) + assert replay.kind is K.DUPLICATE_SUPPRESSED + + +def test_a_contract_too_large_to_remember_cannot_be_diffed_and_says_so() -> None: + source = WatcherSource(WatcherSourceKind.MANUAL, "m", DependencyTarget(api_hosts=("a.example.com",))) + new = parse_contract_document(_openapi(True), ref="spec") + cursor = WatcherCursor(source_key=source.key, last_contract_fingerprint="old", last_contract_json=None) + outcome = ingest_snapshot(source, snapshot_from_contract(source, new, now=NOW), cursor) + assert outcome.kind is K.SKIPPED and "too large" in outcome.notes[0] + assert outcome.cursor.last_contract_fingerprint == new.fingerprint # the baseline moves forward + + +def test_mismatched_contract_formats_are_skipped_not_crashed() -> None: + source = WatcherSource(WatcherSourceKind.MANUAL, "m", DependencyTarget(api_hosts=("a.example.com",))) + surface = parse_contract_document( + {"patchfrog_sdk_surface": 1, "package": "p", "ecosystem": "pypi", "version": "1", "modules": ["p"], + "symbols": {"Client": {}}}, ref="s") + base = ingest_snapshot(source, snapshot_from_contract(source, surface, now=NOW), None) + outcome = ingest_snapshot(source, snapshot_from_contract(source, parse_contract_document(_openapi(True), ref="o"), now=NOW), + base.cursor) + assert outcome.kind is K.SKIPPED and outcome.change is None + + +async def test_polling_a_manual_source_is_an_error_not_a_silent_noop() -> None: + manual = WatcherSource(WatcherSourceKind.MANUAL, "m", DependencyTarget()) + with pytest.raises(PermanentFetchError, match="submitted, not polled"): + await poll_source(manual, None, fetcher=FakeFetcher(), now=NOW) + + +# -- watch registry (M11.1) --------------------------------------------------------------- + + +def _sub(workspace: str, package: str | None, *, ecosystem: str = "pypi", kind: str = "sdk") -> DependencySubscription: + return DependencySubscription(workspace, f"{workspace}/repo", kind, ecosystem, package) + + +def test_fifty_workspaces_using_one_package_cause_one_fetch() -> None: + subs = [_sub(f"ws-{i:02d}", "OpenAI_SDK") for i in range(50)] + requirements = required_watchers(subs) + assert len(requirements) == 1 + assert requirements[0].source.key == "pypi:openai-sdk" and len(requirements[0].subscribers) == 50 + assert requirements[0].origin == "dependency" + assert subscribers_for(requirements, "pypi:openai-sdk") == requirements[0].subscribers + assert subscribers_for(requirements, "pypi:other") == () + + +def test_only_dependencies_actually_used_are_watched() -> None: + subs = [ + _sub("a", "requests"), _sub("a", "left-pad", ecosystem="npm"), _sub("a", None), + _sub("a", "somegem", ecosystem="rubygems"), _sub("a", "stripe-api", kind="http_api"), + _sub("a", "openapi-doc", kind="openapi_contract", ecosystem="openapi"), + ] + keys = [r.source.key for r in required_watchers(subs)] + assert keys == ["npm:left-pad", "pypi:requests"] + + +def test_configured_sources_are_deduplicated_and_win_over_derived_targets() -> None: + specific = WatcherSource(WatcherSourceKind.PYPI, "requests", + DependencyTarget(ecosystem=Ecosystem.PYPI, package_name="requests", provider_key="http")) + gh = WatcherSource(WatcherSourceKind.GITHUB_RELEASES, "psf/requests", DependencyTarget(package_name="requests")) + requirements = required_watchers( + [_sub("a", "requests"), _sub("b", "requests")], + [ConfiguredSource("c", specific), ConfiguredSource("a", gh), ConfiguredSource("b", gh)], + ) + by_key = {r.source.key: r for r in requirements} + assert set(by_key) == {"pypi:requests", "github_releases:psf/requests"} + assert by_key["pypi:requests"].subscribers == ("a", "b", "c") and by_key["pypi:requests"].origin == "configured" + assert by_key["pypi:requests"].source.target.provider_key == "http" + assert by_key["github_releases:psf/requests"].subscribers == ("a", "b") + + +# -- gate (M11.5/M11.11) ------------------------------------------------------------------- + + +async def test_the_launch_gate_requires_structural_proof_by_default() -> None: + base = await _poll(FakeFetcher({URL: _versions("1.4.0")}), None) + major = await _poll(FakeFetcher({URL: _versions("1.4.0", "2.0.0")}), base.cursor) + assert major.change is not None + decision = should_launch_campaign(major.change.event) + assert not decision.launch and "recorded, no campaign opened" in decision.reason + assert should_launch_campaign(major.change.event, min_risk=ChangeRisk.REVIEW_REQUIRED).launch + + +# -- URL safety (M11.2) ---------------------------------------------------------------------- + + +async def _resolve_public(host: str, port: int) -> list[str]: + return ["93.184.216.34"] + + +async def _resolve_private(host: str, port: int) -> list[str]: + return ["10.0.0.5"] + + +@pytest.mark.parametrize( + "url", + [ + "http://example.com/x", "https://user:pw@example.com/x", "https://localhost/x", "https://127.0.0.1/x", + "https://169.254.169.254/latest/meta-data", "https://[::1]/x", "https://10.1.2.3/x", "https://service.internal/x", + "https://example.com:6379/x", "ftp://example.com/x", "https:///nohost", "https://192.168.0.1/x", + ], +) +async def test_unsafe_urls_are_refused(url: str) -> None: + with pytest.raises(UnsafeUrlError): + await validate_public_url(url, resolver=_resolve_public) + + +async def test_hostnames_resolving_to_private_addresses_are_refused() -> None: + with pytest.raises(UnsafeUrlError, match="non-public"): + await validate_public_url("https://sneaky.example.com/x", resolver=_resolve_private) + assert await validate_public_url("https://example.com/x", resolver=_resolve_public) == "https://example.com/x" + + +async def test_httpx_fetcher_refuses_internal_targets_and_unsafe_redirects() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.host == "good.example.com": + return httpx.Response(302, headers={"location": "https://169.254.169.254/latest"}) + return httpx.Response(200, content=b"{}") + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False) + fetcher = HttpxFetcher(client=client, resolver=_resolve_public) + with pytest.raises(UnsafeSourceError): + await fetcher.get("https://127.0.0.1/x") + with pytest.raises(UnsafeSourceError): + await fetcher.get("https://good.example.com/x") # redirect hop to a metadata address + assert (await fetcher.get("https://other.example.com/x")).status == 200 + + +async def test_httpx_fetcher_caps_size_and_scopes_auth_headers_to_their_host() -> None: + seen: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen[request.url.host] = request.headers.get("authorization") + if request.url.path == "/big": + return httpx.Response(200, content=b"x" * 2000) + return httpx.Response(200, content=b"{}", headers={"etag": '"v1"', "x-ratelimit-remaining": "7"}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False) + fetcher = HttpxFetcher(client=client, resolver=_resolve_public, + auth_headers={"api.github.com": {"Authorization": "Bearer test-token-value"}}) + response = await fetcher.get("https://api.github.com/x") + assert response.etag == '"v1"' and response.rate_limit_remaining == 7 + await fetcher.get("https://example.com/x") + assert seen["api.github.com"] == "Bearer test-token-value" and seen["example.com"] is None + with pytest.raises(PermanentFetchError, match="larger"): + await fetcher.get("https://example.com/big", max_bytes=100) + + +# -- structural invariants ----------------------------------------------------------------- + +_WATCHERS = Path(__file__).resolve().parents[2] / "patchfrog" / "watchers" + + +def test_watchers_never_import_a_provider_or_the_review_engine() -> None: + for path in _WATCHERS.rglob("*.py"): + for node in ast.walk(ast.parse(path.read_text())): + if isinstance(node, ast.ImportFrom) and node.module: + assert not node.module.startswith( + ("patchfrog.review", "patchfrog.routing", "anthropic", "openai", "google", "patchfrog.persistence") + ), f"{path.name} imports {node.module}" + if isinstance(node, ast.Import): + assert not any(a.name.split(".")[0] in ("anthropic", "openai", "google") for a in node.names) + + +def test_watchers_hold_no_workspace_or_cloud_concepts() -> None: + for path in _WATCHERS.rglob("*.py"): + text = path.read_text().lower() + assert "patchfrog_cloud" not in text and "celery" not in text, path.name diff --git a/tests/unit/test_watchers_sources.py b/tests/unit/test_watchers_sources.py new file mode 100644 index 0000000..4de0fae --- /dev/null +++ b/tests/unit/test_watchers_sources.py @@ -0,0 +1,229 @@ +"""M11.2/M11.3: source adapters against the in-memory fetcher -- no network.""" + +from __future__ import annotations + +import json +from datetime import timedelta + +import pytest + +from patchfrog.dependencies.domain import Ecosystem +from patchfrog.upstream.domain import DependencyTarget +from patchfrog.watchers.adapters import ( + ChangelogFeedAdapter, + GitHubReleasesAdapter, + NpmAdapter, + OpenApiUrlAdapter, + PyPIAdapter, +) +from patchfrog.watchers.domain import SnapshotKind, WatcherCursor, WatcherSource, WatcherSourceKind +from patchfrog.watchers.fetch import ( + FakeFetcher, + FetchResponse, + PermanentFetchError, + RateLimitedError, + TransientFetchError, + backoff_delay_seconds, + raise_for_response, +) +from tests.support.campaigns import NOW + + +def _source(kind: WatcherSourceKind, locator: str, **kw: object) -> WatcherSource: + return WatcherSource(kind=kind, locator=locator, target=DependencyTarget(display_name=locator), **kw) # type: ignore[arg-type] + + +def _json(data: object, **kw: object) -> FetchResponse: + return FetchResponse(status=200, body=json.dumps(data).encode(), **kw) # type: ignore[arg-type] + + +async def test_pypi_picks_latest_stable_and_ignores_prereleases_yanked_and_empty() -> None: + source = _source(WatcherSourceKind.PYPI, "Acme_SDK") + fetcher = FakeFetcher({ + "https://pypi.org/pypi/acme-sdk/json": _json({"releases": { + "1.0.0": [{"upload_time_iso_8601": "2026-01-01T00:00:00Z"}], + "1.5.0": [{"upload_time_iso_8601": "2026-03-01T00:00:00Z"}], + "2.0.0rc1": [{"upload_time_iso_8601": "2026-04-01T00:00:00Z"}], + "1.9.0": [{"upload_time_iso_8601": "2026-04-02T00:00:00Z", "yanked": True}], + "1.8.0": [], + }}, etag='"abc"'), + }) + snap = await PyPIAdapter().fetch(source, WatcherCursor.empty(source.key), fetcher, now=NOW) + assert snap.kind is SnapshotKind.VERSION and snap.latest_version == "1.5.0" + assert snap.release is not None and snap.release.published_at == "2026-03-01T00:00:00Z" + assert snap.release.url == "https://pypi.org/project/acme-sdk/1.5.0/" and not snap.release.prerelease + assert snap.etag == '"abc"' + + +async def test_pypi_can_include_prereleases_and_a_final_outranks_its_own_prerelease() -> None: + releases = {"2.0.0rc1": [{"upload_time_iso_8601": "t"}], "1.5.0": [{"upload_time_iso_8601": "t"}]} + fetcher = FakeFetcher({"https://pypi.org/pypi/x/json": _json({"releases": releases})}) + include = _source(WatcherSourceKind.PYPI, "x", include_prereleases=True) + snap = await PyPIAdapter().fetch(include, WatcherCursor.empty(include.key), fetcher, now=NOW) + assert snap.latest_version == "2.0.0rc1" and snap.release is not None and snap.release.prerelease + releases["2.0.0"] = [{"upload_time_iso_8601": "t"}] + fetcher = FakeFetcher({"https://pypi.org/pypi/x/json": _json({"releases": releases})}) + final = await PyPIAdapter().fetch(include, WatcherCursor.empty(include.key), fetcher, now=NOW) + assert final.latest_version == "2.0.0" + + +async def test_conditional_request_uses_etag_and_handles_304() -> None: + source = _source(WatcherSourceKind.PYPI, "x") + fetcher = FakeFetcher({"https://pypi.org/pypi/x/json": FetchResponse(status=304)}) + cursor = WatcherCursor(source_key=source.key, etag='"old"') + snap = await PyPIAdapter().fetch(source, cursor, fetcher, now=NOW) + assert snap.not_modified and snap.etag == '"old"' + assert fetcher.header_names == [("If-None-Match",)] + + +async def test_npm_scoped_package_url_and_dist_tag_latest() -> None: + source = _source(WatcherSourceKind.NPM, "@Acme/SDK") + fetcher = FakeFetcher({ + "https://registry.npmjs.org/@acme%2Fsdk": _json({ + "dist-tags": {"latest": "3.1.0"}, + "versions": {"3.0.0": {}, "3.1.0": {}, "4.0.0-beta.1": {}}, + "time": {"3.1.0": "2026-05-05T00:00:00.000Z"}, + }), + }) + snap = await NpmAdapter().fetch(source, WatcherCursor.empty(source.key), fetcher, now=NOW) + assert snap.latest_version == "3.1.0" and snap.release is not None + assert snap.release.published_at == "2026-05-05T00:00:00.000Z" + + +async def test_npm_skips_a_deprecated_latest_tag() -> None: + source = _source(WatcherSourceKind.NPM, "pkg") + fetcher = FakeFetcher({ + "https://registry.npmjs.org/pkg": _json({ + "dist-tags": {"latest": "2.0.0"}, "versions": {"1.9.0": {}, "2.0.0": {"deprecated": "do not use"}}, + }), + }) + snap = await NpmAdapter().fetch(source, WatcherCursor.empty(source.key), fetcher, now=NOW) + assert snap.latest_version == "1.9.0" + + +async def test_github_releases_ignore_drafts_and_prereleases_and_read_tags() -> None: + source = _source(WatcherSourceKind.GITHUB_RELEASES, "Acme/SDK") + url = "https://api.github.com/repos/acme/sdk/releases?per_page=30" + fetcher = FakeFetcher({url: _json([ + {"tag_name": "v2.1.0", "draft": True, "published_at": "2026-06-01T00:00:00Z"}, + {"tag_name": "v2.0.0-rc.1", "prerelease": True}, + {"tag_name": "v1.9.0", "published_at": "2026-05-01T00:00:00Z", "html_url": "https://github.com/acme/sdk/releases/tag/v1.9.0", + "body": "notes that must not be stored"}, + {"tag_name": "nightly"}, + ])}) + snap = await GitHubReleasesAdapter().fetch(source, WatcherCursor.empty(source.key), fetcher, now=NOW) + assert snap.latest_version == "1.9.0" and snap.release is not None + assert snap.release.notes_sha256 and "notes that must not" not in repr(snap.release) + assert "Accept" in fetcher.header_names[0] + + +async def test_github_releases_rejects_a_malformed_locator() -> None: + with pytest.raises(PermanentFetchError): + await GitHubReleasesAdapter().fetch( + _source(WatcherSourceKind.GITHUB_RELEASES, "just-a-name"), WatcherCursor.empty("k"), FakeFetcher(), now=NOW + ) + + +OPENAPI_V1 = {"openapi": "3.0.0", "info": {"title": "T", "version": "1.0.0"}, + "paths": {"/v1/items": {"get": {"parameters": [], "responses": {"200": {"description": "ok"}}}}}} + + +async def test_openapi_url_produces_a_normalized_contract_with_a_stable_fingerprint() -> None: + url = "https://api.example.com/openapi.json" + source = _source(WatcherSourceKind.OPENAPI_URL, url) + fetcher = FakeFetcher({url: _json(OPENAPI_V1, etag='"e1"')}) + one = await OpenApiUrlAdapter().fetch(source, WatcherCursor.empty(source.key), fetcher, now=NOW) + two = await OpenApiUrlAdapter().fetch(source, WatcherCursor.empty(source.key), fetcher, now=NOW + timedelta(hours=1)) + assert one.kind is SnapshotKind.CONTRACT and one.contract is not None and two.contract is not None + assert one.contract.fingerprint == two.contract.fingerprint and one.latest_version == "1.0.0" + assert one.contract.ref == url + + +async def test_openapi_url_rejects_non_openapi_and_oversized_documents() -> None: + url = "https://x.example.com/spec" + source = _source(WatcherSourceKind.OPENAPI_URL, url) + with pytest.raises(PermanentFetchError, match="not an OpenAPI"): + await OpenApiUrlAdapter().fetch( + source, WatcherCursor.empty(source.key), FakeFetcher({url: _json({"hello": "world"})}), now=NOW + ) + big = FetchResponse(status=200, body=b"x" * 5_000_001) + with pytest.raises(PermanentFetchError, match="larger"): + await OpenApiUrlAdapter().fetch(source, WatcherCursor.empty(source.key), FakeFetcher({url: big}), now=NOW) + + +ATOM = b""" +Release v2.3.02026-07-01T00:00:00Z +Breaking: removed foo() +Release v2.2.02026-06-01T00:00:00Z +Roadmap update (no version)""" +RSS = b"""1.4.1 releasedhttps://e.com/1 +Mon, 01 Jun 2026 00:00:00 GMT""" + + +async def test_changelog_feed_reads_atom_rss_and_json_feed() -> None: + adapter = ChangelogFeedAdapter() + for body, expected in ( + (ATOM, "2.3.0"), (RSS, "1.4.1"), + (json.dumps({"items": [{"title": "v5.0.0", "url": "https://e.com/5"}, {"title": "v4.9.0"}]}).encode(), "5.0.0"), + ): + url = f"https://feeds.example.com/{expected}" + source = _source(WatcherSourceKind.CHANGELOG_FEED, url) + snap = await adapter.fetch(source, WatcherCursor.empty(source.key), + FakeFetcher({url: FetchResponse(status=200, body=body)}), now=NOW) + assert snap.latest_version == expected + + +async def test_changelog_feed_without_any_version_is_not_a_signal() -> None: + url = "https://feeds.example.com/novers" + source = _source(WatcherSourceKind.CHANGELOG_FEED, url) + body = b'We are hiring' + snap = await ChangelogFeedAdapter().fetch(source, WatcherCursor.empty(source.key), + FakeFetcher({url: FetchResponse(status=200, body=body)}), now=NOW) + assert snap.latest_version is None and snap.notes + + +async def test_changelog_feed_refuses_dtd_and_entities() -> None: + url = "https://feeds.example.com/evil" + source = _source(WatcherSourceKind.CHANGELOG_FEED, url) + evil = b']>v1.0.0' + with pytest.raises(PermanentFetchError, match="DTD"): + await ChangelogFeedAdapter().fetch(source, WatcherCursor.empty(source.key), + FakeFetcher({url: FetchResponse(status=200, body=evil)}), now=NOW) + + +def test_http_status_mapping() -> None: + raise_for_response(FetchResponse(status=200), what="x") + raise_for_response(FetchResponse(status=304), what="x") + with pytest.raises(RateLimitedError) as limited: + raise_for_response(FetchResponse(status=429, retry_after_seconds=90.0), what="x") + assert limited.value.retry_after_seconds == 90.0 and limited.value.retryable + with pytest.raises(RateLimitedError): + raise_for_response(FetchResponse(status=403, rate_limit_remaining=0), what="x") + with pytest.raises(PermanentFetchError): + raise_for_response(FetchResponse(status=403, rate_limit_remaining=10), what="x") + for status in (500, 502, 503, 408): + with pytest.raises(TransientFetchError): + raise_for_response(FetchResponse(status=status), what="x") + with pytest.raises(PermanentFetchError) as missing: + raise_for_response(FetchResponse(status=404), what="x") + assert not missing.value.retryable + + +def test_backoff_grows_is_capped_jittered_and_respects_retry_after() -> None: + low, high = (lambda: 0.0), (lambda: 1.0) + assert backoff_delay_seconds(1, base=30, jitter=low) == 15 and backoff_delay_seconds(1, base=30, jitter=high) == 30 + assert backoff_delay_seconds(3, base=30, jitter=high) == 120 + assert backoff_delay_seconds(30, base=30, cap=600, jitter=high) == 600 + assert backoff_delay_seconds(1, base=30, jitter=low, retry_after=300) == 300 + assert backoff_delay_seconds(1, base=30, cap=100, jitter=low, retry_after=5000) == 100 + with pytest.raises(ValueError): + backoff_delay_seconds(0) + + +def test_source_keys_normalize_so_shared_fetches_dedupe() -> None: + target = DependencyTarget(ecosystem=Ecosystem.PYPI) + a = WatcherSource(WatcherSourceKind.PYPI, "Acme_SDK", target) + b = WatcherSource(WatcherSourceKind.PYPI, "acme-sdk", target) + assert a.key == b.key == "pypi:acme-sdk" + assert WatcherSource(WatcherSourceKind.GITHUB_RELEASES, "Acme/SDK/", target).key == "github_releases:acme/sdk" + assert WatcherSource(WatcherSourceKind.OPENAPI_URL, "https://a.example/s.json#frag", target).key.endswith("s.json") From 85448cd5cdbbeea44bb4209af782cf36d2105f62 Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 05:37:18 +0300 Subject: [PATCH 05/17] M11: watcher -> campaign pipeline, observability, campaigns CLI and the end-to-end demo process_change_for_workspace applies the launch gate (structural proof by default), loads the previous campaign, runs the per-repo M6->M9 flow, and writes the campaign, the M6 event and the M7/M8 audit trail in one transaction. Adds label-validated watcher/campaign/PR metrics, the 'patchfrog campaigns analyze|demo' commands, and the deterministic four-repo demo (baseline -> change -> one campaign -> one PR -> no duplicates -> RESOLVED). Fixes a latent crash: an SDK surface restored from a stored snapshot now rebuilds its SdkSurface so it can be the old side of a diff. --- patchfrog/campaigns/demo.py | 201 +++++++++++++++++ patchfrog/campaigns/evaluate.py | 10 +- patchfrog/campaigns/ingest.py | 149 +++++++++++++ patchfrog/campaigns/observe.py | 85 +++++++ patchfrog/cli.py | 5 +- patchfrog/cli_campaigns.py | 206 +++++++++++++++++ patchfrog/ops/metrics.py | 29 +++ patchfrog/upstream/events.py | 13 ++ .../test_campaign_orchestration.py | 11 + .../integration/test_campaign_pipeline_e2e.py | 211 ++++++++++++++++++ 10 files changed, 918 insertions(+), 2 deletions(-) create mode 100644 patchfrog/campaigns/demo.py create mode 100644 patchfrog/campaigns/ingest.py create mode 100644 patchfrog/campaigns/observe.py create mode 100644 patchfrog/cli_campaigns.py create mode 100644 tests/integration/test_campaign_pipeline_e2e.py diff --git a/patchfrog/campaigns/demo.py b/patchfrog/campaigns/demo.py new file mode 100644 index 0000000..9ecaeb7 --- /dev/null +++ b/patchfrog/campaigns/demo.py @@ -0,0 +1,201 @@ +"""The deterministic M10/M11 end-to-end demo. + +A fictional workspace of four repositories, one fictional upstream SDK +(``client.chat.create`` -> ``client.responses.create``), a manually submitted +SDK-surface snapshot standing in for a watcher source, an in-memory GitHub and +an in-memory database. No network, no live provider call, nothing outside a +temporary directory is written. + +Stages: (1) the watcher baselines, then detects the change; (2) one campaign +is created, four repositories are evaluated, one evidence-backed PR is +opened and one human-required action is reported; (3) a re-run creates no +second campaign and no second PR; (4) after the PR lands, the human fix is +made and the stale repository is rediscovered, the campaign reconciles to +``RESOLVED``. +""" + +from __future__ import annotations + +import shutil +import tempfile +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from patchfrog.campaigns.domain import EnrolledRepository +from patchfrog.campaigns.evaluate import RepositoryInput +from patchfrog.campaigns.ingest import WorkspaceChangeResult, process_change_for_workspace +from patchfrog.campaigns.orchestrate import pr_publisher_from +from patchfrog.campaigns.policy import PublicationMode, WatchMode, WorkspacePolicy +from patchfrog.dependencies.domain import Ecosystem +from patchfrog.migration_pr.fake_github import FakeMigrationGitHubPublisher +from patchfrog.migration_pr.publisher import MigrationPRPublisher +from patchfrog.persistence.models import Base +from patchfrog.persistence.models.campaign import CompatibilityCampaignModel +from patchfrog.persistence.models.migration_pr import MigrationPRModel +from patchfrog.upstream.domain import DependencyTarget +from patchfrog.upstream.events import load_contract_file +from patchfrog.upstream.hints import ChangeHints, load_hints +from patchfrog.watchers.adapters.manual import snapshot_from_contract +from patchfrog.watchers.domain import WatcherCursor, WatcherSource, WatcherSourceKind, WatchOutcome +from patchfrog.watchers.pipeline import ingest_snapshot + +DEMO_ROOT = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "campaigns" / "acme" +DEMO_WORKSPACE = "acme" +DEMO_SHA = "d3adb33f" * 5 +#: Fixed so every run prints the same thing. +DEMO_NOW = datetime(2026, 10, 2, 12, 0, tzinfo=UTC) +DEMO_REPOSITORIES = ("acme-web", "acme-worker", "acme-search", "acme-legacy") + + +class DemoUnavailable(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class DemoStage: + title: str + lines: tuple[str, ...] + + +@dataclass(slots=True) +class DemoReport: + watcher: list[WatchOutcome] = field(default_factory=list) + first: WorkspaceChangeResult | None = None + rerun: WorkspaceChangeResult | None = None + resolved: WorkspaceChangeResult | None = None + campaigns_in_database: int = 0 + pull_requests_in_database: int = 0 + github_pr_creations: int = 0 + github_pr_drafts: list[bool] = field(default_factory=list) + + +def _entry(name: str, root: Path | None, *, discovered_at: datetime | None) -> RepositoryInput: + return RepositoryInput( + repository=EnrolledRepository( + full_name=f"acme/{name}", last_discovery_at=discovered_at, last_discovery_commit_sha=DEMO_SHA + ), + root=root, commit_sha=DEMO_SHA, extra_workspace_paths=(DEMO_ROOT / "sdk_stub",), + ) + + +@asynccontextmanager +async def _database() -> AsyncIterator[async_sessionmaker[AsyncSession]]: + try: + import aiosqlite # noqa: F401 + except ImportError as exc: # pragma: no cover - environment dependent + raise DemoUnavailable("the demo needs aiosqlite (pip install 'patchfrog[dev]')") from exc + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False} + ) + try: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + yield async_sessionmaker(engine, expire_on_commit=False) + finally: + await engine.dispose() + + +def _load_change(*, hints: ChangeHints) -> tuple[WatcherSource, WatcherCursor, WatchOutcome, WatchOutcome]: + old = load_contract_file(DEMO_ROOT / "contract" / "old.yaml") + new = load_contract_file(DEMO_ROOT / "contract" / "new.yaml") + source = WatcherSource( + WatcherSourceKind.MANUAL, "acme-ai", + DependencyTarget(ecosystem=Ecosystem.PYPI, package_name="acme-ai", modules=("acme_ai",), display_name="acme-ai"), + ) + baseline = ingest_snapshot(source, snapshot_from_contract(source, old, now=DEMO_NOW), None, hints=hints) + changed = ingest_snapshot( + source, snapshot_from_contract(source, new, now=DEMO_NOW + timedelta(hours=1)), baseline.cursor, hints=hints + ) + return source, changed.cursor, baseline, changed + + +async def run_full_demo() -> DemoReport: + if not DEMO_ROOT.is_dir(): + raise DemoUnavailable(f"demo assets not found at {DEMO_ROOT} (expected a full repository checkout)") + report = DemoReport() + hints = load_hints((DEMO_ROOT / "contract" / "hints.yaml").read_text()) + source, cursor, baseline, changed = _load_change(hints=hints) + report.watcher = [baseline, changed] + assert changed.change is not None + event = changed.change.event + # polling the same upstream state again emits nothing + report.watcher.append( + ingest_snapshot( + source, snapshot_from_contract(source, load_contract_file(DEMO_ROOT / "contract" / "new.yaml"), + now=DEMO_NOW + timedelta(hours=2)), cursor, hints=hints, + ) + ) + + policy = WorkspacePolicy(watch_mode=WatchMode.MIGRATE_AND_OPEN_PR, publication=PublicationMode.AUTOMATIC) + fake = FakeMigrationGitHubPublisher() + for name in DEMO_REPOSITORIES: + fake.set_ref(owner="acme", repository=name, ref="heads/main", sha=DEMO_SHA) + repos = DEMO_ROOT / "repos" + fresh, stale = DEMO_NOW - timedelta(hours=2), DEMO_NOW - timedelta(days=30) + + with tempfile.TemporaryDirectory(prefix="patchfrog-campaign-demo-") as tmp: + work = Path(tmp) + async with _database() as session_factory: + publish = pr_publisher_from( + MigrationPRPublisher(session_factory=session_factory, publisher=fake, installation_id=1) + ) + + async def run(entries: list[RepositoryInput], now: datetime) -> WorkspaceChangeResult: + return await process_change_for_workspace( + session_factory=session_factory, workspace_key=DEMO_WORKSPACE, event=event, entries=entries, + now=now, policy=policy, hints=hints, publish=publish, + ) + + first_entries = [ + _entry("acme-web", repos / "acme-web", discovered_at=fresh), + _entry("acme-worker", repos / "acme-worker", discovered_at=fresh), + _entry("acme-search", repos / "acme-search", discovered_at=fresh), + _entry("acme-legacy", None, discovered_at=stale), + ] + report.first = await run(first_entries, DEMO_NOW) + report.rerun = await run(first_entries, DEMO_NOW) + + # -- the world moves on: A's PR is merged, B is fixed by a human, D is rediscovered. + assert report.first.run is not None + patches = {e.record.repository: e.patch for e in report.first.run.evaluations if e.patch is not None} + web = work / "acme-web" + shutil.copytree(repos / "acme-web", web) + web_patch = patches["acme/acme-web"] + for relative, content in web_patch.new_contents.items(): + (web / relative).write_text(content) + worker = work / "acme-worker" + shutil.copytree(repos / "acme-worker", worker) + for relative, content in patches["acme/acme-worker"].new_contents.items(): + (worker / relative).write_text(content) + summary = worker / "app" / "summary.py" + summary.write_text(summary.read_text().replace(", stream=False", "")) + later = DEMO_NOW + timedelta(days=1) + report.resolved = await run( + [ + _entry("acme-web", web, discovered_at=later - timedelta(hours=1)), + _entry("acme-worker", worker, discovered_at=later - timedelta(hours=1)), + _entry("acme-search", repos / "acme-search", discovered_at=later - timedelta(hours=1)), + _entry("acme-legacy", repos / "acme-legacy", discovered_at=later - timedelta(minutes=5)), + ], + later, + ) + async with session_factory() as session: + report.campaigns_in_database = int( + (await session.execute(select(func.count()).select_from(CompatibilityCampaignModel))).scalar_one() + ) + report.pull_requests_in_database = int( + (await session.execute(select(func.count()).select_from(MigrationPRModel))).scalar_one() + ) + report.github_pr_creations = len(fake.create_pull_request_calls) + report.github_pr_drafts = list(fake.create_pull_request_drafts) + return report + + +__all__ = ["DEMO_ROOT", "DemoReport", "DemoUnavailable", "run_full_demo"] diff --git a/patchfrog/campaigns/evaluate.py b/patchfrog/campaigns/evaluate.py index c8a93ab..8c836b0 100644 --- a/patchfrog/campaigns/evaluate.py +++ b/patchfrog/campaigns/evaluate.py @@ -13,6 +13,7 @@ from __future__ import annotations import re +import uuid from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field, replace from datetime import datetime @@ -82,6 +83,9 @@ class RepositoryInput: extra_workspace_paths: tuple[Path, ...] = () #: The record from an earlier run of this campaign, if any. previous: RepositoryRecord | None = None + #: The engine ``repositories`` row id, when the caller has one. Only used to + #: persist the M7/M8 audit trail (:mod:`patchfrog.campaigns.ingest`). + engine_repository_id: uuid.UUID | None = None @dataclass(frozen=True, slots=True) @@ -98,6 +102,9 @@ class RepositoryEvaluation: bundle: MigrationEvidenceBundle | None = None pr_plan: MigrationPRPlan | None = None human_actions: tuple[str, ...] = field(default_factory=tuple) + #: The checkout the plan/patch were computed against (for the audit trail). + root: Path | None = None + engine_repository_id: uuid.UUID | None = None def _impact( @@ -252,7 +259,8 @@ async def _evaluate( ) return RepositoryEvaluation( record=record, classification=classification, impact=impact, inventory=inventory, plan=plan, patch=patch, - bundle=bundle, pr_plan=pr_plan, human_actions=_human_actions(plan), + bundle=bundle, pr_plan=pr_plan, human_actions=_human_actions(plan), root=entry.root, + engine_repository_id=entry.engine_repository_id, ) diff --git a/patchfrog/campaigns/ingest.py b/patchfrog/campaigns/ingest.py new file mode 100644 index 0000000..7b1f2dc --- /dev/null +++ b/patchfrog/campaigns/ingest.py @@ -0,0 +1,149 @@ +"""Detected upstream change -> one workspace's campaign (M11.5). + +``gate -> previous campaign -> run_campaign (M6->M9 per repository) -> +audit trail -> persist``. This is the single-workspace unit of the +continuous pipeline; a hosted service calls it once per subscribing +workspace. Everything deterministic, nothing model-assisted. + +Atomicity: the campaign row, the M6 event row and (when repository ids are +supplied) the M7 plan / patch / M8 verification rows are written in **one** +transaction. If any write fails the whole transaction rolls back and the +caller's retry re-runs idempotently -- M9's PR identity makes a repeated +publish an update, never a second PR. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from patchfrog.campaigns.domain import CampaignState, campaign_identity_key +from patchfrog.campaigns.evaluate import RepositoryEvaluation, RepositoryInput, VerifyFn +from patchfrog.campaigns.freshness import FreshnessPolicy +from patchfrog.campaigns.internal import InternalContract +from patchfrog.campaigns.observe import ( + pr_outcome_for_status, + record_campaign_event, + record_campaign_records, + record_campaign_resolved, + record_pr_outcome, +) +from patchfrog.campaigns.orchestrate import CampaignRunResult, PullRequestPublisher, run_campaign +from patchfrog.campaigns.policy import PublicationAction, WorkspacePolicy +from patchfrog.campaigns.store import CampaignStore +from patchfrog.migration.store import MigrationStore, build_linkage +from patchfrog.migration_verification.service import run_migration_verification +from patchfrog.migration_verification.store import MigrationVerificationStore +from patchfrog.upstream.domain import ChangeRisk, ExternalChangeEvent +from patchfrog.upstream.hints import EMPTY_HINTS, ChangeHints +from patchfrog.upstream.store import UpstreamChangeStore +from patchfrog.watchers.gate import DEFAULT_MIN_RISK, should_launch_campaign + + +@dataclass(frozen=True, slots=True) +class WorkspaceChangeResult: + launched: bool + reason: str + run: CampaignRunResult | None = None + campaign_created: bool = False + + +async def _persist_audit(session: AsyncSession, event: ExternalChangeEvent, run: CampaignRunResult) -> None: + events = UpstreamChangeStore() + migrations = MigrationStore() + verifications = MigrationVerificationStore() + event_row, _ = await events.record_event(session, event) + for evaluation in run.evaluations: + await _persist_repository(session, event_row.id, evaluation, migrations, verifications) + + +async def _persist_repository( + session: AsyncSession, event_id: uuid.UUID, evaluation: RepositoryEvaluation, migrations: MigrationStore, + verifications: MigrationVerificationStore, +) -> None: + if evaluation.engine_repository_id is None or evaluation.plan is None or evaluation.root is None: + return + plan_row, base_fingerprint, _ = await migrations.record_plan( + session, event_id=event_id, repository_id=evaluation.engine_repository_id, plan=evaluation.plan, + root=evaluation.root, + ) + if evaluation.patch is None: + return + linkage = build_linkage( + evaluation.plan, evaluation.patch, base_content_fingerprint=base_fingerprint, + plan_fingerprint=plan_row.plan_fingerprint, + ) + patch_row, _ = await migrations.record_patch(session, plan_id=plan_row.id, patch=evaluation.patch, linkage=linkage) + if evaluation.bundle is not None: + await verifications.record_run(session, patch_id=patch_row.id, bundle=evaluation.bundle) + + +def _observe(run: CampaignRunResult, *, created: bool, previous_state: CampaignState | None, now: datetime) -> None: + campaign = run.campaign + if created: + record_campaign_event("created") + elif run.changed: + record_campaign_event("reconciled") + else: + record_campaign_event("unchanged") + if campaign.state is CampaignState.RESOLVED and previous_state is not CampaignState.RESOLVED: + record_campaign_event("resolved") + record_campaign_resolved(campaign, now=now) + record_campaign_records(campaign.records) + for repository, decision in run.decisions.items(): + if decision.action is PublicationAction.NONE: + continue + record_pr_outcome("eligible") + if decision.action is PublicationAction.AWAIT_APPROVAL: + record_pr_outcome("awaiting_approval") + publication = run.publications.get(repository) + if publication is not None: + record_pr_outcome(pr_outcome_for_status(publication.status.value)) + + +async def process_change_for_workspace( + *, + session_factory: async_sessionmaker[AsyncSession], + workspace_key: str, + event: ExternalChangeEvent, + entries: Sequence[RepositoryInput], + now: datetime, + policy: WorkspacePolicy, + min_risk: ChangeRisk = DEFAULT_MIN_RISK, + freshness_policy: FreshnessPolicy | None = None, + hints: ChangeHints = EMPTY_HINTS, + verify: VerifyFn = run_migration_verification, + publish: PullRequestPublisher | None = None, + internal_contract: InternalContract | None = None, +) -> WorkspaceChangeResult: + if not policy.analyzes: + record_campaign_event("not_launched") + return WorkspaceChangeResult(False, "workspace watch mode is off") + decision = should_launch_campaign(event, min_risk=min_risk) + if not decision.launch: + record_campaign_event("not_launched") + return WorkspaceChangeResult(False, decision.reason) + + identity = campaign_identity_key(workspace_key=workspace_key, change_fingerprint=event.fingerprint) + store = CampaignStore() + async with session_factory() as session: + previous = await store.load(session, identity_key=identity) + + run = await run_campaign( + workspace_key=workspace_key, event=event, entries=entries, now=now, policy=policy, + freshness_policy=freshness_policy, hints=hints, verify=verify, publish=publish, previous=previous, + internal_contract=internal_contract, + ) + async with session_factory() as session: + await _persist_audit(session, event, run) + _, created = await store.upsert(session, run.campaign, recorded_at=now) + await session.commit() + _observe(run, created=created, previous_state=previous.state if previous else None, now=now) + return WorkspaceChangeResult(True, decision.reason, run, created) + + +__all__ = ["WorkspaceChangeResult", "process_change_for_workspace"] diff --git a/patchfrog/campaigns/observe.py b/patchfrog/campaigns/observe.py new file mode 100644 index 0000000..42926fb --- /dev/null +++ b/patchfrog/campaigns/observe.py @@ -0,0 +1,85 @@ +"""Metric recording for watchers, campaigns and migration PRs (M11.12). + +Thin, label-validated wrappers over :mod:`patchfrog.ops.metrics`, so every +caller (the engine's ingest function and Cloud's tasks) increments the same +low-cardinality series. Labels come only from the closed vocabularies below; +an unknown value is folded into ``other`` rather than creating a new series. +Nothing here ever receives a token, URL, repository name or source text. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime + +from patchfrog.campaigns.domain import CampaignState, CompatibilityCampaign, RepositoryRecord +from patchfrog.ops import metrics +from patchfrog.watchers.domain import WatcherSourceKind + +FETCH_OUTCOMES = frozenset({"success", "not_modified", "rate_limited", "transient_error", "permanent_error", "unsafe"}) +WATCH_RESULTS = frozenset({"baseline", "unchanged", "changed", "duplicate_suppressed", "skipped"}) +BACKOFF_REASONS = frozenset({"rate_limited", "transient_error"}) +CAMPAIGN_EVENTS = frozenset({"created", "reconciled", "unchanged", "not_launched", "resolved"}) +PR_OUTCOMES = frozenset( + {"eligible", "opened", "updated", "stale_rejected", "duplicate_avoided", "failed", "awaiting_approval", "skipped"} +) +_KINDS = frozenset(k.value for k in WatcherSourceKind) + + +def _closed(value: str, allowed: frozenset[str]) -> str: + return value if value in allowed else "other" + + +def record_fetch(source_kind: str, outcome: str, *, seconds: float | None = None) -> None: + kind = _closed(source_kind, _KINDS) + metrics.watcher_fetch_attempts_total.labels(kind).inc() + metrics.watcher_fetches_total.labels(kind, _closed(outcome, FETCH_OUTCOMES)).inc() + if seconds is not None: + metrics.watcher_fetch_seconds.labels(kind).observe(max(0.0, seconds)) + + +def record_watch_result(source_kind: str, result: str) -> None: + metrics.watcher_changes_total.labels(_closed(source_kind, _KINDS), _closed(result, WATCH_RESULTS)).inc() + + +def record_backoff(source_kind: str, reason: str) -> None: + metrics.watcher_backoffs_total.labels(_closed(source_kind, _KINDS), _closed(reason, BACKOFF_REASONS)).inc() + + +def record_campaign_event(event: str) -> None: + metrics.campaigns_total.labels(_closed(event, CAMPAIGN_EVENTS)).inc() + + +def record_campaign_records(records: Iterable[RepositoryRecord]) -> None: + for record in records: + metrics.campaign_repositories_total.labels(record.state.value).inc() + + +def record_campaign_resolved(campaign: CompatibilityCampaign, *, now: datetime) -> None: + if campaign.state is CampaignState.RESOLVED: + metrics.campaign_resolution_seconds.observe(max(0.0, (now - campaign.discovered_at).total_seconds())) + + +def record_pr_outcome(outcome: str) -> None: + metrics.migration_prs_total.labels(_closed(outcome, PR_OUTCOMES)).inc() + + +def pr_outcome_for_status(status: str) -> str: + """Maps an M9 ``MigrationPRStatus`` value to a metric outcome.""" + + return { + "opened": "opened", "updated": "duplicate_avoided", "no_op_unchanged": "duplicate_avoided", + "stale_requires_regeneration": "stale_rejected", "failed": "failed", "skipped_not_eligible": "skipped", + }.get(status, "skipped") + + +__all__ = [ + "pr_outcome_for_status", + "record_backoff", + "record_campaign_event", + "record_campaign_records", + "record_campaign_resolved", + "record_fetch", + "record_pr_outcome", + "record_watch_result", +] diff --git a/patchfrog/cli.py b/patchfrog/cli.py index 8d4200a..8a91958 100644 --- a/patchfrog/cli.py +++ b/patchfrog/cli.py @@ -39,7 +39,7 @@ from apps.worker.celery_app import celery_app from apps.worker.tasks.run_review_pipeline import run_review_pipeline_task -from patchfrog import cli_changes +from patchfrog import cli_campaigns, cli_changes from patchfrog.analysis.domain import AnalysisRunSummary from patchfrog.analysis.service import StaleIndexError, StaticAnalysisService from patchfrog.change_risk import classify_change @@ -2490,6 +2490,7 @@ def main(argv: list[str] | None = None) -> int: ) cli_changes.register(subparsers) + cli_campaigns.register(subparsers) cross_repo_parser = subparsers.add_parser( "cross-repo", @@ -2684,6 +2685,8 @@ def main(argv: list[str] | None = None) -> int: return _run_dependencies_discover(args) if args.command in ("changes", "migrations"): return cli_changes.dispatch(args, upsert_repository=_upsert_cli_repository) + if args.command == "campaigns": + return cli_campaigns.dispatch(args) if args.command == "cross-repo": return _run_cross_repo(args) if args.command == "eval": diff --git a/patchfrog/cli_campaigns.py b/patchfrog/cli_campaigns.py new file mode 100644 index 0000000..065838c --- /dev/null +++ b/patchfrog/cli_campaigns.py @@ -0,0 +1,206 @@ +"""``patchfrog campaigns ...`` -- M10 organization compatibility campaigns, offline. + +``analyze`` evaluates one upstream change against several local checkouts the +way a hosted workspace would (freshness, access, blast radius, per-repo +migration and verification) without GitHub or a database. ``demo`` runs the +bundled deterministic end-to-end scenario. Nothing here calls a model provider +or writes to GitHub. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from patchfrog.campaigns.demo import DemoReport, DemoUnavailable, run_full_demo +from patchfrog.campaigns.domain import CompatibilityCampaign, EnrolledRepository, RepositoryAccess +from patchfrog.campaigns.dossier import campaign_to_dict, render_campaign_markdown +from patchfrog.campaigns.evaluate import RepositoryInput +from patchfrog.campaigns.orchestrate import run_campaign +from patchfrog.campaigns.policy import WatchMode, WorkspacePolicy +from patchfrog.cli_changes import CommandError, _repo_arg, add_change_inputs, build_event +from patchfrog.upstream.events import ContractLoadError +from patchfrog.upstream.hints import HintError +from patchfrog.upstream.sdk_surface import SurfaceError + +_POLICIES = {"detect_only": WatchMode.DETECT_ONLY, "migrate": WatchMode.MIGRATE} + + +def _entries(args: argparse.Namespace, now: datetime) -> list[RepositoryInput]: + if not args.repo: + raise CommandError("give at least one --repo PATH (or NAME=PATH)") + repos = [_repo_arg(r) for r in args.repo] + names = {r.name for r in repos} + for flag in ("stale", "unknown", "access_lost"): + missing = set(getattr(args, flag)) - names + if missing: + raise CommandError(f"--{flag.replace('_', '-')} names a repository not given as --repo: {sorted(missing)}") + entries: list[RepositoryInput] = [] + for repo in repos: + if not repo.path.is_dir(): + raise CommandError(f"not a directory: {repo.path}") + discovered: datetime | None = now - timedelta(minutes=5) + access = RepositoryAccess.AVAILABLE + if repo.name in args.stale: + discovered = now - timedelta(days=args.freshness_days + 1) + if repo.name in args.unknown: + discovered = None + if repo.name in args.access_lost: + access = RepositoryAccess.ACCESS_LOST + entries.append( + RepositoryInput( + repository=EnrolledRepository(full_name=repo.name, access=access, last_discovery_at=discovered), + root=repo.path, + ) + ) + return entries + + +def _print_campaign(campaign: CompatibilityCampaign, args: argparse.Namespace) -> None: + payload = campaign_to_dict(campaign) + if args.output: + Path(args.output).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(render_campaign_markdown(campaign), end="") + + +def run_campaigns_analyze(args: argparse.Namespace) -> int: + from patchfrog.campaigns.freshness import FreshnessPolicy + + now = datetime.fromisoformat(args.now).astimezone(UTC) if args.now else datetime.now(UTC) + entries = _entries(args, now) + event, hints = build_event(args, first_repo=entries[0].root) + run = asyncio.run( + run_campaign( + workspace_key=args.workspace, event=event, entries=entries, now=now, + policy=WorkspacePolicy(watch_mode=_POLICIES[args.policy]), + freshness_policy=FreshnessPolicy(window=timedelta(days=args.freshness_days)), hints=hints, + ) + ) + _print_campaign(run.campaign, args) + return 0 + + +def _demo_payload(report: DemoReport) -> dict[str, Any]: + def summarize(result: Any) -> dict[str, Any]: + assert result is not None and result.run is not None + return { + "campaign_created": result.campaign_created, + "dossier": campaign_to_dict(result.run.campaign), + } + + return { + "watcher": [o.kind.value for o in report.watcher], + "first_run": summarize(report.first), + "rerun": summarize(report.rerun), + "after_fixes": summarize(report.resolved), + "campaigns_in_database": report.campaigns_in_database, + "pull_requests_in_database": report.pull_requests_in_database, + "github_pr_creations": report.github_pr_creations, + "github_pr_drafts": report.github_pr_drafts, + } + + +def _states(result: Any) -> str: + campaign = result.run.campaign + return ", ".join(f"{r.repository.split('/')[-1]}={r.state.value}" for r in campaign.records) + + +def render_demo_text(report: DemoReport) -> str: + assert report.first and report.first.run and report.rerun and report.rerun.run + assert report.resolved and report.resolved.run + first, rerun, resolved = report.first.run.campaign, report.rerun.run.campaign, report.resolved.run.campaign + lines = [ + "PatchFrog compatibility campaign demo (fictional 'acme-ai' SDK; offline, no live provider call)", + "", + "1. Watcher", + " " + " -> ".join(o.kind.value for o in report.watcher) + + " (first poll is a baseline; the change is detected once; polling again emits nothing)", + "", + "2. First run: one campaign, four repositories", + f" campaign state: {first.state.value} organization safe: {first.org_blast_radius.safe}", + f" {_states(report.first)}", + f" PRs opened: {report.github_pr_creations} human-required actions: " + f"{sum(1 for r in first.records if r.state.value == 'human_required')}", + "", + "3. Re-run with identical inputs", + f" campaign created again: {report.rerun.campaign_created} version {first.version} -> {rerun.version}", + f" campaigns in database: {report.campaigns_in_database} PRs in database: " + f"{report.pull_requests_in_database} GitHub PR creations: {report.github_pr_creations}", + "", + "4. After the PR lands, the human fix is made and the stale repository is rediscovered", + f" campaign state: {resolved.state.value} (v{resolved.version}) organization safe: " + f"{resolved.org_blast_radius.safe}", + f" {_states(report.resolved)}", + "", + ] + return "\n".join(lines) + "\n" + + +def run_campaigns_demo(args: argparse.Namespace) -> int: + try: + report = asyncio.run(run_full_demo()) + except DemoUnavailable as exc: + raise CommandError(str(exc)) from exc + if args.json: + print(json.dumps(_demo_payload(report), indent=2, sort_keys=True)) + return 0 + print(render_demo_text(report), end="") + if report.first and report.first.run: + print("First-run dossier:\n") + print(render_campaign_markdown(report.first.run.campaign), end="") + return 0 + + +def register(subparsers: Any) -> None: + campaigns = subparsers.add_parser( + "campaigns", + help="M10: organization-wide compatibility campaigns for one upstream change (offline, deterministic)", + ) + sub = campaigns.add_subparsers(dest="campaigns_command", required=True) + + analyze = sub.add_parser( + "analyze", help="Evaluate one upstream change across several local checkouts as one campaign" + ) + add_change_inputs(analyze) + analyze.add_argument("--repo", action="append", default=[], help="Local checkout (repeatable; NAME=PATH to name it)") + analyze.add_argument("--policy", choices=sorted(_POLICIES), default="detect_only", + help="detect_only classifies; migrate also plans, generates and verifies (never publishes)") + analyze.add_argument("--stale", action="append", default=[], metavar="NAME", + help="Treat NAME's dependency discovery as older than the freshness window") + analyze.add_argument("--unknown", action="append", default=[], metavar="NAME", + help="Treat NAME as never discovered") + analyze.add_argument("--access-lost", action="append", default=[], metavar="NAME", + help="Treat NAME as no longer readable under the installation") + analyze.add_argument("--freshness-days", type=int, default=7, help="Freshness window (default 7)") + analyze.add_argument("--workspace", default="local", help="Workspace identity (default 'local')") + analyze.add_argument("--now", default=None, help="ISO timestamp to evaluate at (default: now)") + analyze.add_argument("--json", action="store_true", help="Print the machine-readable dossier") + analyze.add_argument("--output", default=None, help="Also write the JSON dossier to this path") + + demo = sub.add_parser( + "demo", help="The bundled four-repository watcher -> campaign -> PR -> resolution demo (offline)" + ) + demo.add_argument("--json", action="store_true") + + +def dispatch(args: argparse.Namespace) -> int: + try: + if args.campaigns_command == "analyze": + return run_campaigns_analyze(args) + if args.campaigns_command == "demo": + return run_campaigns_demo(args) + except (CommandError, ContractLoadError, SurfaceError, HintError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + return 1 + + +__all__ = ["dispatch", "register", "render_demo_text", "run_campaigns_demo"] diff --git a/patchfrog/ops/metrics.py b/patchfrog/ops/metrics.py index dd9fee9..d2a84f1 100644 --- a/patchfrog/ops/metrics.py +++ b/patchfrog/ops/metrics.py @@ -195,3 +195,32 @@ def mark_worker_process_dead(pid: int) -> None: from prometheus_client import multiprocess multiprocess.mark_process_dead(pid) # type: ignore[no-untyped-call] + +# M10/M11 watchers, campaigns and migration PRs. Every label is a closed enum +# value (watcher source kind, outcome/state vocabularies) -- never a +# repository, workspace, package, URL or PR number. +watcher_fetch_attempts_total = Counter( + "patchfrog_watcher_fetch_attempts_total", "Upstream watcher fetch attempts", ["source_kind"] +) +watcher_fetches_total = Counter( + "patchfrog_watcher_fetches_total", "Upstream watcher fetches by outcome", ["source_kind", "outcome"] +) +watcher_fetch_seconds = Histogram( + "patchfrog_watcher_fetch_seconds", "Wall-clock duration of one upstream watcher fetch", ["source_kind"] +) +watcher_changes_total = Counter( + "patchfrog_watcher_changes_total", "Upstream watcher comparison results", ["source_kind", "result"] +) +watcher_backoffs_total = Counter( + "patchfrog_watcher_backoffs_total", "Upstream watcher retries scheduled with backoff", ["source_kind", "reason"] +) +campaigns_total = Counter("patchfrog_campaigns_total", "Compatibility campaign events", ["event"]) +campaign_repositories_total = Counter( + "patchfrog_campaign_repositories_total", "Repository records by final state at the end of a campaign run", ["state"] +) +campaign_resolution_seconds = Histogram( + "patchfrog_campaign_resolution_seconds", "Time from campaign discovery to RESOLVED", + buckets=(60, 600, 3600, 6 * 3600, 24 * 3600, 3 * 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600), +) +migration_prs_total = Counter("patchfrog_migration_prs_total", "Migration PR publication outcomes", ["outcome"]) + diff --git a/patchfrog/upstream/events.py b/patchfrog/upstream/events.py index 82406e5..ae05328 100644 --- a/patchfrog/upstream/events.py +++ b/patchfrog/upstream/events.py @@ -126,6 +126,19 @@ def contract_from_registry_snapshot(normalized_json: str, *, fingerprint: str, r normalized = json.loads(normalized_json) fmt = "sdk_surface" if normalized.get("format") == "sdk_surface" else "openapi" + if fmt == "sdk_surface": + # A stored surface (e.g. a watcher cursor's previous snapshot) carries everything a diff needs; rebuild + # the SdkSurface so it can be the old side of ``build_contract_change`` like a freshly parsed document. + try: + ecosystem = Ecosystem(str(normalized["ecosystem"])) if normalized.get("ecosystem") else None + except ValueError as exc: + raise ContractLoadError(f"{ref}: stored surface has an unknown ecosystem") from exc + package = str(normalized.get("package") or "") + surface = SdkSurface(package=package, ecosystem=ecosystem, version=version, normalized=normalized) + return LoadedContract( + format=fmt, normalized=normalized, fingerprint=fingerprint, version=version, ref=ref, surface=surface, + title=package or None, + ) if fmt == "openapi" and "paths" not in normalized: raise ContractLoadError(f"{ref}: registry snapshot is not an OpenAPI contract (SDK usage snapshots record " "consumed surface only and cannot be diffed structurally)") diff --git a/tests/integration/test_campaign_orchestration.py b/tests/integration/test_campaign_orchestration.py index 5acf79b..78a42b5 100644 --- a/tests/integration/test_campaign_orchestration.py +++ b/tests/integration/test_campaign_orchestration.py @@ -8,6 +8,7 @@ import shutil from pathlib import Path +import pytest from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from patchfrog.campaigns.domain import ( @@ -34,6 +35,7 @@ from patchfrog.campaigns.store import CampaignStore from patchfrog.dependencies.discovery import discover_dependencies from patchfrog.dependencies.domain import DependencyInventory, Ecosystem +from patchfrog.executable_verification.sandbox import is_sandbox_available from patchfrog.migration_pr.fake_github import FakeMigrationGitHubPublisher from patchfrog.migration_pr.publisher import MigrationPRPublisher from patchfrog.upstream.events import build_contract_change, load_contract_file @@ -51,6 +53,9 @@ ) MIGRATE = WorkspacePolicy(watch_mode=WatchMode.MIGRATE) +#: Hosts without a working bwrap/prlimit sandbox skip the tests whose point is a *VERIFIED* outcome (the +#: repository's established convention -- see docs/ci-health.md); freshness/state/idempotency tests still run. +needs_sandbox = pytest.mark.skipif(not is_sandbox_available(), reason="verification sandbox unavailable on this host") WS = "workspace-1" @@ -79,6 +84,7 @@ def _states(result: CampaignRunResult) -> dict[str, RepoState]: return {r.repository.removeprefix("acme/"): r.state for r in result.campaign.records} +@needs_sandbox async def test_mixed_outcomes_are_never_reported_resolved() -> None: result = await _baseline() assert _states(result) == { @@ -95,6 +101,7 @@ async def test_mixed_outcomes_are_never_reported_resolved() -> None: assert any("cannot be declared safe" in risk for risk in campaign.unresolved_risks) +@needs_sandbox async def test_per_repository_evidence_is_independent() -> None: result = await _baseline() web, worker = (result.campaign.record_for(f"acme/{n}") for n in ("acme-web", "acme-worker")) @@ -214,6 +221,7 @@ def _publisher( return pr_publisher_from(MigrationPRPublisher(session_factory=session_factory, publisher=fake, installation_id=1)) +@needs_sandbox async def test_automatic_publication_opens_exactly_one_pr_and_never_for_human_required( session_factory: async_sessionmaker[AsyncSession], ) -> None: @@ -235,6 +243,7 @@ async def test_automatic_publication_opens_exactly_one_pr_and_never_for_human_re assert again.campaign.version == first.campaign.version +@needs_sandbox async def test_draft_only_and_manual_approval_modes(session_factory: async_sessionmaker[AsyncSession]) -> None: draft_fake = _fake_github() draft = WorkspacePolicy(watch_mode=WatchMode.MIGRATE_AND_OPEN_PR, publication=PublicationMode.DRAFT_ONLY) @@ -255,6 +264,7 @@ async def test_migrate_mode_never_touches_github(session_factory: async_sessionm assert fake.create_ref_calls == [] and fake.create_pull_request_calls == [] +@needs_sandbox async def test_stale_base_is_rejected_and_never_published(session_factory: async_sessionmaker[AsyncSession]) -> None: fake = _fake_github() fake.set_ref(owner="acme", repository="acme-web", ref="heads/main", sha="b" * 40) # base moved @@ -384,6 +394,7 @@ async def test_same_change_in_two_workspaces_makes_two_campaigns( assert len(await store.list_for_workspace(session, workspace_key="ws-b")) == 1 +@needs_sandbox async def test_dossier_is_machine_readable_and_honest() -> None: result = await _baseline() dossier = campaign_to_dict(result.campaign) diff --git a/tests/integration/test_campaign_pipeline_e2e.py b/tests/integration/test_campaign_pipeline_e2e.py new file mode 100644 index 0000000..6141fac --- /dev/null +++ b/tests/integration/test_campaign_pipeline_e2e.py @@ -0,0 +1,211 @@ +"""M10/M11 end to end: watcher -> change event -> campaign -> per-repository +M6->M9 -> (fake) PR -> reconciliation, with real persistence, real sandbox +verification and the in-memory GitHub. No live provider call anywhere.""" + +from __future__ import annotations + +import json + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from patchfrog.campaigns.demo import DemoReport, run_full_demo +from patchfrog.campaigns.domain import CampaignState, EnrolledRepository, RepoState +from patchfrog.campaigns.evaluate import RepositoryInput +from patchfrog.campaigns.ingest import process_change_for_workspace +from patchfrog.campaigns.policy import PublicationMode, WatchMode, WorkspacePolicy +from patchfrog.cli import main +from patchfrog.executable_verification.sandbox import is_sandbox_available +from patchfrog.persistence.models.campaign import CompatibilityCampaignModel +from patchfrog.persistence.models.migration_pr import MigrationPRModel +from patchfrog.persistence.models.migration_verification import MigrationVerificationRunModel +from patchfrog.persistence.models.repository import RepositoryModel +from patchfrog.persistence.models.upstream import ( + ExternalChangeEventModel, + MigrationPatchModel, + MigrationPlanModel, +) +from patchfrog.upstream.domain import ChangeRisk, DependencyTarget +from patchfrog.upstream.events import build_version_change +from patchfrog.watchers.domain import WatchOutcomeKind +from tests.support.campaigns import ACME, FRESH_AT, NOW, SHA, acme_event, acme_input + +needs_sandbox = pytest.mark.skipif(not is_sandbox_available(), reason="verification sandbox unavailable on this host") + +_DEMO: DemoReport | None = None + + +async def _demo() -> DemoReport: + global _DEMO + if _DEMO is None: + _DEMO = await run_full_demo() + return _DEMO + + +@needs_sandbox +async def test_full_demo_matches_the_acceptance_scenario() -> None: + report = await _demo() + assert [o.kind for o in report.watcher] == [ + WatchOutcomeKind.BASELINE, WatchOutcomeKind.CHANGED, WatchOutcomeKind.UNCHANGED, + ] + assert report.first is not None and report.first.run is not None and report.first.launched + first = report.first.run.campaign + # one campaign, four repository evaluations + assert report.first.campaign_created and len(first.records) == 4 + states = {r.repository.split("/")[1]: r.state for r in first.records} + assert states == { + "acme-web": RepoState.PR_OPENED, "acme-worker": RepoState.HUMAN_REQUIRED, + "acme-search": RepoState.NOT_AFFECTED, "acme-legacy": RepoState.STALE, + } + # not falsely resolved; stale repo never claimed unaffected + assert first.state is CampaignState.ACTION_REQUIRED and not first.org_blast_radius.safe + # exactly one evidence-backed PR (verified evidence), one human-required action, one stale repo + assert report.github_pr_creations == 1 and report.github_pr_drafts == [False] + web = first.record_for("acme/acme-web") + assert web is not None and web.verification_outcome == "verified" and web.pr_number is not None + assert sum(1 for r in first.records if r.state is RepoState.HUMAN_REQUIRED) == 1 + assert [r.repository for r in first.records if r.requires_rediscovery] == ["acme/acme-legacy"] + # re-run: no duplicate campaign, no duplicate PR, no version churn + assert report.rerun is not None and report.rerun.run is not None + assert not report.rerun.campaign_created and report.rerun.run.campaign.version == first.version + assert report.campaigns_in_database == 1 and report.pull_requests_in_database == 1 + # second scenario: fix + rediscovery -> RESOLVED + assert report.resolved is not None and report.resolved.run is not None + resolved = report.resolved.run.campaign + assert resolved.state is CampaignState.RESOLVED and resolved.org_blast_radius.safe + assert {r.repository.split("/")[1]: r.state for r in resolved.records} == { + "acme-web": RepoState.RESOLVED, "acme-worker": RepoState.RESOLVED, + "acme-search": RepoState.NOT_AFFECTED, "acme-legacy": RepoState.NOT_AFFECTED, + } + + +@needs_sandbox +def test_campaigns_demo_cli_text_and_json(capsys: pytest.CaptureFixture[str]) -> None: + assert main(["campaigns", "demo"]) == 0 + text = capsys.readouterr().out + assert "action_required" in text and "resolved" in text and "organization safe: False" in text + assert "campaign created again: False" in text and "GitHub PR creations: 1" in text + assert main(["campaigns", "demo", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["first_run"]["dossier"]["campaign"]["state"] == "action_required" + assert payload["after_fixes"]["dossier"]["campaign"]["state"] == "resolved" + assert payload["github_pr_creations"] == 1 and payload["campaigns_in_database"] == 1 + + +def test_campaigns_analyze_cli_reports_stale_and_access_lost_honestly( + capsys: pytest.CaptureFixture[str], tmp_path: object +) -> None: + repos = ACME / "repos" + args = [ + "campaigns", "analyze", "--old", str(ACME / "contract" / "old.yaml"), "--new", str(ACME / "contract" / "new.yaml"), + "--hints", str(ACME / "contract" / "hints.yaml"), + "--repo", f"web={repos / 'acme-web'}", "--repo", f"search={repos / 'acme-search'}", + "--repo", f"legacy={repos / 'acme-legacy'}", "--repo", f"gone={repos / 'acme-worker'}", + "--stale", "legacy", "--access-lost", "gone", "--json", "--now", "2026-10-02T12:00:00+00:00", + ] + assert main(args) == 0 + dossier = json.loads(capsys.readouterr().out) + by_repo = {r["repository"]: r for r in dossier["repositories"]} + assert by_repo["legacy"]["state"] == "stale" and by_repo["legacy"]["requires_rediscovery"] + assert by_repo["gone"]["state"] == "access_lost" and by_repo["gone"]["impact"]["direct"] == 0 + assert by_repo["search"]["state"] == "not_affected" + assert by_repo["web"]["state"] == "impacted" # default policy is detect_only: nothing is generated + assert dossier["impact"]["organization_safe"] is False + assert dossier["campaign"]["state"] == "action_required" + + +def test_campaigns_analyze_cli_rejects_bad_input(capsys: pytest.CaptureFixture[str]) -> None: + assert main(["campaigns", "analyze", "--old", str(ACME / "contract" / "old.yaml"), + "--new", str(ACME / "contract" / "new.yaml")]) == 2 + assert "give at least one --repo" in capsys.readouterr().err + assert main(["campaigns", "analyze", "--old", str(ACME / "contract" / "old.yaml"), + "--new", str(ACME / "contract" / "new.yaml"), "--repo", f"a={ACME / 'repos' / 'acme-web'}", + "--stale", "typo"]) == 2 + assert "not given as --repo" in capsys.readouterr().err + + +# -- gate + policy at the ingest boundary ---------------------------------------------------------- + + +async def test_a_version_only_change_is_recorded_but_opens_no_campaign( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + event = build_version_change(DependencyTarget(package_name="acme-ai"), "1.4.0", "2.0.0") + assert event.classification.risk is ChangeRisk.REVIEW_REQUIRED + result = await process_change_for_workspace( + session_factory=session_factory, workspace_key="ws", event=event, entries=[acme_input("acme-web")], now=NOW, + policy=WorkspacePolicy(watch_mode=WatchMode.MIGRATE), + ) + assert not result.launched and "below the campaign threshold" in result.reason and result.run is None + async with session_factory() as session: + assert (await session.execute(select(func.count()).select_from(CompatibilityCampaignModel))).scalar_one() == 0 + lowered = await process_change_for_workspace( + session_factory=session_factory, workspace_key="ws", event=event, entries=[acme_input("acme-search")], now=NOW, + policy=WorkspacePolicy(watch_mode=WatchMode.DETECT_ONLY), min_risk=ChangeRisk.REVIEW_REQUIRED, + ) + assert lowered.launched and lowered.run is not None + + +async def test_watch_mode_off_launches_nothing(session_factory: async_sessionmaker[AsyncSession]) -> None: + event, hints = acme_event() + result = await process_change_for_workspace( + session_factory=session_factory, workspace_key="ws", event=event, entries=[acme_input("acme-web")], now=NOW, + policy=WorkspacePolicy(watch_mode=WatchMode.OFF), hints=hints, + ) + assert not result.launched and "off" in result.reason + + +async def test_detect_only_ingest_persists_a_campaign_and_the_event_but_no_migration( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + event, hints = acme_event() + result = await process_change_for_workspace( + session_factory=session_factory, workspace_key="ws", event=event, + entries=[acme_input("acme-web"), acme_input("acme-search")], now=NOW, hints=hints, + policy=WorkspacePolicy(), + ) + assert result.launched and result.campaign_created and result.run is not None + assert result.run.campaign.state is CampaignState.ACTION_REQUIRED + async with session_factory() as session: + assert (await session.execute(select(func.count()).select_from(CompatibilityCampaignModel))).scalar_one() == 1 + assert (await session.execute(select(func.count()).select_from(ExternalChangeEventModel))).scalar_one() == 1 + assert (await session.execute(select(func.count()).select_from(MigrationPlanModel))).scalar_one() == 0 + + +@needs_sandbox +async def test_ingest_writes_the_audit_trail_atomically_with_the_campaign( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as session: + repository = RepositoryModel( + github_repository_id=1, owner="acme", name="acme-web", full_name="acme/acme-web", installation_id=1 + ) + session.add(repository) + await session.commit() + repository_id = repository.id + event, hints = acme_event() + base = acme_input("acme-web") + entry = RepositoryInput( + repository=EnrolledRepository("acme/acme-web", last_discovery_at=FRESH_AT, last_discovery_commit_sha=SHA), + root=base.root, commit_sha=SHA, extra_workspace_paths=base.extra_workspace_paths, + engine_repository_id=repository_id, + ) + result = await process_change_for_workspace( + session_factory=session_factory, workspace_key="ws", event=event, entries=[entry], now=NOW, hints=hints, + policy=WorkspacePolicy(watch_mode=WatchMode.MIGRATE, publication=PublicationMode.MANUAL_APPROVAL), + ) + assert result.run is not None and result.run.campaign.records[0].state is RepoState.VERIFIED + async with session_factory() as session: + for model in (CompatibilityCampaignModel, ExternalChangeEventModel, MigrationPlanModel, MigrationPatchModel, + MigrationVerificationRunModel): + assert (await session.execute(select(func.count()).select_from(model))).scalar_one() == 1, model + assert (await session.execute(select(func.count()).select_from(MigrationPRModel))).scalar_one() == 0 + # an identical re-run reconciles in place: still one of everything + await process_change_for_workspace( + session_factory=session_factory, workspace_key="ws", event=event, entries=[entry], now=NOW, hints=hints, + policy=WorkspacePolicy(watch_mode=WatchMode.MIGRATE, publication=PublicationMode.MANUAL_APPROVAL), + ) + async with session_factory() as session: + for model in (CompatibilityCampaignModel, MigrationPlanModel, MigrationPatchModel, MigrationVerificationRunModel): + assert (await session.execute(select(func.count()).select_from(model))).scalar_one() == 1, model From 459a2a6fb428b6f75b8f06e3ab1903bb46f13be2 Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 05:38:43 +0300 Subject: [PATCH 06/17] CI: stop rendering key material into job logs; add a regression guard The disposable GitHub App private key lived in the job-level env block, which GitHub prints (resolved) in every step header. The key and the webhook secret are now generated at run time, written to a file / $GITHUB_ENV, masked, and the container check mounts the key file instead of passing its content. The new workflow-hygiene test fails on the previous configuration and passes on this one. This stops intentional plain-text display going forward; it does not erase anything already present in historical logs. --- .github/workflows/ci.yml | 67 +++++++++---------- tests/unit/test_ci_workflow_hygiene.py | 89 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 37 deletions(-) create mode 100644 tests/unit/test_ci_workflow_hygiene.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caa0cd0..99c843c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,43 +37,16 @@ jobs: APP_ENV: test DATABASE_URL: postgresql+asyncpg://patchfrog:patchfrog@localhost:5432/patchfrog REDIS_URL: redis://localhost:6379/0 - # A GitHub App identity is required for Settings to construct, not - # for any real GitHub call CI makes -- every job below runs against + # A GitHub App identity is required for Settings to construct, not for + # any real GitHub call CI makes -- every job below runs against # fixtures/oracle providers only, never live credentials. - # GITHUB_PRIVATE_KEY below is a disposable RSA keypair generated - # solely for this workflow (`openssl genrsa 2048`) -- never - # registered with any real GitHub App, never used outside CI. + # + # Secret-looking values are deliberately NOT written here: a job-level + # `env:` block is printed, resolved, in the header of every step's log. + # The disposable App key and webhook secret are generated at run time + # by the "Generate disposable credentials" step below, written to a + # file / $GITHUB_ENV, and masked. See tests/unit/test_ci_workflow_hygiene.py. GITHUB_APP_ID: "0" - GITHUB_PRIVATE_KEY: | - -----BEGIN PRIVATE KEY----- - MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDc1Br1N+2s8fLE - 2r1+/42VyQRbJ/XColFATS4htUGInCp3vrp2lJD7rfhlQLCCj6+oL8KFOI9wRjFD - HQUfUbSyOAiPVjwwLw4wg/fegw/rPIqk0sbtgLX6tNsiDvF+jeZ2ILIiA6O5h/of - tAPwHZFbM0F28CNC/FczaAN49ICnGg/5cbkCmWu6ZFRHYLBdKeQK2326zOjLZAAf - 3U08BjvSwjxGuytSAlETUTLnn9NmwSiAAIzhqfsufIOJ21aS30lmmfUdehmUuDOM - Gx3o+C5DTwX0zqHB4Te3aLk1PBzKxVMFzPsxTJDKBzuAWE8RyuvKN3HRGPgluIM9 - o2WsR8EZAgMBAAECggEAMWTUHtOCuzNnLPcMedKplb5f1g/rHbz21Z4t2mZToT3l - FeG4jXuPrmvsaqF8mceP79u289uB4gghTrS4B5hcBZ0Y9Q7B6bkswFdUYxYHwoNJ - M7LdwhQPIGPBvC2BgOA2k2ee0JLl1UMABIVceCH0+GRPmU3/ZSVST/JNDIoMGNQG - EjuHnM8Hjs4WP2r7ous2CeEcUdDrnhqrVfcFOzIKMEiffoWIaw12oBhRR6D+stP1 - wB95YJ57326Z/yQyQUZw+iOOpkG0url3zyzJUu5jvpF9mAOVHt1i61N0OxktYNd4 - b5OuXfOTbYoaLl+jn3mzW/pLQpkfgQa28NBqDDcUMQKBgQD49XsudnZQ0NoeuU8+ - LTxQ/e6R0pBKjqS/4yv0nxrt5aEY8CKp7nrVGhubX1btRIZNS3xc+UrtWrdivSzM - 9Y3LAIw67XOa1oYID0hg0C6q5jftpMpqb9C9ZKEIz9bP4V4xpNDM67k48qAujvm2 - XGZ6AgRT2RSFtr4H+CzongSlZQKBgQDjEvQvsgogK/XfMDJmKA34Y/8q5mGmp4PU - 8lf7KTwsBg2rgABqU5ZDoDuguBzrch1eE5NxECN1mQyDG2eX201eL3zerCalXXYT - p+nDuHHwQBiua+cjAKPOHny+IV4Y2ijij6nXK10Trb4POOdjuMZQU8aBnlE3LTlR - AADs6sGbpQKBgAzlkpsGkfx/xTzgNqWu7Df535j4QndKSmiSWR2T/G4kfiLvlawd - 6YkOkC2NU9B4w0hPx8J9O49uKp/X0vFPKyhAAAOxd1KnGExklkhFoJVD1dPevImk - /zhAdUMimSstrpZ9xj9HWtIDZ57h0zfKM5jQb+70COcgX1nEVnc6cF0tAoGAHOEZ - O0OqdAMDRzpq/oETncah+kPIoZhmdNvJgsDwzSyL6obiBqPQ1ZL4Ym1oCC3APGnC - 87s4t0kaxUXj+/VBNrxVZkCAvcFQNhnB6kl3/DU6rlw8zj/TlXBdLxUxUXnVf1ME - J8B1Gv6a0HbNPuqOFkNBWNTYwWq4Amw8seT9cJ0CgYEA5ovXV2T4++ws2Qt3cPO5 - im8J2aCh1fYKE/1ZNnuO0xnUUu3VeH8Rz2iw4mFzELKc64v9BSw2xON7tWSDtY6z - 0VKkLmokNeUbcCpEs/lLy6/bdP0/pVTBLesXBDsm6qnAw1Stnc/MTnXPsiaTYwke - VgAFWZ9YEAZPVROqM6i76BU= - -----END PRIVATE KEY----- - GITHUB_WEBHOOK_SECRET: ci-placeholder-not-a-real-secret # The database-specific suite must fail if the declared CI service is # unavailable or unmigrated. Local runs may skip these tests when no # compatible Postgres is present. @@ -84,6 +57,24 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Generate disposable credentials (written to a file, masked, never printed) + run: | + set +x + umask 077 + key="$RUNNER_TEMP/ci-github-app-key.pem" + openssl genrsa -out "$key" 2048 2>/dev/null + # Belt and braces: if any later step ever echoed this key, the runner redacts it. + while IFS= read -r line; do + [ "${#line}" -ge 8 ] && echo "::add-mask::$line" + done < "$key" + chmod 0644 "$key" # a disposable test key; the container user must be able to read the mounted file + webhook_secret="$(openssl rand -hex 24)" + echo "::add-mask::$webhook_secret" + { + echo "GITHUB_PRIVATE_KEY_PATH=$key" + echo "GITHUB_WEBHOOK_SECRET=$webhook_secret" + } >> "$GITHUB_ENV" + - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -142,8 +133,10 @@ jobs: run: | docker run --rm \ -e DATABASE_URL="$DATABASE_URL" -e REDIS_URL="$REDIS_URL" \ - -e GITHUB_APP_ID="$GITHUB_APP_ID" -e GITHUB_PRIVATE_KEY="$GITHUB_PRIVATE_KEY" \ - -e GITHUB_WEBHOOK_SECRET="$GITHUB_WEBHOOK_SECRET" \ + -e GITHUB_APP_ID="$GITHUB_APP_ID" \ + -v "$GITHUB_PRIVATE_KEY_PATH:/run/ci-github-app-key.pem:ro" \ + -e GITHUB_PRIVATE_KEY_PATH=/run/ci-github-app-key.pem \ + -e GITHUB_WEBHOOK_SECRET \ --network host \ patchfrog-worker:ci python -c " from apps.worker.celery_app import celery_app diff --git a/tests/unit/test_ci_workflow_hygiene.py b/tests/unit/test_ci_workflow_hygiene.py new file mode 100644 index 0000000..4d76424 --- /dev/null +++ b/tests/unit/test_ci_workflow_hygiene.py @@ -0,0 +1,89 @@ +"""M11.13: CI must not render secret material into job logs. + +History: a disposable RSA key used to be written into the workflow's job +``env:`` block. GitHub prints a step's resolved ``env:`` in the step header, so +the full key landed in plain-text logs. This test guards the *configuration* +against reintroducing that. It cannot (and does not claim to) clean logs that +were already written. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import pytest +import yaml + +WORKFLOWS = sorted((Path(__file__).resolve().parents[2] / ".github" / "workflows").glob("*.y*ml")) +SECRETISH = re.compile(r"(PRIVATE_KEY|SECRET|TOKEN|PASSWORD|API_KEY|CREDENTIAL)", re.IGNORECASE) +EXPRESSION = re.compile(r"^\$\{\{\s*(secrets|vars)\.[A-Za-z0-9_]+\s*\}\}$") +#: The only secret-looking literal allowed: the throw-away password of the Postgres *service +#: container* (it protects an empty database that exists for the length of one job). +ALLOWED_LITERALS = {"POSTGRES_PASSWORD"} + + +def _env_blocks(node: Any, path: str = "") -> list[tuple[str, dict[str, Any]]]: + found: list[tuple[str, dict[str, Any]]] = [] + if isinstance(node, dict): + for key, value in node.items(): + here = f"{path}.{key}" if path else str(key) + if key == "env" and isinstance(value, dict): + found.append((here, value)) + found.extend(_env_blocks(value, here)) + elif isinstance(node, list): + for index, value in enumerate(node): + found.extend(_env_blocks(value, f"{path}[{index}]")) + return found + + +def test_there_is_at_least_one_workflow() -> None: + assert WORKFLOWS, "no workflows found -- the hygiene check would be vacuous" + + +@pytest.mark.parametrize("workflow", WORKFLOWS, ids=lambda p: p.name) +def test_no_secret_looking_literal_in_any_env_block(workflow: Path) -> None: + document = yaml.safe_load(workflow.read_text()) + for where, env in _env_blocks(document): + for name, value in env.items(): + if not SECRETISH.search(str(name)) or str(name) in ALLOWED_LITERALS: + continue + assert EXPRESSION.match(str(value).strip()), ( + f"{workflow.name}: {where}.{name} holds a literal; secret-looking values must come from " + "`${{ secrets.* }}` or be generated and masked at run time (the env block is printed in step logs)" + ) + + +@pytest.mark.parametrize("workflow", WORKFLOWS, ids=lambda p: p.name) +def test_no_key_material_or_long_encoded_blob_in_the_workflow_text(workflow: Path) -> None: + text = workflow.read_text() + assert not re.search(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", text) + assert not re.search(r"\bMII[A-Za-z0-9+/]{40,}", text), "a PEM/DER body appears to be embedded" + assert not re.search(r"[A-Za-z0-9+/]{80,}={0,2}", text), "a long encoded blob appears to be embedded" + + +@pytest.mark.parametrize("workflow", WORKFLOWS, ids=lambda p: p.name) +def test_no_command_that_prints_the_environment_or_resolved_config(workflow: Path) -> None: + for line in workflow.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + continue + assert not re.search(r"\bset -[a-z]*x", stripped), f"shell tracing prints expanded secrets: {stripped!r}" + assert not re.search(r"\b(printenv|env\s*\||export -p)\b", stripped), f"prints the environment: {stripped!r}" + assert "docker compose config" not in stripped and "docker-compose config" not in stripped + assert not re.search(r"cat\s+\S*\.env\b", stripped) + + +def test_the_ci_job_generates_its_disposable_credentials_at_run_time() -> None: + document = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github" / "workflows" / "ci.yml").read_text()) + scripts = [ + str(step.get("run", "")) + for job in document["jobs"].values() + for step in job.get("steps", []) + ] + generator = next((s for s in scripts if "openssl genrsa" in s), None) + assert generator is not None, "no step generates the disposable key" + assert "::add-mask::" in generator and "GITHUB_ENV" in generator and "GITHUB_PRIVATE_KEY_PATH" in generator + # the container check mounts the key file; it never passes the key's content as an env value + assert all(not re.search(r"-e\s+GITHUB_PRIVATE_KEY=", s) for s in scripts) From f70f06373c8efb422eb833c83f21cbd61ea880a2 Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 05:40:33 +0300 Subject: [PATCH 07/17] Exclude the campaign fixture repositories from pytest, ruff and mypy collection --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f63ed6a..6107639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,12 +98,12 @@ include = ["patchfrog*", "apps*"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" -addopts = "-ra --ignore=tests/fixtures/repos --ignore=tests/fixtures/evaluation/cases --ignore=tests/fixtures/cost_benchmark --ignore=tests/fixtures/dependencies --ignore=tests/fixtures/upstream_changes" +addopts = "-ra --ignore=tests/fixtures/repos --ignore=tests/fixtures/evaluation/cases --ignore=tests/fixtures/cost_benchmark --ignore=tests/fixtures/dependencies --ignore=tests/fixtures/upstream_changes --ignore=tests/fixtures/campaigns" [tool.ruff] line-length = 100 target-version = "py312" -extend-exclude = ["tests/fixtures/repos", "tests/fixtures/evaluation/cases", "tests/fixtures/cost_benchmark", "tests/fixtures/dependencies", "tests/fixtures/upstream_changes"] +extend-exclude = ["tests/fixtures/repos", "tests/fixtures/evaluation/cases", "tests/fixtures/cost_benchmark", "tests/fixtures/dependencies", "tests/fixtures/upstream_changes", "tests/fixtures/campaigns"] [tool.ruff.lint] select = [ @@ -121,7 +121,7 @@ strict = true warn_unused_ignores = true disallow_untyped_defs = true plugins = ["pydantic.mypy"] -exclude = ["migrations/", "\\.venv/", "tests/fixtures/repos/", "tests/fixtures/evaluation/cases/", "tests/fixtures/cost_benchmark/", "tests/fixtures/dependencies/", "tests/fixtures/upstream_changes/"] +exclude = ["migrations/", "\\.venv/", "tests/fixtures/repos/", "tests/fixtures/evaluation/cases/", "tests/fixtures/cost_benchmark/", "tests/fixtures/dependencies/", "tests/fixtures/upstream_changes/", "tests/fixtures/campaigns/"] [[tool.mypy.overrides]] module = "celery.*" From f06d5f9cdcdbe0e6c21c8f7d69c1c32f919159f7 Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 05:43:09 +0300 Subject: [PATCH 08/17] M11: lossless event serialization, tenant-scoped registry read, registry-consulted evidence Hosted pipelines detect a change in one process and evaluate it per workspace in others: add a lossless ExternalChangeEvent JSON round trip. Add a repository-scoped registry read (the existing one is intentionally global and unsafe for multi-tenant callers; an empty scope never means 'all'). Let a repository input say 'the registry was consulted' so a fresh discovery that found nothing matching is NOT_AFFECTED without a checkout, while no evidence at all stays UNKNOWN. --- patchfrog/campaigns/evaluate.py | 5 +- patchfrog/upstream/serialize.py | 141 ++++++++++++++++++ patchfrog/upstream/store.py | 39 ++++- .../test_campaign_orchestration.py | 87 +++++++++++ .../unit/test_upstream_event_serialization.py | 84 +++++++++++ 5 files changed, 347 insertions(+), 9 deletions(-) create mode 100644 patchfrog/upstream/serialize.py create mode 100644 tests/unit/test_upstream_event_serialization.py diff --git a/patchfrog/campaigns/evaluate.py b/patchfrog/campaigns/evaluate.py index 8c836b0..920a00d 100644 --- a/patchfrog/campaigns/evaluate.py +++ b/patchfrog/campaigns/evaluate.py @@ -79,6 +79,9 @@ class RepositoryInput: root: Path | None = None inventory: DependencyInventory | None = None registry_rows: tuple[RegistryDependency, ...] = () + #: The registry was consulted for this repository (``registry_rows`` may legitimately be empty: a + #: fresh discovery that found nothing matching). Distinct from "no evidence at all". + use_registry: bool = False commit_sha: str | None = None extra_workspace_paths: tuple[Path, ...] = () #: The record from an earlier run of this campaign, if any. @@ -118,7 +121,7 @@ def _impact( return analyze_repository(entry.root, event, hints=hints, repository=name, commit_sha=sha) if entry.inventory is not None: return analyze_inventory(entry.inventory, event, hints=hints), entry.inventory - if entry.registry_rows: + if entry.registry_rows or entry.use_registry: workspace = registry_impact(entry.registry_rows, event, hints=hints, repositories=[name]) return (workspace.repositories[0] if workspace.repositories else None), None return None, None diff --git a/patchfrog/upstream/serialize.py b/patchfrog/upstream/serialize.py new file mode 100644 index 0000000..07646d5 --- /dev/null +++ b/patchfrog/upstream/serialize.py @@ -0,0 +1,141 @@ +"""Lossless JSON round trip for :class:`ExternalChangeEvent` (M11). + +A hosted pipeline detects a change in one process and evaluates it per +workspace in others, so the event must cross a process boundary without being +re-derived. Everything M6 consumer mapping needs is preserved, including the +old contract's normalized structure (OpenAPI path matching reads it); a +normalized contract larger than the bound is dropped and the event says so in +its ``notes`` rather than silently degrading. + +Nothing here is secret-bearing: an event holds contract structure, versions, +release metadata and digests -- never notes text, credentials or source code. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any + +from patchfrog.dependencies.domain import Ecosystem +from patchfrog.upstream.domain import ( + ChangeClassification, + ChangeRisk, + CompatibilityClass, + ContractDiffItem, + DependencyRelease, + DependencyTarget, + DiffItemKind, + DiffSubject, + ExternalChangeEvent, + ExternalChangeKind, + ExternalChangeSource, + ExternalContractRevision, + SubjectKind, +) + +SERIALIZATION_VERSION = 1 +MAX_SERIALIZED_CONTRACT_BYTES = 256_000 + + +def _revision(revision: ExternalContractRevision, label: str, notes: list[str]) -> dict[str, Any]: + normalized: Any = None + if revision.normalized is not None: + rendered = json.dumps(revision.normalized, sort_keys=True, separators=(",", ":"), default=str) + if len(rendered.encode()) <= MAX_SERIALIZED_CONTRACT_BYTES: + normalized = json.loads(rendered) + else: + notes.append(f"{label} contract was too large to carry across processes; consumer mapping may be less precise") + return { + "version": revision.version, "fingerprint": revision.fingerprint, "source_ref": revision.source_ref, + "normalized": normalized, "contract_format": revision.contract_format, + } + + +def event_to_json(event: ExternalChangeEvent) -> str: + notes = list(event.notes) + old = _revision(event.old, "old", notes) + new = _revision(event.new, "new", notes) + target = event.target + payload = { + "v": SERIALIZATION_VERSION, + "target": { + "provider_key": target.provider_key, "ecosystem": target.ecosystem.value if target.ecosystem else None, + "package_name": target.package_name, "dependency_key": target.dependency_key, + "api_hosts": list(target.api_hosts), "modules": list(target.modules), "display_name": target.display_name, + }, + "source": event.source.value, "kind": event.kind.value, "source_ref": event.source_ref, + "old": old, "new": new, + "diff": [ + { + "kind": i.kind.value, "location": i.location, + "subject": {"kind": i.subject.kind.value, "method": i.subject.method, "path": i.subject.path, + "name": i.subject.name, "member": i.subject.member}, + "compatibility": i.compatibility.value, "old": i.old, "new": i.new, "explanation": i.explanation, + "evidence": list(i.evidence), "replacement": i.replacement, + } + for i in event.diff + ], + "classification": { + "risk": event.classification.risk.value, "compatibility": event.classification.compatibility.value, + "reasons": list(event.classification.reasons), "counts": dict(event.classification.counts), + "has_structural_evidence": event.classification.has_structural_evidence, + }, + "fingerprint": event.fingerprint, "observed_at": event.observed_at.isoformat(), + "release": ( + {"version": event.release.version, "tag": event.release.tag, "published_at": event.release.published_at, + "url": event.release.url, "notes_sha256": event.release.notes_sha256, "prerelease": event.release.prerelease} + if event.release else None + ), + "hints_fingerprint": event.hints_fingerprint, "hints_provenance": event.hints_provenance, + "truncated": event.truncated, "notes": notes, "engine_version": event.engine_version, + } + return json.dumps(payload, sort_keys=True, separators=(",", ":")) + + +def _revision_from(data: dict[str, Any]) -> ExternalContractRevision: + return ExternalContractRevision( + version=data["version"], fingerprint=data["fingerprint"], source_ref=data["source_ref"], + normalized=data["normalized"], contract_format=data["contract_format"], + ) + + +def event_from_json(text: str) -> ExternalChangeEvent: + data: dict[str, Any] = json.loads(text) + if data.get("v") != SERIALIZATION_VERSION: + raise ValueError(f"unsupported event serialization version {data.get('v')!r}") + t = data["target"] + target = DependencyTarget( + provider_key=t["provider_key"], ecosystem=Ecosystem(t["ecosystem"]) if t["ecosystem"] else None, + package_name=t["package_name"], dependency_key=t["dependency_key"], api_hosts=tuple(t["api_hosts"]), + modules=tuple(t["modules"]), display_name=t["display_name"], + ) + diff = tuple( + ContractDiffItem( + kind=DiffItemKind(i["kind"]), location=i["location"], + subject=DiffSubject( + SubjectKind(i["subject"]["kind"]), method=i["subject"]["method"], path=i["subject"]["path"], + name=i["subject"]["name"], member=i["subject"]["member"], + ), + compatibility=CompatibilityClass(i["compatibility"]), old=i["old"], new=i["new"], + explanation=i["explanation"], evidence=tuple(i["evidence"]), replacement=i["replacement"], + ) + for i in data["diff"] + ) + c = data["classification"] + release = data["release"] + return ExternalChangeEvent( + target=target, source=ExternalChangeSource(data["source"]), kind=ExternalChangeKind(data["kind"]), + source_ref=data["source_ref"], old=_revision_from(data["old"]), new=_revision_from(data["new"]), diff=diff, + classification=ChangeClassification( + risk=ChangeRisk(c["risk"]), compatibility=CompatibilityClass(c["compatibility"]), + reasons=tuple(c["reasons"]), counts=dict(c["counts"]), has_structural_evidence=c["has_structural_evidence"], + ), + fingerprint=data["fingerprint"], observed_at=datetime.fromisoformat(data["observed_at"]), + release=DependencyRelease(**release) if release else None, hints_fingerprint=data["hints_fingerprint"], + hints_provenance=data["hints_provenance"], truncated=data["truncated"], notes=tuple(data["notes"]), + engine_version=data["engine_version"], + ) + + +__all__ = ["MAX_SERIALIZED_CONTRACT_BYTES", "SERIALIZATION_VERSION", "event_from_json", "event_to_json"] diff --git a/patchfrog/upstream/store.py b/patchfrog/upstream/store.py index 9c4cfc0..6ecec78 100644 --- a/patchfrog/upstream/store.py +++ b/patchfrog/upstream/store.py @@ -21,6 +21,7 @@ import hashlib import json import uuid +from collections.abc import Sequence from dataclasses import asdict from datetime import UTC, datetime @@ -262,17 +263,39 @@ async def registry_dependencies( ) -> tuple[list[RegistryDependency], list[str], dict[tuple[str, str], uuid.UUID], dict[str, uuid.UUID]]: """Every active registry dependency (with usage sites) across all repositories, every repository name, the dependency row ids and - the repository ids -- the cross-repository view of M6.7.""" + the repository ids -- the cross-repository view of M6.7. **Not + tenant-scoped**: hosted callers must use + :meth:`registry_dependencies_for_repositories`.""" repositories = list((await session.execute(select(RepositoryModel))).scalars().all()) repo_names = {r.id: r.full_name for r in repositories} - rows = list( - ( - await session.execute( - select(ExternalDependencyModel).where(ExternalDependencyModel.status == STATUS_ACTIVE) - ) - ).scalars().all() + rows, dependency_ids = await self._registry_rows(session, repo_names, repository_ids=None) + return rows, sorted(repo_names.values()), dependency_ids, {v: k for k, v in repo_names.items()} + + async def registry_dependencies_for_repositories( + self, session: AsyncSession, repository_ids: Sequence[uuid.UUID] + ) -> list[RegistryDependency]: + """Active registry dependencies of exactly the given engine + repositories -- the only registry read a multi-tenant caller may + use. An empty ``repository_ids`` yields an empty list (never "all").""" + + if not repository_ids: + return [] + repositories = list( + (await session.execute(select(RepositoryModel).where(RepositoryModel.id.in_(list(repository_ids))))) + .scalars().all() ) + repo_names = {r.id: r.full_name for r in repositories} + rows, _ = await self._registry_rows(session, repo_names, repository_ids=list(repo_names)) + return rows + + async def _registry_rows( + self, session: AsyncSession, repo_names: dict[uuid.UUID, str], *, repository_ids: list[uuid.UUID] | None + ) -> tuple[list[RegistryDependency], dict[tuple[str, str], uuid.UUID]]: + query = select(ExternalDependencyModel).where(ExternalDependencyModel.status == STATUS_ACTIVE) + if repository_ids is not None: + query = query.where(ExternalDependencyModel.repository_id.in_(repository_ids)) + rows = list((await session.execute(query)).scalars().all()) site_rows = list( ( await session.execute( @@ -310,7 +333,7 @@ async def registry_dependencies( key=lambda s: (s.file_path, s.line or 0, s.evidence_type.value, s.token))) result.append(RegistryDependency(name, row.last_observed_commit_sha, identity, sites)) dependency_ids[(name, row.dependency_key)] = row.id - return result, sorted(repo_names.values()), dependency_ids, {v: k for k, v in repo_names.items()} + return result, dependency_ids __all__ = ["MAX_JSON_COLUMN_BYTES", "UpstreamChangeStore"] diff --git a/tests/integration/test_campaign_orchestration.py b/tests/integration/test_campaign_orchestration.py index 78a42b5..6794a29 100644 --- a/tests/integration/test_campaign_orchestration.py +++ b/tests/integration/test_campaign_orchestration.py @@ -6,6 +6,7 @@ import ast import shutil +import uuid from pathlib import Path import pytest @@ -514,3 +515,89 @@ def test_campaigns_package_has_no_fixture_specific_strings() -> None: text = path.read_text().lower() for word in ("acme", "chat.create", "responses.create", "stripe", "openai"): assert word not in text.replace("an openai", ""), f"{path.name} mentions {word!r}" + + +# -- registry pre-screen (cheap path: no checkout) --------------------------------------------------- + + +async def _registry_rows( + session_factory: async_sessionmaker[AsyncSession], names: tuple[str, ...] +) -> dict[str, tuple[uuid.UUID, str]]: + from patchfrog.dependencies.registry import DependencyRegistry + from patchfrog.persistence.models.repository import RepositoryModel + + event, _ = acme_event() + out: dict[str, tuple[uuid.UUID, str]] = {} + async with session_factory() as session: + for index, name in enumerate(names, start=1): + repository = RepositoryModel( + github_repository_id=index, owner="acme", name=name, full_name=f"acme/{name}", installation_id=1 + ) + session.add(repository) + await session.flush() + inventory = discover_dependencies( + ACME / "repos" / name, repository=f"acme/{name}", commit_sha=SHA, + adapters=adapters_for_target(event.target), + ) + await DependencyRegistry().record_inventory(session, repository_id=repository.id, inventory=inventory) + out[name] = (repository.id, f"acme/{name}") + await session.commit() + return out + + +async def test_registry_read_is_scoped_to_the_given_repositories( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + from patchfrog.upstream.store import UpstreamChangeStore + + ids = await _registry_rows(session_factory, ("acme-web", "acme-search")) + store = UpstreamChangeStore() + async with session_factory() as session: + only_web = await store.registry_dependencies_for_repositories(session, [ids["acme-web"][0]]) + nothing = await store.registry_dependencies_for_repositories(session, []) + both = await store.registry_dependencies_for_repositories(session, [i for i, _ in ids.values()]) + assert {r.repository for r in only_web} == {"acme/acme-web"} + assert nothing == [] # an empty scope is never "everything" + assert {r.repository for r in both} == {"acme/acme-web", "acme/acme-search"} + + +async def test_fresh_registry_with_no_matching_dependency_is_not_affected_without_a_checkout( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + entry = RepositoryInput( + repository=EnrolledRepository("acme/other", last_discovery_at=FRESH_AT), root=None, commit_sha=SHA, + use_registry=True, + ) + result = await _run([entry], policy=MIGRATE) + record = result.campaign.records[0] + assert record.state is RepoState.NOT_AFFECTED and record.org_class is OrgClass.NOT_AFFECTED + assert record.impact_status == "unaffected" + # ...whereas "the registry was never consulted" stays unknown + unconsulted = RepositoryInput(repository=EnrolledRepository("acme/other", last_discovery_at=FRESH_AT), commit_sha=SHA) + assert (await _run([unconsulted], policy=MIGRATE)).campaign.records[0].state is RepoState.UNKNOWN + + +async def test_registry_evidence_classifies_affected_but_cannot_generate_a_patch( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + from patchfrog.upstream.store import UpstreamChangeStore + + ids = await _registry_rows(session_factory, ("acme-web", "acme-search")) + async with session_factory() as session: + rows = await UpstreamChangeStore().registry_dependencies_for_repositories(session, [i for i, _ in ids.values()]) + by_repo: dict[str, list[object]] = {} + for row in rows: + by_repo.setdefault(row.repository, []).append(row) + entries = [ + RepositoryInput( + repository=EnrolledRepository(name, last_discovery_at=FRESH_AT), root=None, commit_sha=SHA, + registry_rows=tuple(by_repo.get(name, [])), use_registry=True, # type: ignore[arg-type] + ) + for name in ("acme/acme-web", "acme/acme-search") + ] + result = await _run(entries, policy=MIGRATE) + states = {r.repository: r for r in result.campaign.records} + assert states["acme/acme-search"].state is RepoState.NOT_AFFECTED + web = states["acme/acme-web"] + assert web.state is RepoState.IMPACTED and web.org_class is OrgClass.AFFECTED + assert web.patch_fingerprint is None and any("checkout" in r for r in web.reasons) diff --git a/tests/unit/test_upstream_event_serialization.py b/tests/unit/test_upstream_event_serialization.py new file mode 100644 index 0000000..c7ea0de --- /dev/null +++ b/tests/unit/test_upstream_event_serialization.py @@ -0,0 +1,84 @@ +"""M11: an event survives a process boundary unchanged, and so does everything computed from it.""" + +from __future__ import annotations + +import json + +import pytest + +from patchfrog.upstream.domain import DependencyTarget +from patchfrog.upstream.events import ( + build_contract_change, + build_version_change, + load_contract_file, + parse_contract_document, +) +from patchfrog.upstream.serialize import ( + MAX_SERIALIZED_CONTRACT_BYTES, + event_from_json, + event_to_json, +) +from tests.support.campaigns import ACME, INTERNAL, acme_event + + +def test_contract_event_round_trips_exactly() -> None: + event, _ = acme_event() + restored = event_from_json(event_to_json(event)) + assert restored == event + assert restored.fingerprint == event.fingerprint and restored.old.normalized == event.old.normalized + + +def test_version_event_and_release_round_trip() -> None: + from patchfrog.upstream.domain import DependencyRelease + + release = DependencyRelease(version="2.0.0", tag="v2.0.0", published_at="2026-01-01", url="https://e.com/r", + notes_sha256="ab" * 32, prerelease=False) + event = build_version_change(DependencyTarget(package_name="acme-ai"), "1.4.0", "2.0.0", release=release) + assert event_from_json(event_to_json(event)) == event + + +def test_internal_surface_event_round_trips() -> None: + event = build_contract_change( + load_contract_file(INTERNAL / "contract" / "old.yaml"), load_contract_file(INTERNAL / "contract" / "new.yaml") + ) + assert event_from_json(event_to_json(event)) == event + + +def test_serialization_is_deterministic() -> None: + event, _ = acme_event() + assert event_to_json(event) == event_to_json(event_from_json(event_to_json(event))) + + +def test_unknown_serialization_version_is_rejected() -> None: + event, _ = acme_event() + data = json.loads(event_to_json(event)) + data["v"] = 99 + with pytest.raises(ValueError, match="unsupported"): + event_from_json(json.dumps(data)) + + +def test_oversized_contract_is_dropped_loudly_not_silently() -> None: + doc = {"openapi": "3.0.0", "info": {"title": "T", "version": "1"}, + "paths": {f"/v1/resource-{i}/items": {"get": {"parameters": [{"name": f"q{i}", "in": "query"}], + "responses": {"200": {"description": "ok"}}}} + for i in range(5000)}} + old = parse_contract_document(doc, ref="o") + new = parse_contract_document({**doc, "info": {"title": "T", "version": "2"}, "paths": {}}, ref="n") + event = build_contract_change(old, new) + text = event_to_json(event) + assert len(json.dumps(dict(event.old.normalized or {}), sort_keys=True, separators=(",", ":"))) \ + > MAX_SERIALIZED_CONTRACT_BYTES + restored = event_from_json(text) + assert restored.old.normalized is None + assert any("too large to carry" in n for n in restored.notes) + + +def test_a_restored_event_yields_the_same_campaign_inputs() -> None: + from patchfrog.upstream.workspace import analyze_repository + + event, hints = acme_event() + restored = event_from_json(event_to_json(event)) + for name in ("acme-web", "acme-worker", "acme-search"): + a, _ = analyze_repository(ACME / "repos" / name, event, hints=hints, repository=name) + b, _ = analyze_repository(ACME / "repos" / name, restored, hints=hints, repository=name) + assert (a.status, a.direct_count, a.potential_count) == (b.status, b.direct_count, b.potential_count) From 8a9408e9347fe3dfcdd00900132824e0bfbb6131 Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 05:45:35 +0300 Subject: [PATCH 09/17] M11: human approval turns AWAIT_APPROVAL into a publication after a fresh evaluation approved_repositories is threaded through run_campaign/process_change_for_workspace. Approval never lowers the bar: the evaluation is recomputed from scratch, a partially verified migration is still a draft, and an approval for a repository whose migration is not eligible changes nothing. --- patchfrog/campaigns/ingest.py | 3 ++- patchfrog/campaigns/orchestrate.py | 26 ++++++++++++++++-- .../test_campaign_orchestration.py | 27 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/patchfrog/campaigns/ingest.py b/patchfrog/campaigns/ingest.py index 7b1f2dc..ab328b5 100644 --- a/patchfrog/campaigns/ingest.py +++ b/patchfrog/campaigns/ingest.py @@ -119,6 +119,7 @@ async def process_change_for_workspace( verify: VerifyFn = run_migration_verification, publish: PullRequestPublisher | None = None, internal_contract: InternalContract | None = None, + approved_repositories: frozenset[str] = frozenset(), ) -> WorkspaceChangeResult: if not policy.analyzes: record_campaign_event("not_launched") @@ -136,7 +137,7 @@ async def process_change_for_workspace( run = await run_campaign( workspace_key=workspace_key, event=event, entries=entries, now=now, policy=policy, freshness_policy=freshness_policy, hints=hints, verify=verify, publish=publish, previous=previous, - internal_contract=internal_contract, + internal_contract=internal_contract, approved_repositories=approved_repositories, ) async with session_factory() as session: await _persist_audit(session, event, run) diff --git a/patchfrog/campaigns/orchestrate.py b/patchfrog/campaigns/orchestrate.py index 31c3e97..c5ce2f5 100644 --- a/patchfrog/campaigns/orchestrate.py +++ b/patchfrog/campaigns/orchestrate.py @@ -49,7 +49,11 @@ ) from patchfrog.campaigns.state import RepoFacts, derive_campaign_state, derive_repo_state from patchfrog.migration.domain import GeneratedPatch -from patchfrog.migration_pr.domain import MigrationPRPlan, MigrationPullRequest +from patchfrog.migration_pr.domain import ( + MigrationPREligibility, + MigrationPRPlan, + MigrationPullRequest, +) from patchfrog.migration_pr.publisher import MigrationPRPublicationMode, MigrationPRPublisher from patchfrog.migration_verification.service import run_migration_verification from patchfrog.upstream.domain import ExternalChangeEvent @@ -115,11 +119,24 @@ def _apply_publication(evaluation: RepositoryEvaluation, pr: MigrationPullReques ) +def _approved(decision: PublicationDecision, eligibility: MigrationPREligibility) -> PublicationDecision: + """A human approved this repository's pending PR: ``AWAIT_APPROVAL`` becomes a real publication. + Approval never lowers the bar -- the evaluation it applies to was just recomputed from scratch, and a + partially verified migration is still opened as a draft.""" + + if decision.action is not PublicationAction.AWAIT_APPROVAL: + return decision + if eligibility is MigrationPREligibility.OPEN_WITH_OPERATOR_APPROVAL: + return PublicationDecision(PublicationAction.PUBLISH_DRAFT, "approved by a human; partially verified, so a draft") + return PublicationDecision(PublicationAction.PUBLISH, "approved by a human") + + async def _publish_phase( evaluations: Sequence[RepositoryEvaluation], *, policy: WorkspacePolicy, publish: PullRequestPublisher | None, + approved: frozenset[str] = frozenset(), ) -> tuple[list[RepositoryEvaluation], dict[str, PublicationDecision], dict[str, MigrationPullRequest]]: decisions: dict[str, PublicationDecision] = {} publications: dict[str, MigrationPullRequest] = {} @@ -131,6 +148,8 @@ async def _publish_phase( continue name = evaluation.record.repository decision = decide_publication(policy, plan.eligibility) + if name in approved: + decision = _approved(decision, plan.eligibility) decisions[name] = decision record = replace( evaluation.record, reasons=bound_reasons([*evaluation.record.reasons, f"publication policy: {decision.reason}"]) @@ -181,6 +200,7 @@ async def run_campaign( publish: PullRequestPublisher | None = None, previous: CompatibilityCampaign | None = None, internal_contract: InternalContract | None = None, + approved_repositories: frozenset[str] = frozenset(), ) -> CampaignRunResult: policy = policy or WorkspacePolicy() identity = campaign_identity_key(workspace_key=workspace_key, change_fingerprint=event.fingerprint) @@ -212,7 +232,9 @@ async def run_campaign( evaluations = await evaluate_all( event, scoped, now=now, policy=policy, freshness_policy=freshness_policy, hints=hints, verify=verify ) - published, decisions, publications = await _publish_phase(evaluations, policy=policy, publish=publish) + published, decisions, publications = await _publish_phase( + evaluations, policy=policy, publish=publish, approved=approved_repositories + ) records = tuple(e.record for e in published) blast = build_org_blast_radius([e.classification for e in published]) diff --git a/tests/integration/test_campaign_orchestration.py b/tests/integration/test_campaign_orchestration.py index 6794a29..c7e8e64 100644 --- a/tests/integration/test_campaign_orchestration.py +++ b/tests/integration/test_campaign_orchestration.py @@ -601,3 +601,30 @@ async def test_registry_evidence_classifies_affected_but_cannot_generate_a_patch web = states["acme/acme-web"] assert web.state is RepoState.IMPACTED and web.org_class is OrgClass.AFFECTED assert web.patch_fingerprint is None and any("checkout" in r for r in web.reasons) + + +@needs_sandbox +async def test_a_human_approval_turns_await_into_a_real_publication_after_a_fresh_run( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + fake = _fake_github() + policy = WorkspacePolicy(watch_mode=WatchMode.MIGRATE_AND_OPEN_PR, publication=PublicationMode.MANUAL_APPROVAL) + waiting = await _run(policy=policy, publish=_publisher(session_factory, fake)) + assert fake.create_pull_request_calls == [] and waiting.decisions["acme/acme-web"].action.value == "await_approval" + + approved = await _run( + policy=policy, publish=_publisher(session_factory, fake), previous=waiting.campaign, + approved_repositories=frozenset({"acme/acme-web"}), + ) + assert len(fake.create_pull_request_calls) == 1 and fake.create_pull_request_drafts == [False] + web = approved.campaign.record_for("acme/acme-web") + assert web is not None and web.state is RepoState.PR_OPENED + # approval is per repository: the human-required repo still has no PR + assert approved.campaign.record_for("acme/acme-worker").pr_number is None # type: ignore[union-attr] + # approving a repository whose migration is not eligible changes nothing + again = await _run( + policy=policy, publish=_publisher(session_factory, fake), previous=approved.campaign, + approved_repositories=frozenset({"acme/acme-web", "acme/acme-worker"}), + ) + assert len(fake.create_pull_request_calls) == 1 + assert again.campaign.record_for("acme/acme-worker").pr_number is None # type: ignore[union-attr] From cd7c12f8c311cadaf9771408d26c571101028644 Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 05:54:50 +0300 Subject: [PATCH 10/17] GitHubClient: get_default_branch (name + head together); tests for draft PR creation --- patchfrog/github/client.py | 28 ++++++++++++++++-------- tests/unit/test_github_client_git.py | 32 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/patchfrog/github/client.py b/patchfrog/github/client.py index b5692ea..9de258e 100644 --- a/patchfrog/github/client.py +++ b/patchfrog/github/client.py @@ -90,16 +90,12 @@ async def get_pull_request( data = await self._get_json(installation_id=installation_id, path=path) return _parse_pull_request(data) - async def get_default_branch_head_sha( + async def get_default_branch( self, *, installation_id: int, owner: str, repository: str - ) -> str: - """The exact commit SHA at the tip of the repository's default - branch, right now -- used by ``patchfrog ops preflight`` (external - beta readiness) to resolve ``.patchfrog.yml`` for a repository - that has no open PR yet. Never used by the real review/publish - pipeline itself (that always works from a webhook-supplied - ``head_sha``, never a freshly-queried default branch -- see the - module docstring of :mod:`patchfrog.publishing.config_resolution`).""" + ) -> tuple[str, str]: + """``(default branch name, its head commit SHA)`` right now. The pair is read together so a + caller that publishes against the branch (the migration PR publisher) and a caller that + checks it out agree on both.""" repo_data = await self._get_json(installation_id=installation_id, path=f"/repos/{owner}/{repository}") default_branch = repo_data.get("default_branch") @@ -112,6 +108,20 @@ async def get_default_branch_head_sha( sha = branch_data.get("commit", {}).get("sha") if not isinstance(sha, str) or not sha: raise GitHubResponseError(f"GitHub did not report a head commit for {owner}/{repository}@{default_branch}") + return default_branch, sha + + async def get_default_branch_head_sha( + self, *, installation_id: int, owner: str, repository: str + ) -> str: + """The exact commit SHA at the tip of the repository's default + branch, right now -- used by ``patchfrog ops preflight`` (external + beta readiness) to resolve ``.patchfrog.yml`` for a repository + that has no open PR yet. Never used by the real review/publish + pipeline itself (that always works from a webhook-supplied + ``head_sha``, never a freshly-queried default branch -- see the + module docstring of :mod:`patchfrog.publishing.config_resolution`).""" + + _, sha = await self.get_default_branch(installation_id=installation_id, owner=owner, repository=repository) return sha async def get_ref(self, *, installation_id: int, owner: str, repository: str, ref: str) -> str | None: diff --git a/tests/unit/test_github_client_git.py b/tests/unit/test_github_client_git.py index 0a50a7b..efa3fc6 100644 --- a/tests/unit/test_github_client_git.py +++ b/tests/unit/test_github_client_git.py @@ -180,3 +180,35 @@ async def test_malformed_ref_response_raises(http_client: httpx.AsyncClient) -> respx.get(f"{API_BASE}/repos/octo/repo/git/ref/heads/main").mock(return_value=httpx.Response(200, json={})) with pytest.raises(GitHubResponseError): await _client(http_client).get_ref(installation_id=1, owner="octo", repository="repo", ref="heads/main") + + +@respx.mock +async def test_create_pull_request_can_open_a_draft(http_client: httpx.AsyncClient) -> None: + response = { + "number": 43, "title": "t", "body": "b", "user": {"login": "patchfrog[bot]"}, + "base": {"ref": "main", "sha": "l" * 40}, "head": {"ref": "x", "sha": "m" * 40}, + "html_url": "https://github.com/octo/repo/pull/43", "state": "open", "merged": False, + } + route = respx.post(f"{API_BASE}/repos/octo/repo/pulls").mock(return_value=httpx.Response(201, json=response)) + await _client(http_client).create_pull_request( + installation_id=1, owner="octo", repository="repo", title="t", body="b", head="x", base="main", draft=True, + ) + assert json.loads(route.calls.last.request.content)["draft"] is True + + +@respx.mock +async def test_get_default_branch_returns_name_and_head_together(http_client: httpx.AsyncClient) -> None: + respx.get(f"{API_BASE}/repos/octo/repo").mock(return_value=httpx.Response(200, json={"default_branch": "trunk"})) + respx.get(f"{API_BASE}/repos/octo/repo/branches/trunk").mock( + return_value=httpx.Response(200, json={"commit": {"sha": "c" * 40}}) + ) + client = _client(http_client) + assert await client.get_default_branch(installation_id=1, owner="octo", repository="repo") == ("trunk", "c" * 40) + assert await client.get_default_branch_head_sha(installation_id=1, owner="octo", repository="repo") == "c" * 40 + + +@respx.mock +async def test_get_default_branch_rejects_a_response_without_a_branch(http_client: httpx.AsyncClient) -> None: + respx.get(f"{API_BASE}/repos/octo/repo").mock(return_value=httpx.Response(200, json={})) + with pytest.raises(GitHubResponseError): + await _client(http_client).get_default_branch(installation_id=1, owner="octo", repository="repo") From fab979e81080b6e7d629954caec6bd251bc36d5b Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 06:04:31 +0300 Subject: [PATCH 11/17] M10: a repository whose evidence could not be obtained is FAILED, never classified from weaker evidence Found while building the hosted runner: a failed checkout fell back to registry evidence, which for an SDK without a built-in discovery adapter (package declaration only, no call sites) reads as 'not affected'. RepositoryInput now carries evidence_error; the evaluation marks such a repository FAILED, with credentials scrubbed by the engine itself. --- patchfrog/campaigns/evaluate.py | 27 ++++++++++++++++--- .../test_campaign_orchestration.py | 25 +++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/patchfrog/campaigns/evaluate.py b/patchfrog/campaigns/evaluate.py index 920a00d..26ab7ad 100644 --- a/patchfrog/campaigns/evaluate.py +++ b/patchfrog/campaigns/evaluate.py @@ -57,13 +57,17 @@ _TOKEN_SHAPES = re.compile(r"\b(gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_\-]{16,}|AIza[0-9A-Za-z_\-]{20,})\b") +def scrub(text: str) -> str: + """One bounded line with URL credentials and well-known token shapes removed.""" + + text = _CREDENTIAL_URL.sub(r"\1***@", text.replace("\n", " ")) + return _TOKEN_SHAPES.sub("***", text)[:MAX_ERROR_CHARS] + + def safe_error(exc: BaseException) -> str: """A bounded, credential-scrubbed one-line description of an exception.""" - text = f"{type(exc).__name__}: {exc}".replace("\n", " ") - text = _CREDENTIAL_URL.sub(r"\1***@", text) - text = _TOKEN_SHAPES.sub("***", text) - return text[:MAX_ERROR_CHARS] + return scrub(f"{type(exc).__name__}: {exc}") @dataclass(frozen=True, slots=True) @@ -89,6 +93,10 @@ class RepositoryInput: #: The engine ``repositories`` row id, when the caller has one. Only used to #: persist the M7/M8 audit trail (:mod:`patchfrog.campaigns.ingest`). engine_repository_id: uuid.UUID | None = None + #: The caller *tried* to obtain evidence (e.g. check the repository out) and could not. The repository is + #: then ``FAILED`` -- never classified from whatever weaker evidence happens to be at hand, which could + #: read as a false "not affected". + evidence_error: str | None = None @dataclass(frozen=True, slots=True) @@ -166,6 +174,16 @@ async def evaluate_repository( ) -> RepositoryEvaluation: policy = policy or WorkspacePolicy() freshness = assess_freshness(entry.repository, now=now, policy=freshness_policy) + if entry.evidence_error is not None and freshness is Freshness.FRESH: + classification = RepositoryClassification( + entry.repository.full_name, OrgClass.UNKNOWN, None, "evidence could not be obtained", False + ) + record = _replace( + _base_record(entry, freshness=freshness, classification=classification, now=now), + state=RepoState.FAILED, error=scrub(entry.evidence_error), + reasons=bound_reasons(["evidence could not be obtained; this repository is unresolved, not unaffected"]), + ) + return RepositoryEvaluation(record=record, classification=classification) try: return await _evaluate(event, entry, freshness=freshness, now=now, policy=policy, hints=hints, verify=verify) except Exception as exc: @@ -293,4 +311,5 @@ async def evaluate_all( "evaluate_all", "evaluate_repository", "safe_error", + "scrub", ] diff --git a/tests/integration/test_campaign_orchestration.py b/tests/integration/test_campaign_orchestration.py index c7e8e64..84e4eea 100644 --- a/tests/integration/test_campaign_orchestration.py +++ b/tests/integration/test_campaign_orchestration.py @@ -511,7 +511,10 @@ def test_campaigns_package_never_imports_a_provider() -> None: def test_campaigns_package_has_no_fixture_specific_strings() -> None: + # demo.py is the one deliberate exception: it *is* the bundled fixture scenario. for path in _CAMPAIGNS.glob("*.py"): + if path.name == "demo.py": + continue text = path.read_text().lower() for word in ("acme", "chat.create", "responses.create", "stripe", "openai"): assert word not in text.replace("an openai", ""), f"{path.name} mentions {word!r}" @@ -628,3 +631,25 @@ async def test_a_human_approval_turns_await_into_a_real_publication_after_a_fres ) assert len(fake.create_pull_request_calls) == 1 assert again.campaign.record_for("acme/acme-worker").pr_number is None # type: ignore[union-attr] + + +async def test_a_repository_whose_evidence_could_not_be_obtained_is_failed_never_unaffected() -> None: + # acme-web would classify as affected; acme-search as unaffected. A failed checkout must not let either + # fall back to weaker evidence (which for a repository using an SDK without a discovery adapter could + # read as "not affected"). + broken = [ + RepositoryInput( + repository=EnrolledRepository(f"acme/{name}", last_discovery_at=FRESH_AT), commit_sha=SHA, + evidence_error="checkout failed: boom https://user:hunter2@example.com/x", + ) + for name in ("acme-web", "acme-search") + ] + result = await _run([*broken, acme_input("acme-legacy", discovered_at=STALE_AT, root=None)], policy=MIGRATE) + states = _states(result) + assert states["acme-web"] is RepoState.FAILED and states["acme-search"] is RepoState.FAILED + for name in ("acme/acme-web", "acme/acme-search"): + record = result.campaign.record_for(name) + assert record is not None and record.error is not None and "boom" in record.error + assert "hunter2" not in record.error # credentials are scrubbed by the engine, not trusted to the caller + assert record.org_class is OrgClass.UNKNOWN and record.direct_consumers == 0 + assert result.campaign.state is not CampaignState.RESOLVED and not result.campaign.org_blast_radius.safe From 9de40841e21d6508e1a24a37620f1f64ce5e0ede Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 06:12:57 +0300 Subject: [PATCH 12/17] upstream.events: parse_contract_text (the loader for documents that arrive as text) --- patchfrog/upstream/events.py | 17 +++++++++++++++++ tests/unit/test_upstream_event_serialization.py | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/patchfrog/upstream/events.py b/patchfrog/upstream/events.py index ae05328..5d7d6ad 100644 --- a/patchfrog/upstream/events.py +++ b/patchfrog/upstream/events.py @@ -119,6 +119,22 @@ def load_contract_file(path: Path) -> LoadedContract: return parse_contract_document(data, ref=path.name) +def parse_contract_text(text: str, *, ref: str) -> LoadedContract: + """The same loader as :func:`load_contract_file`, for a document that arrives as text (e.g. a contract + snapshot submitted through a hosted form). Bounded and ``safe_load`` only.""" + + if len(text.encode("utf-8", errors="replace")) > MAX_OPENAPI_FILE_BYTES: + raise ContractLoadError(f"{ref}: larger than {MAX_OPENAPI_FILE_BYTES} bytes") + spec = load_spec(text) + if spec is not None: + return parse_contract_document(spec, ref=ref) + try: + data = json.loads(text) if text.lstrip().startswith("{") else yaml.safe_load(text) + except (json.JSONDecodeError, yaml.YAMLError) as exc: + raise ContractLoadError(f"{ref}: not valid YAML/JSON") from exc + return parse_contract_document(data, ref=ref) + + def contract_from_registry_snapshot(normalized_json: str, *, fingerprint: str, ref: str, version: str | None) -> LoadedContract: """The old side of a registry-backed diff: an M5 contract snapshot's @@ -362,5 +378,6 @@ def release_from_metadata(data: Mapping[str, Any]) -> DependencyRelease: "event_fingerprint", "load_contract_file", "parse_contract_document", + "parse_contract_text", "release_from_metadata", ] diff --git a/tests/unit/test_upstream_event_serialization.py b/tests/unit/test_upstream_event_serialization.py index c7ea0de..634b8e9 100644 --- a/tests/unit/test_upstream_event_serialization.py +++ b/tests/unit/test_upstream_event_serialization.py @@ -82,3 +82,17 @@ def test_a_restored_event_yields_the_same_campaign_inputs() -> None: a, _ = analyze_repository(ACME / "repos" / name, event, hints=hints, repository=name) b, _ = analyze_repository(ACME / "repos" / name, restored, hints=hints, repository=name) assert (a.status, a.direct_count, a.potential_count) == (b.status, b.direct_count, b.potential_count) + + +def test_parse_contract_text_matches_loading_the_same_file() -> None: + from patchfrog.upstream.events import ContractLoadError, parse_contract_text + + path = ACME / "contract" / "new.yaml" + from_file = load_contract_file(path) + from_text = parse_contract_text(path.read_text(), ref="new") + assert from_text.fingerprint == from_file.fingerprint and from_text.format == "sdk_surface" + for bad in ("not: [a contract", "just text", "{}"): + with pytest.raises(ContractLoadError): + parse_contract_text(bad, ref="x") + with pytest.raises(ContractLoadError, match="larger"): + parse_contract_text("a" * 6_000_000, ref="x") From 5d30ed852329ada75a1a09dd74a26f283cfae4b6 Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 06:22:28 +0300 Subject: [PATCH 13/17] docs: compatibility campaigns, upstream watchers, roadmap (v1 complete for beta), boundary, CI hygiene --- docs/campaigns.md | 117 +++++++++++++++++++++++++++++++++++++++ docs/ci-health.md | 10 ++++ docs/product-boundary.md | 14 +++++ docs/roadmap.md | 26 +++++++++ docs/watchers.md | 72 ++++++++++++++++++++++++ 5 files changed, 239 insertions(+) create mode 100644 docs/campaigns.md create mode 100644 docs/watchers.md diff --git a/docs/campaigns.md b/docs/campaigns.md new file mode 100644 index 0000000..32c4ae7 --- /dev/null +++ b/docs/campaigns.md @@ -0,0 +1,117 @@ +# Compatibility campaigns (M10) + +One upstream API/SDK change usually reaches several repositories of one organization. A +**compatibility campaign** is the single, durable, honest record of that fact: which repositories were +in scope, how each was classified, how far each migration got, and what is still unresolved. It is +built entirely from the M5 → M9 machinery (dependency registry, upstream change, blast radius, +migration plan/patch, verification, PR publication); it adds no new analysis and no model call. + +``` +upstream change ──► every enrolled repository evaluated independently + (fresh? readable? affected? migration? verified? PR?) + ──► one CompatibilityCampaign (idempotent per workspace + change + engine version) +``` + +Package: `patchfrog/campaigns/`. Version constant: `CAMPAIGN_ENGINE_VERSION = 1`. + +## The organization graph (M10.1) + +`campaigns.graph.build_org_graph` derives, on demand, `workspace → repositories → dependency identities → +per-repository instances (version, usage sites, contract)`. It is a **view**, never stored: a dependency +identity is shared logically across repositories (an SDK consumer, a direct-REST consumer and a +generated-client consumer all land under one provider identity) while every per-repository fact stays owned +by that repository's own M5 rows. + +## Repository and campaign states (M10.3) + +Repository states: `NOT_AFFECTED`, `IMPACTED`, `MIGRATION_PLANNED`, `PATCH_GENERATED`, `VERIFYING`, +`VERIFIED`, `PARTIALLY_VERIFIED`, `HUMAN_REQUIRED`, `FAILED`, `PR_OPENED`, `RESOLVED`, plus the +uncertainty states that exist so doubt is representable instead of rounded away: `STALE`, `UNKNOWN`, +`ACCESS_LOST`, `UNVERIFIED`. + +Campaign states: `DETECTED`, `ANALYZING`, `ACTION_REQUIRED`, `MIGRATING`, `PARTIALLY_RESOLVED`, +`RESOLVED`, `BLOCKED`. Precedence (`campaigns/state.py`, first match wins): + +1. nothing evaluated → `DETECTED`; some unevaluated → `ANALYZING` +2. every repository `NOT_AFFECTED`/`RESOLVED` → `RESOLVED` (the **only** road to it) +3. at least one `RESOLVED` and anything unfinished → `PARTIALLY_RESOLVED` +4. blocking repositories, all system-blocked (`FAILED`/`ACCESS_LOST`), nothing in flight → `BLOCKED` +5. blocking repositories (`IMPACTED`, `HUMAN_REQUIRED`, `FAILED`, `PARTIALLY_VERIFIED`, `UNVERIFIED`, + `STALE`, `UNKNOWN`, `ACCESS_LOST`) → `ACTION_REQUIRED` +6. otherwise → `MIGRATING` + +`RESOLVED` is reached only by **fresh re-evaluation** showing a previously affected repository is no +longer affected — never by "a PR was opened" or "a patch exists". + +## Honesty rules + +* **Freshness (M10.7).** Each repository's evidence is `FRESH`, `STALE` (older than the window, + default 7 days, configurable), `UNKNOWN` (never discovered, or a clock/record fault) or `ACCESS_LOST`. + A repository whose evidence is not fresh is **not analyzed at all** and is never reported unaffected. +* **Uncertainty is never "safe".** `OrgBlastRadius.safe` is true only if every enrolled repository is + positively known unaffected. Version-level-only evidence is `UNKNOWN`, not `AFFECTED`. +* **Failure is isolated.** Any exception evaluating or publishing one repository becomes that + repository's `FAILED` record (credentials scrubbed) and cannot touch another. +* **Evidence that could not be obtained is not weaker evidence.** A caller that tried and failed to + check a repository out passes `evidence_error`; the repository is `FAILED`, never classified from + registry data that, for an SDK without a built-in discovery adapter, would read as "not affected". +* **Scope is explicit (M10.6).** Only repositories passed in are evaluated — there is no implicit + access. A repository no longer enrolled leaves the campaign; one enrolled but unreadable stays as + `ACCESS_LOST`. + +## Orchestration (M10.4) and publication + +`campaigns.orchestrate.run_campaign` evaluates every repository (M6 impact → M7 plan/patch → M8 +verification → M9 PR plan), then publishes **per repository** under the workspace policy +(`campaigns/policy.py`: watch mode `off | detect_only | migrate | migrate_and_open_pr`, verification +requirement `require_verified | allow_partially_verified`, publication `automatic | draft_only | +manual_approval`). Safe defaults: detect and report, require verified, manual approval. A partially +verified PR is always a draft. A human approval (`approved_repositories`) turns `AWAIT_APPROVAL` into a +publication only after a **fresh** evaluation, and never lowers the bar. Each repository keeps its own base +SHA, patch fingerprint, evidence bundle, PR and publication state. Nothing ever merges. + +## Internal contracts (M10.5) + +An organization's own contract (a published package, an OpenAPI spec, a generated client, an internal SDK) +is an `InternalContract` with an **explicit** identity: package (ecosystem + name), declared API hosts, or an +M5 dependency key. No relationship is inferred from repository names, organization, layout or similarity. +Consumers are matched by their *own* M5 evidence through the same `match_dependency` external changes use; +the producer repository is the origin, not a consumer. There is no second graph and no new relation table. + +## Idempotency (M10.9) and persistence + +`campaign_identity_key(workspace_key, change_fingerprint, engine_version)` is the campaign's identity; +re-running with unchanged inputs yields an equal campaign (same `version`). Repository-level operations +stay separately idempotent (M9's PR identity is repository + change + engine version). Tables +`compatibility_campaigns` and `campaign_repository_records` (Alembic `0038`); `workspace_key` is an opaque +string the caller chooses (a hosted workspace id, an installation id, an org login). + +## Dossier (M10.8) + +`campaigns.dossier.campaign_to_dict` / `render_campaign_markdown`: upstream change, blast-radius counts, +per-repository state/freshness/impact/verification/PR, residual risk, human-required actions, a single +next action per repository, and per-repository data freshness — derivable from persisted state alone. + +## CLI + +```bash +patchfrog campaigns demo # the bundled four-repository scenario, offline, deterministic +patchfrog campaigns analyze --old OLD --new NEW --hints H \ + --repo web=./web --repo legacy=./legacy --stale legacy --policy migrate +``` + +The demo (`patchfrog/campaigns/demo.py`, fixtures in `tests/fixtures/campaigns/acme`): a watcher baselines +then detects `client.chat.create → client.responses.create`; one campaign covers four repositories — one +auto-fixable and verified (one PR), one human-required, one not affected, one with stale discovery — and is +`ACTION_REQUIRED`, not resolved; an identical re-run creates no second campaign or PR; after the PR is +merged, the human fix is made and the stale repository is rediscovered, it reconciles to `RESOLVED`. + +## Known limitations + +* Verification needs a working `bwrap` sandbox; without it nothing is ever `VERIFIED` + (`docs/executable-verification.md`). +* An SDK with no built-in discovery adapter is recorded in the registry by package declaration only + (no call sites). Consumers are therefore found by **live analysis of a checkout** (using the change's own + target adapters), not by the registry alone — see `docs/watchers.md`. +* Transitive blast radius follows the in-repository code graph only; cross-repository transitivity is not + inferred. diff --git a/docs/ci-health.md b/docs/ci-health.md index fc1b18f..d14fc5d 100644 --- a/docs/ci-health.md +++ b/docs/ci-health.md @@ -66,3 +66,13 @@ Pydantic forward-reference warning during one governance-tool test, and a rare Python asyncio subprocess-transport finalizer warning after a deliberately killed analyzer process. Neither changes a test result or represents an ignored assertion; they remain visible so dependency/runtime upgrades can remove them. + +## CI never prints key material + +The workflow generates its disposable GitHub App key and webhook secret at run time (written to a file or +`$GITHUB_ENV`, and masked) instead of writing them in the job-level `env:` block, which GitHub prints -- +resolved -- in every step header. `tests/unit/test_ci_workflow_hygiene.py` fails if a secret-looking literal +reappears in any `env:` block, if key or encoded-blob text is embedded in a workflow, or if a workflow prints +the environment or resolved Docker config. This guards the *configuration going forward*; it does not and +cannot erase anything already present in historical logs. + diff --git a/docs/product-boundary.md b/docs/product-boundary.md index eb40b5e..eb4ac1c 100644 --- a/docs/product-boundary.md +++ b/docs/product-boundary.md @@ -56,6 +56,20 @@ all: None of this is being built as part of this PR, and none of it is being implied to already exist. +## Compatibility campaigns and watchers (M10/M11) + +The same governing rule applies to the compatibility pipeline: + +| Public engine (this repository) | Private PatchFrog Cloud | +|---|---| +| campaign domain, states, freshness, org blast radius, dossier, internal-contract identity | which workspaces opted in, their stored policy values | +| watcher interface, source adapters, snapshot comparison, watch-registry aggregation, launch gate | when to poll: schedules, cadence, claims/leases, retry/backoff, per-host limits | +| the publication *decision* and dossier (`decide_publication`, `build_pr_plan`) and its exact-head / stale-base / integrity / idempotency checks | the official GitHub App credentials and executing the authorized GitHub side effect | +| lossless event serialization, tenant-scoped registry reads | cursor and delivery storage, per-workspace fan-out, usage counters, dashboard, notifications | + +The engine contains no hosted-service code and no credentials; Cloud contains no analysis, diffing, impact, +migration, verification or eligibility logic. + ## GitHub App boundary Two GitHub App identities exist in this model, and they are never shared: diff --git a/docs/roadmap.md b/docs/roadmap.md index d107c6f..e6d7535 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -231,6 +231,32 @@ GitHub publication is wired (`GitHubClientMigrationPublisher`) but not yet called from the production webhook/worker pipeline -- see `docs/migration-pr.md`'s "Known limitations". +**M10 — Cross-repository / organization intelligence.** Implemented: the +`CompatibilityCampaign` (one upstream change across a workspace's repositories, +idempotent per workspace + change + engine version), a derived organization +compatibility graph, an organization blast radius that never rounds uncertainty +to "safe", deterministic repository and campaign states, freshness (`FRESH`, +`STALE`, `UNKNOWN`, `ACCESS_LOST`), per-repository isolation, a bounded +explicit-identity internal-contract model, a machine-readable and renderable +dossier, and `patchfrog campaigns analyze|demo`. See `docs/campaigns.md`. + +**M11 — Continuous upstream watchers and hosted operation.** Implemented: +the engine-side watcher interface and adapters (PyPI, npm, GitHub Releases, +OpenAPI URLs, structured changelog feeds, manual snapshots), snapshot +comparison with cursors and duplicate suppression, the watch registry, the +campaign launch gate, an SSRF-safe fetcher, lossless event serialization, and +the single-workspace ingest function (event → campaign → audit trail → policy- +gated PR). Hosted scheduling, cursor storage, workspace policy, real GitHub +publication with the official App identity, the dashboard and usage counters +live in the private PatchFrog Cloud repository. See `docs/watchers.md` and +`validation/m10_m11/latest-summary.md`. + +**PatchFrog v1 is complete for beta.** No further feature milestone is planned. +What remains is operational: deploy when infrastructure budget allows, onboard +five to ten real users, observe real provider/API changes, and measure detection +precision, migration success rate and verification quality. Build only from +what that feedback shows. + ## Then — Advanced Verification **AA — Multi-Repository System Intelligence** diff --git a/docs/watchers.md b/docs/watchers.md new file mode 100644 index 0000000..3425bb9 --- /dev/null +++ b/docs/watchers.md @@ -0,0 +1,72 @@ +# Upstream watchers (M11, engine side) + +PatchFrog should not need a human to feed it every API change. A **watcher** turns "something upstream +may have changed" into at most one `ExternalChangeEvent` — the same event M6 builds from manual input — +so a detected change flows through exactly the pipeline a hand-fed one does: + +``` +source ─ fetch ─► normalized snapshot ─ compare with cursor ─► candidate change (M6 event, fingerprinted) + ─► launch gate ─► M10 campaign ─► M7 migration ─► M8 verification ─► M9 decision ─► PR / report +``` + +Package `patchfrog/watchers/` (`WATCHER_ENGINE_VERSION = 1`) holds **detection, normalization and diff +intelligence**. *When* to poll, retries across workers, cursor storage and fan-out to workspaces belong to +the hosting service (PatchFrog Cloud, or your own scheduler). See `docs/product-boundary.md`. + +## Sources (M11.2) + +| Kind | Reads | Evidence | +|---|---|---| +| `pypi` | `https://pypi.org/pypi//json` | latest non-yanked, non-prerelease version | +| `npm` | `https://registry.npmjs.org/` | `dist-tags.latest` (not deprecated) | +| `github_releases` | `GET /repos///releases` | latest published, non-draft, non-prerelease tag | +| `openapi_url` | an OpenAPI/Swagger document at an https URL | a normalized, fingerprinted contract | +| `changelog_feed` | Atom, RSS or JSON Feed | an entry whose title carries a version | +| `manual` | a snapshot the caller submits | any contract document | + +Documented APIs only. **No HTML scraping.** A changelog sentence without a version is not a signal. The +XML parser rejects DTDs/entities. Remote `$ref`s in a fetched OpenAPI document are not resolved. + +## Interface (M11.3) + +`WatcherSource` (kind, locator, the `DependencyTarget` a change refers to) → an adapter's `fetch` → +`WatcherSnapshot` → `detect_change(source, snapshot, cursor)` → `WatchOutcome` (`BASELINE`, `UNCHANGED`, +`CHANGED`, `DUPLICATE_SUPPRESSED`, `SKIPPED`) carrying the new `WatcherCursor` and, for `CHANGED`, a +`DetectedUpstreamChange`. The cursor is a small JSON document (`to_json`/`from_json`) a host stores opaquely. + +* **First observation is a baseline** and emits nothing: a watcher never replays history it was not watching. +* A version decrease (yank/rollback) never regresses the cursor. Unparseable versions are skipped, not guessed. +* The emitted event's fingerprint is the M6 fingerprint; the cursor remembers the last 50 it emitted, and a + host should also enforce uniqueness on it — the same change seen twice is one change. +* A stored previous contract larger than 256 KB is dropped from the cursor and the loss is reported, so a + later change cannot silently degrade into a version-only comparison. + +## The launch gate (M11.5/M11.11) + +A change opens a campaign only if it carries evidence of impact: by default `BREAKING`, i.e. **structural +proof** (an OpenAPI or SDK-surface diff). A bare version bump is `REVIEW_REQUIRED` at most and is recorded +without opening a campaign unless a workspace lowers the threshold (and even then a version number alone only +ever yields `UNKNOWN`, never a defect or a patch). Consequence for beta: package-registry and release watchers +produce *events*; campaigns come from sources that carry a contract (OpenAPI URL, manual SDK-surface +submission, or a provider adapter supplying structure). + +## The watch registry (M11.1) + +`required_watchers` derives what to watch from the dependency identities workspaces actually use — a +dependency with a PyPI/npm package gets a registry watcher; GitHub releases, OpenAPI URLs and feeds are +configured explicitly. Sources de-duplicate by `WatcherSource.key`, so fifty workspaces using one package +cause one fetch; the result fans out and each workspace evaluates it against its own evidence. + +## Outbound safety + +`validate_public_url` is the gate every fetch passes: https only, no credentials in the URL, a small port +allow-list, no `localhost`/`.internal`/`.local`, and every resolved address must be public (no loopback, +link-local, private, multicast); the real fetcher revalidates every redirect hop, caps response size, and +sends an auth header only to the exact host it was registered for. **Limitation:** a hostile DNS server could +answer differently between validation and connection; also restrict the watcher process's egress at the network +layer. + +## Cost + +Everything here is deterministic. A watcher never imports a model provider (structurally tested); a detected +change with no consumer produces `NOT_AFFECTED`, not a finding. From cbfe1e5abe36b9fbbd59fa2de52a940ff104b236 Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 06:25:00 +0300 Subject: [PATCH 14/17] tests: nothing in the engine's GitHub surface can merge a pull request --- .../test_campaign_orchestration.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/integration/test_campaign_orchestration.py b/tests/integration/test_campaign_orchestration.py index 84e4eea..7e3e5b6 100644 --- a/tests/integration/test_campaign_orchestration.py +++ b/tests/integration/test_campaign_orchestration.py @@ -653,3 +653,26 @@ async def test_a_repository_whose_evidence_could_not_be_obtained_is_failed_never assert "hunter2" not in record.error # credentials are scrubbed by the engine, not trusted to the caller assert record.org_class is OrgClass.UNKNOWN and record.direct_consumers == 0 assert result.campaign.state is not CampaignState.RESOLVED and not result.campaign.org_blast_radius.safe + + +def test_nothing_in_the_engine_can_merge_a_pull_request() -> None: + """"No auto-merge" as a structural fact, not a promise: the GitHub surface PatchFrog can call has no merge, + auto-merge or approval operation at all, so no policy setting could ever enable one.""" + + from patchfrog.github.client import GitHubClient + from patchfrog.migration_pr.github_publisher import ( + GitHubClientMigrationPublisher, + MigrationGitHubPublisher, + ) + + forbidden = ("merge", "auto_merge", "approve", "enable_auto") + for cls in (GitHubClient, GitHubClientMigrationPublisher, MigrationGitHubPublisher): + for name in dir(cls): + if name.startswith("_"): + continue + assert not any(word in name for word in forbidden) or name in {"create_pull_request_review"}, (cls, name) + package_root = Path(__file__).resolve().parents[2] / "patchfrog" + for directory in ("migration_pr", "campaigns", "watchers"): + for path in (package_root / directory).rglob("*.py"): + text = path.read_text() + assert "/merge" not in text and "auto_merge" not in text, path.name From dc679d8d4fb6b14117d8b4016983ff29146fc02e Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 06:28:27 +0300 Subject: [PATCH 15/17] tests: campaign store concurrency on real PostgreSQL (advisory lock + unique identity) --- .../test_campaign_store_postgres.py | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/integration/test_campaign_store_postgres.py diff --git a/tests/integration/test_campaign_store_postgres.py b/tests/integration/test_campaign_store_postgres.py new file mode 100644 index 0000000..a3418e4 --- /dev/null +++ b/tests/integration/test_campaign_store_postgres.py @@ -0,0 +1,113 @@ +"""M10.9 on real PostgreSQL: concurrent upserts of one campaign identity never create a second campaign and +never lose a repository record (SQLite cannot reproduce the race: no real MVCC, no advisory locks). + +Skips locally when a migrated Postgres is not reachable; CI sets ``PATCHFROG_REQUIRE_POSTGRES=1``.""" + +from __future__ import annotations + +import asyncio +import uuid +from dataclasses import replace + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from patchfrog.campaigns.domain import ( + CampaignState, + CompatibilityCampaign, + RepositoryRecord, + RepoState, +) +from patchfrog.campaigns.orchestrate import run_campaign +from patchfrog.campaigns.policy import WatchMode, WorkspacePolicy +from patchfrog.campaigns.store import CampaignStore +from patchfrog.persistence.models.campaign import ( + CampaignRepositoryRecordModel, + CompatibilityCampaignModel, +) +from tests.support.campaigns import NOW, acme_event, acme_input +from tests.support.postgres import postgres_engine_or_skip + + +async def _campaign(workspace_key: str) -> CompatibilityCampaign: + event, hints = acme_event() + run = await run_campaign( + workspace_key=workspace_key, event=event, entries=[acme_input("acme-web"), acme_input("acme-search")], now=NOW, + policy=WorkspacePolicy(watch_mode=WatchMode.DETECT_ONLY), hints=hints, + ) + return run.campaign + + +async def test_concurrent_upserts_of_one_campaign_identity_yield_one_row() -> None: + engine = await postgres_engine_or_skip("compatibility_campaigns") + session_factory = async_sessionmaker(engine, expire_on_commit=False) + workspace_key = f"pg-campaign-test-{uuid.uuid4().hex[:8]}" + campaign = await _campaign(workspace_key) + store = CampaignStore() + + async def upsert(variant: CompatibilityCampaign) -> bool: + async with session_factory() as session: + _, created = await store.upsert(session, variant, recorded_at=NOW) + await session.commit() + return created + + try: + results = await asyncio.gather(*(upsert(campaign) for _ in range(8))) + assert results.count(True) == 1 # exactly one creator; the advisory lock + unique identity held + async with session_factory() as session: + assert ( + await session.execute( + select(func.count()).select_from(CompatibilityCampaignModel).where( + CompatibilityCampaignModel.workspace_key == workspace_key + ) + ) + ).scalar_one() == 1 + loaded = await store.load(session, identity_key=campaign.campaign_id) + assert loaded is not None and sorted(loaded.repositories_in_scope) == sorted(campaign.repositories_in_scope) + # concurrent *different* states converge to the highest version, with one record row per repository + bumped = replace(campaign, version=campaign.version + 1, state=CampaignState.ACTION_REQUIRED) + await asyncio.gather(upsert(campaign), upsert(bumped), upsert(bumped)) + async with session_factory() as session: + row = await store.get_row(session, identity_key=campaign.campaign_id) + assert row is not None + records = ( + await session.execute( + select(func.count()).select_from(CampaignRepositoryRecordModel).where( + CampaignRepositoryRecordModel.campaign_id == row.id + ) + ) + ).scalar_one() + assert row.version == campaign.version + 1 + assert records == len(campaign.records) + finally: + async with session_factory() as session: + await session.execute( + delete(CompatibilityCampaignModel).where(CompatibilityCampaignModel.workspace_key == workspace_key) + ) + await session.commit() + await engine.dispose() + + +async def test_a_resolved_repository_state_round_trips_on_postgres() -> None: + engine = await postgres_engine_or_skip("compatibility_campaigns") + session_factory = async_sessionmaker(engine, expire_on_commit=False) + workspace_key = f"pg-campaign-test-{uuid.uuid4().hex[:8]}" + campaign = await _campaign(workspace_key) + resolved: tuple[RepositoryRecord, ...] = tuple(replace(r, state=RepoState.RESOLVED, ever_affected=True) for r in campaign.records) + campaign = replace(campaign, records=resolved, state=CampaignState.RESOLVED) + store = CampaignStore() + try: + async with session_factory() as session: + await store.upsert(session, campaign, recorded_at=NOW) + await session.commit() + async with session_factory() as session: + loaded = await store.load(session, identity_key=campaign.campaign_id) + assert loaded is not None and loaded.state is CampaignState.RESOLVED + assert all(r.state is RepoState.RESOLVED and r.ever_affected for r in loaded.records) + finally: + async with session_factory() as session: + await session.execute( + delete(CompatibilityCampaignModel).where(CompatibilityCampaignModel.workspace_key == workspace_key) + ) + await session.commit() + await engine.dispose() From df3e6c8fe751e366cf3553fdeeb7e59fcf102fae Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 06:58:57 +0300 Subject: [PATCH 16/17] validation: M10+M11 results, defects found, regression status --- validation/m10_m11/latest-summary.md | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/validation/m10_m11/latest-summary.md b/validation/m10_m11/latest-summary.md index 47cc4aa..1458a33 100644 --- a/validation/m10_m11/latest-summary.md +++ b/validation/m10_m11/latest-summary.md @@ -124,3 +124,71 @@ No candidate, no provider call. Every M10/M11 step is deterministic; the campaign/watcher packages must never import a model provider (structural test, same pattern as `test_*_never_imports_a_provider`). A watcher change that does not reach a consumer produces `NOT_AFFECTED`, never a finding. + +--- + +# Results (written after implementation) + +Branch `feat/m10-m11-org-campaigns-watchers`, PR #68. Cloud counterpart: `patchfrog-cloud` PR #3, which pins +this branch's engine head `dc679d8`. + +## What was built, against the plan above + +| Plan item | Outcome | +|---|---| +| `patchfrog/campaigns/` (domain, freshness, graph, blast, internal, state, policy, evaluate, orchestrate, dossier, store, ingest, observe, demo) | built; one module per concern; no provider import (structurally tested) | +| `patchfrog/watchers/` (domain, fetch, net, adapters x6, diff, registry, gate, pipeline) | built; no persistence/Cloud/Celery import (structurally tested) | +| Alembic `0038` | single head; fresh upgrade and downgrade/upgrade verified on PostgreSQL 16 | +| CLI | `patchfrog campaigns analyze|demo` | +| CI log hygiene | key + secrets generated at run time, masked; regression test fails on the old workflow | +| `CAMPAIGN_ENGINE_VERSION = 1`, `WATCHER_ENGINE_VERSION = 1` | added; no other version constant changed | + +Deviations from the plan, and why: the engine gained `event_to_json/event_from_json`, a tenant-scoped registry +read, `evidence_error`, `approved_repositories`, `parse_contract_text`, `GitHubClient.get_default_branch` and draft +PR support. None was in the plan's file list; each was forced by the hosted pipeline or by a defect found while +building it (below). + +## Defects found by building it (all fixed, all covered by a test) + +1. **A failed checkout fell back to registry evidence, which reads "not affected"** for an SDK with no built-in + discovery adapter (package declaration only, no call sites). Now `FAILED` via `RepositoryInput.evidence_error`. + The registry pre-screen was changed to only ever *exclude* repositories that do not declare the dependency. +2. An SDK surface restored from a stored snapshot had no `SdkSurface`, so diffing asserted. +3. A human-closed PR (M9 `no_op_unchanged`) was counted as "in flight"; it is now a human decision. +4. An opened-then-updated PR bumped the campaign version on an identical re-run (campaign spam); normalized. +5. (Cloud) two pollers recording one change raise `IntegrityError` at flush, not only at commit. +6. Test-harness lessons recorded for operators: a venv under `/tmp` hides the interpreter from the sandbox; the stock + Docker profile reports the sandbox unavailable. + +## Regression status (M4-M9) + +| Area | Evidence | +|---|---| +| M4 cost | `eval cost-benchmark --json` output identical to `evaluation_baselines/m4_cost_benchmark.json` (timing keys ignored) | +| M5 discovery | `tests/integration/test_dependency_*` unchanged and green | +| M6 diff / blast radius | upstream corpus green; one *additive* change in `contract_from_registry_snapshot` (rebuilds `SdkSurface` for stored surfaces) that only the new watcher path reaches | +| M7 planning/generation | migration corpus green | +| M8 outcome precedence | `REGRESSION_DETECTED > FAILED > HUMAN_REQUIRED > verified/partial > UNVERIFIED` unchanged (`test_migration_verification_decision`) | +| M9 | publisher tests green; the `draft` parameter is backward-compatible (default `False`); PR identity, stale-base and integrity unchanged | + +## Local results + +* `ruff check .`, `mypy . --strict` (826 files): clean. +* Full `pytest` with PostgreSQL 16 + Redis up and `PATCHFROG_REQUIRE_POSTGRES=1`: **3092 passed, 6 skipped, 0 failed**. + (Without Redis the 5 pre-existing `test_ops_doctor` tests fail locally; they fail identically on `main`.) +* Alembic: one head (`0038_compatibility_campaigns`); from-scratch upgrade OK; downgrade/upgrade round trip OK. +* Docker: `api` and `worker` targets build; the worker image registers all 9 engine Celery tasks (key mounted as a + file, as the new CI does). +* Secret scan of the added lines against `main`: no matches for key headers/token shapes. Pre-existing tracked files + that mention a PEM header contain placeholders only. This proves tracked-file and diff contents, not that nothing + was ever exposed locally or in historical logs. + +CI on the exact PR head is the authority for "green"; see the PR. + +## Not done / not proven + +* Nothing was run against live GitHub, PyPI, npm or production. Real PR publication with the official App is + unproven live. +* Migration verification is sandbox-dependent; where `bwrap` is unavailable nothing is `VERIFIED`. +* No retention job for the engine's older tables; production Postgres is frozen (see Cloud + `docs/production-restore.md`). From c7c994481bee43b0c81155ead60dd19280daf55e Mon Sep 17 00:00:00 2001 From: Kadir Eren Altintas Date: Fri, 2 Oct 2026 07:08:42 +0300 Subject: [PATCH 17/17] tests: the publication-error isolation test needs a VERIFIED repository, so it needs the sandbox (found by CI, where bwrap is unavailable) --- tests/integration/test_campaign_orchestration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/test_campaign_orchestration.py b/tests/integration/test_campaign_orchestration.py index 7e3e5b6..769fcbe 100644 --- a/tests/integration/test_campaign_orchestration.py +++ b/tests/integration/test_campaign_orchestration.py @@ -277,6 +277,7 @@ async def test_stale_base_is_rejected_and_never_published(session_factory: async assert any("regenerate" in r for r in web.reasons) +@needs_sandbox async def test_publication_error_is_isolated_to_its_repository( session_factory: async_sessionmaker[AsyncSession], ) -> None: