""" IssueSink adapter interface and implementations. IssueSink is the outbound boundary between activity-core and task backends (issue-core, State Hub progress, etc.). It receives TaskSpec objects and returns TaskRef objects. Active sink is selected by ISSUE_SINK_TYPE: state-hub (default) — State Hub progress (`activity_task_spawn`); no Forgejo null — dry-run synthetic refs rest — issue-core REST (explicit opt-in; may project to Forgejo) ACTIVITY-WP-0022: default must not silently create Forgejo issues for internal findings. Use rest only when intentionally projecting to an external tracker. """ from __future__ import annotations import logging import os import uuid from abc import ABC, abstractmethod import httpx from activity_core.rules.models import TaskRef, TaskSpec logger = logging.getLogger(__name__) ISSUE_CORE_URL = os.environ.get("ISSUE_CORE_URL", "http://127.0.0.1:8765") ISSUE_CORE_API_KEY_ENV = "ISSUE_CORE_API_KEY" # Safe default for internal fleet findings (ACTIVITY-WP-0022). DEFAULT_ISSUE_SINK_TYPE = "state-hub" ISSUE_SINK_TYPE = os.environ.get("ISSUE_SINK_TYPE", DEFAULT_ISSUE_SINK_TYPE) class IssueSink(ABC): @abstractmethod def emit(self, task_spec: TaskSpec) -> TaskRef: ... class IssueCoreRestSink(IssueSink): """POSTs to issue-core REST API. Config: ISSUE_CORE_URL and ISSUE_CORE_API_KEY env vars (shared key with the issue-core server). """ def __init__( self, base_url: str = ISSUE_CORE_URL, api_key: str | None = None, ) -> None: self._base_url = base_url.rstrip("/") if api_key is not None: self._api_key = api_key.strip() else: self._api_key = os.environ.get(ISSUE_CORE_API_KEY_ENV, "").strip() def _auth_headers(self) -> dict[str, str]: if not self._api_key: raise RuntimeError( f"{ISSUE_CORE_API_KEY_ENV} is not set. " "Required when ISSUE_SINK_TYPE=rest." ) return {"Authorization": f"Bearer {self._api_key}"} def emit(self, task_spec: TaskSpec) -> TaskRef: payload = { "title": task_spec.title, "description": task_spec.description, "target_repo": task_spec.target_repo, "priority": task_spec.priority, "labels": task_spec.labels, "due_in_days": task_spec.due_in_days, "source_type": task_spec.source_type, "source_id": task_spec.source_id, "triggering_event_id": ( str(task_spec.triggering_event_id) if task_spec.triggering_event_id is not None else None ), "activity_definition_id": task_spec.activity_definition_id, } resp = httpx.post( f"{self._base_url}/issues/", json=payload, headers=self._auth_headers(), timeout=10.0, ) status = getattr(resp, "status_code", None) if status is not None and status >= 400: # Surface issue-core backend detail (e.g. dead Gitea token → 503) # so Temporal history and operator status are actionable. detail = (getattr(resp, "text", None) or "")[:500] raise RuntimeError( f"IssueCoreRestSink POST {self._base_url}/issues/ " f"failed HTTP {status}: {detail}" ) if status is None: # Test doubles may only implement raise_for_status/json. resp.raise_for_status() data = resp.json() return TaskRef( external_id=data["issue_id"], backend_url=data.get("issue_url"), backend=data.get("backend", ""), ) class NullSink(IssueSink): """Discards tasks and returns synthetic TaskRefs. For testing.""" def emit(self, task_spec: TaskSpec) -> TaskRef: synthetic_id = f"null-{uuid.uuid4()}" logger.debug("NullSink: discarding task %r → %s", task_spec.title, synthetic_id) return TaskRef(external_id=synthetic_id, backend="null") class StateHubProgressSink(IssueSink): """Record each TaskSpec as a State Hub progress event (no Forgejo issues). ACTIVITY-WP-0021 path B: when issue-core→Forgejo is unavailable or policy forbids automated Forgejo issues, operators can set ISSUE_SINK_TYPE=state-hub so scheduled definitions still complete with observable evidence. Posts event_type=activity_task_spawn (override with STATE_HUB_TASK_EVENT_TYPE). """ def __init__( self, base_url: str | None = None, *, event_type: str | None = None, author: str = "activity-core", timeout_seconds: float = 10.0, ) -> None: self._base_url = ( base_url or os.environ.get("STATE_HUB_URL") or "http://127.0.0.1:8000" ).rstrip("/") self._event_type = event_type or os.environ.get( "STATE_HUB_TASK_EVENT_TYPE", "activity_task_spawn" ) self._author = author self._timeout = timeout_seconds def emit(self, task_spec: TaskSpec) -> TaskRef: from activity_core.state_hub_write import parse_state_hub_write_response external_id = f"sh-{uuid.uuid4()}" body = { "event_type": self._event_type, "author": self._author, "summary": task_spec.title[:240] or "activity-core task spawn", "detail": { "task_ref": external_id, "title": task_spec.title, "description": task_spec.description, "target_repo": task_spec.target_repo, "priority": task_spec.priority, "labels": task_spec.labels, "source_type": task_spec.source_type, "source_id": task_spec.source_id, "triggering_event_id": ( str(task_spec.triggering_event_id) if task_spec.triggering_event_id is not None else None ), "activity_definition_id": task_spec.activity_definition_id, "backend": "state-hub-progress", }, } resp = httpx.post( f"{self._base_url}/progress/", json=body, timeout=self._timeout, ) if resp.status_code >= 400: raise RuntimeError( f"StateHubProgressSink POST {self._base_url}/progress/ " f"failed HTTP {resp.status_code}: {resp.text[:500]}" ) data = parse_state_hub_write_response(resp) progress_id = data.get("id") or data.get("outbox_id") or external_id return TaskRef( external_id=str(progress_id), backend_url=f"{self._base_url}/progress/", backend="state-hub-progress", ) def get_issue_sink() -> IssueSink: """Factory: returns the configured IssueSink based on ISSUE_SINK_TYPE. Re-reads the env on each call so ConfigMap/env patches apply without requiring a module reload (ACTIVITY-WP-0021). """ sink_type = os.environ.get("ISSUE_SINK_TYPE", DEFAULT_ISSUE_SINK_TYPE).strip().lower() if not sink_type: sink_type = DEFAULT_ISSUE_SINK_TYPE if sink_type == "null": return NullSink() if sink_type in {"state-hub", "state_hub", "progress"}: return StateHubProgressSink() if sink_type == "rest": return IssueCoreRestSink() logger.warning( "unknown ISSUE_SINK_TYPE=%r — falling back to %s (safe default)", sink_type, DEFAULT_ISSUE_SINK_TYPE, ) return StateHubProgressSink()