"""Durable dependency-edge vocabulary for the control plane (#784, umbrella #628). Umbrella #628 scope item 6 requires dependencies to be durable structured state carrying source, target, type, blocking condition, completion condition, current state, and evidence. Before this module the only dependency knowledge in the system was the per-run parse performed by :mod:`allocator_dependencies`, which collapsed into two in-memory ``WorkCandidate`` fields and was then discarded. This module owns the vocabulary half of that store: * the seven relationship types #628 enumerates; * the three observation states, matching the outcome of :func:`allocator_dependencies.resolve_dependency_state`; * fail-closed normalization for both, plus for work kinds; * the default blocking/completion condition text for each type; * evidence sanitization, so no credential or endpoint ever reaches the store. Persistence lives in :mod:`control_plane_db`; ingestion from a live allocation run is :func:`record_issue_dependency_edges`. Nothing here changes allocator selection — this slice records the graph, it does not act on it. """ from __future__ import annotations import re from typing import Any, Iterable, Mapping # --- Work kinds ------------------------------------------------------------- # Mirrors control_plane_db.WORK_KINDS. Declared locally so this module stays # import-light and usable from the DB layer without a circular import. WORK_KIND_ISSUE = "issue" WORK_KIND_PR = "pr" WORK_KINDS = frozenset({WORK_KIND_ISSUE, WORK_KIND_PR}) # --- Edge types (#628 scope item 6) ----------------------------------------- EDGE_ISSUE_BLOCKED_BY_ISSUE = "issue_blocked_by_issue" EDGE_PR_WAITING_FOR_REQUESTED_CHANGES = "pr_waiting_for_requested_changes" EDGE_MERGE_WAITING_FOR_APPROVAL = "merge_waiting_for_approval" EDGE_RECONCILIATION_WAITING_FOR_MERGE = "reconciliation_waiting_for_merge" EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE = "deployment_waiting_for_infrastructure" EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION = "acceptance_waiting_for_validation" EDGE_TASK_WAITING_FOR_DEFECT_FIX = "task_waiting_for_defect_fix" EDGE_TYPES: frozenset[str] = frozenset( { EDGE_ISSUE_BLOCKED_BY_ISSUE, EDGE_PR_WAITING_FOR_REQUESTED_CHANGES, EDGE_MERGE_WAITING_FOR_APPROVAL, EDGE_RECONCILIATION_WAITING_FOR_MERGE, EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE, EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION, EDGE_TASK_WAITING_FOR_DEFECT_FIX, } ) # --- Edge states ------------------------------------------------------------ # Deliberately three-valued: unavailable evidence is never recorded as met, # matching resolve_dependency_state's fail-closed contract (#758 AC6/AC7). STATE_UNMET = "unmet" STATE_MET = "met" STATE_UNAVAILABLE = "unavailable" EDGE_STATES: frozenset[str] = frozenset({STATE_UNMET, STATE_MET, STATE_UNAVAILABLE}) # Default condition text per edge type: (blocking_condition, completion_condition). DEFAULT_CONDITIONS: dict[str, tuple[str, str]] = { EDGE_ISSUE_BLOCKED_BY_ISSUE: ( "target issue is not closed", "target issue is closed", ), EDGE_PR_WAITING_FOR_REQUESTED_CHANGES: ( "requested changes are outstanding at the current head", "requested changes are addressed at the current head", ), EDGE_MERGE_WAITING_FOR_APPROVAL: ( "no approval exists at the current head", "an approval exists at the current head", ), EDGE_RECONCILIATION_WAITING_FOR_MERGE: ( "target pull request is not merged", "target pull request is merged", ), EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE: ( "required infrastructure is unavailable", "required infrastructure is available", ), EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION: ( "required validation evidence is missing", "required validation evidence is recorded", ), EDGE_TASK_WAITING_FOR_DEFECT_FIX: ( "blocking defect is unresolved or undeployed", "blocking defect is fixed and the runtime carries the fix", ), } class DependencyGraphError(ValueError): """Base error for dependency-edge vocabulary violations.""" class InvalidEdgeTypeError(DependencyGraphError): """Raised when an edge type outside :data:`EDGE_TYPES` is supplied.""" class InvalidEdgeStateError(DependencyGraphError): """Raised when a state outside :data:`EDGE_STATES` is supplied.""" class InvalidEdgeEndpointError(DependencyGraphError): """Raised when an edge endpoint is not an assignable work unit.""" def normalize_edge_type(value: Any) -> str: """Return the canonical edge type, or raise fail-closed. Unknown values are never coerced to a default: an unrecognized relationship would be stored as an unqueryable free-text row and would silently break reverse lookup for whichever consumer expected the real type. """ text = str(value or "").strip().lower() if text not in EDGE_TYPES: raise InvalidEdgeTypeError( f"unknown dependency edge_type '{value}'; expected one of " f"{sorted(EDGE_TYPES)} (fail closed)" ) return text def normalize_edge_state(value: Any) -> str: """Return the canonical edge state, or raise fail-closed.""" text = str(value or "").strip().lower() if text not in EDGE_STATES: raise InvalidEdgeStateError( f"unknown dependency edge state '{value}'; expected one of " f"{sorted(EDGE_STATES)} (fail closed)" ) return text def normalize_work_kind(value: Any) -> str: """Return the canonical work kind for an edge endpoint, or raise.""" text = str(value or "").strip().lower() if text not in WORK_KINDS: raise InvalidEdgeEndpointError( f"dependency edge endpoint kind '{value}' is not assignable work; " f"expected one of {sorted(WORK_KINDS)} (never raw incidents)" ) return text def default_conditions(edge_type: str) -> tuple[str, str]: """Return ``(blocking_condition, completion_condition)`` for *edge_type*.""" return DEFAULT_CONDITIONS[normalize_edge_type(edge_type)] # --- Evidence sanitization -------------------------------------------------- _SECRET_KEY_PATTERN = re.compile( r"token|secret|password|passwd|authorization|auth_header|credential|api_key" r"|apikey|private_key|cookie|session_token", re.IGNORECASE, ) _URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]*://\S+", re.IGNORECASE) REDACTED = "[redacted]" # Evidence is a small observation record; a deep or huge payload is a sign the # caller is dumping API responses into the store. _MAX_EVIDENCE_DEPTH = 6 _MAX_EVIDENCE_STRING = 2000 def sanitize_evidence(payload: Any, *, _depth: int = 0) -> Any: """Return *payload* with credentials and endpoint URLs removed. Applies to every stored evidence record. Keys naming a secret are replaced wholesale; any value containing a URL has the URL replaced, so an endpoint can never be persisted or handed back through a read tool. """ if _depth > _MAX_EVIDENCE_DEPTH: return REDACTED if isinstance(payload, Mapping): clean: dict[str, Any] = {} for key, value in payload.items(): name = str(key) if _SECRET_KEY_PATTERN.search(name): clean[name] = REDACTED else: clean[name] = sanitize_evidence(value, _depth=_depth + 1) return clean if isinstance(payload, (list, tuple)): return [sanitize_evidence(item, _depth=_depth + 1) for item in payload] if isinstance(payload, str): text = _URL_PATTERN.sub(REDACTED, payload) if len(text) > _MAX_EVIDENCE_STRING: text = text[:_MAX_EVIDENCE_STRING] + "…" return text if isinstance(payload, (int, float, bool)) or payload is None: return payload return sanitize_evidence(str(payload), _depth=_depth + 1) # --- Ingestion from a live allocation run ----------------------------------- # Observed live state as recorded in evidence. The exact Gitea state string is # not stored for the unmet case: resolve_dependency_state has already reduced # "any live value other than closed" to unmet, and re-deriving it here would # invent evidence the resolver never produced. OBSERVED_CLOSED = "closed" OBSERVED_NOT_CLOSED = "not_closed" OBSERVED_UNAVAILABLE = "unavailable" OBSERVATION_SOURCE_ALLOCATOR = "allocator_live_issue_lookup" _OBSERVED_STATE_BY_EDGE_STATE = { STATE_MET: OBSERVED_CLOSED, STATE_UNMET: OBSERVED_NOT_CLOSED, STATE_UNAVAILABLE: OBSERVED_UNAVAILABLE, } def _observation(state: str, *, observed_by: str | None, subject: str) -> dict[str, Any]: return { "observed_state": _OBSERVED_STATE_BY_EDGE_STATE[state], "observation_source": OBSERVATION_SOURCE_ALLOCATOR, "observed_by_session": observed_by, "declaration": "Depends declaration in issue body", "subject": subject, } def edges_from_dependency_resolution( resolution: Mapping[str, Any], *, source_number: int, observed_by: str | None = None, ) -> list[dict[str, Any]]: """Convert one resolver result into edge records ready for persistence. *resolution* is the dict returned by :func:`allocator_dependencies.resolve_dependency_state`. Its ``met`` / ``unmet`` / ``unavailable`` partitions map one-to-one onto the stored states, so no dependency is re-classified here. """ subject = f"issue#{int(source_number)}" blocking, completion = default_conditions(EDGE_ISSUE_BLOCKED_BY_ISSUE) records: list[dict[str, Any]] = [] partitions: tuple[tuple[str, Iterable[Any]], ...] = ( (STATE_MET, resolution.get("met") or ()), (STATE_UNMET, resolution.get("unmet") or ()), (STATE_UNAVAILABLE, resolution.get("unavailable") or ()), ) for state, refs in partitions: for ref in refs: records.append( { "source_kind": WORK_KIND_ISSUE, "source_number": int(source_number), "target_kind": WORK_KIND_ISSUE, "target_number": int(ref), "edge_type": EDGE_ISSUE_BLOCKED_BY_ISSUE, "state": state, "blocking_condition": blocking, "completion_condition": completion, "evidence": _observation( state, observed_by=observed_by, subject=subject ), } ) return records def record_issue_dependency_edges( db: Any, *, remote: str, org: str, repo: str, source_number: int, resolution: Mapping[str, Any], observed_by: str | None = None, ) -> list[str]: """Persist the edges implied by one candidate's dependency resolution. Best-effort by contract: allocation correctness must not depend on this store existing or being writable, so every failure is returned as a reason string and never raised. The caller keeps using the in-memory resolution it already holds. """ try: records = edges_from_dependency_resolution( resolution, source_number=source_number, observed_by=observed_by ) except Exception as exc: # noqa: BLE001 — ingestion never breaks allocation return [f"dependency edge ingestion skipped for issue#{source_number}: {exc}"] reasons: list[str] = [] for record in records: try: db.upsert_dependency_edge(remote=remote, org=org, repo=repo, **record) except Exception as exc: # noqa: BLE001 — see docstring reasons.append( f"dependency edge not persisted for issue#{source_number} → " f"issue#{record['target_number']}: {exc}" ) return reasons