""" POST /issues/ — task ingestion endpoint. Receives a TaskSpec payload (see schemas.TaskIngestionRequest) from an authorized emitter, routes it to the configured backend, and returns the created issue's id and (optional) URL. Routing strategy (v1): - Single default backend, looked up via cli.utils.get_default_backend(). - target_repo, triggering_event_id, source_*, activity_definition_id are stored on the issue's sync_metadata for traceability back to the emitter. - Per-target-repo routing is a planned follow-up; see SCOPE.md. """ from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional, Tuple from fastapi import APIRouter, Depends, HTTPException, status from ..backends.gitea import GiteaBackend from ..backends.local import LocalSQLiteBackend from ..cli.utils import get_config_dir, load_backend_configs from ..core.interfaces import BackendFactory, IssueBackend from ..core.mapping import MappingService from ..core.models import Issue, IssueState, Label from .auth import require_api_key from .schemas import BackendName, TaskIngestionRequest, TaskIngestionResponse router = APIRouter() BackendFactory.register_backend("local", LocalSQLiteBackend) BackendFactory.register_backend("gitea", GiteaBackend) _BACKEND_TYPE_TO_NAME: Dict[str, str] = { "local": "sqlite", "gitea": "gitea", "github": "github", } def _resolve_backend() -> Tuple[IssueBackend, str]: configs = load_backend_configs() default_name = configs.get("default", "local") if default_name not in configs: if default_name == "local": configs["local"] = { "type": "local", "db_path": str(get_config_dir() / "issues.db"), } else: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Default backend '{default_name}' is not configured.", ) backend_config = configs[default_name] backend_type = backend_config["type"] try: backend = BackendFactory.create_backend(backend_type) backend.connect(backend_config) except Exception as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Failed to connect to backend '{default_name}': {exc}", ) return backend, backend_type def _build_issue(payload: TaskIngestionRequest, backend_type: str) -> Issue: now = datetime.now(timezone.utc) labels = [Label(name=name) for name in payload.labels] labels.append(Label(name=f"priority:{payload.priority}")) labels.append(Label(name=f"source:{payload.source_type}")) if payload.target_repo: labels.append(Label(name=f"repo:{payload.target_repo}")) ingestion_meta: Dict[str, Any] = { "target_repo": payload.target_repo, "source_type": payload.source_type, "source_id": payload.source_id, "triggering_event_id": str(payload.triggering_event_id), "activity_definition_id": payload.activity_definition_id, "ingested_at": now.isoformat(), } if payload.due_in_days is not None: ingestion_meta["due_at"] = (now + timedelta(days=payload.due_in_days)).isoformat() sync_metadata: Dict[str, Any] = {"ingestion": ingestion_meta} if payload.work_record_uuid: sync_metadata["mapping"] = { "work_record_uuid": payload.work_record_uuid, "work_record_id": payload.work_record_id, "work_record_kind": payload.work_record_kind, } return Issue( id="", number=0, title=payload.title, description=payload.description, state=IssueState.OPEN, created_at=now, updated_at=now, labels=labels, backend_type=backend_type, sync_metadata=sync_metadata, ) def _mapping_backend_name(backend_type: str) -> str: return "sqlite" if backend_type == "local" else backend_type def _upsert_mapping_if_requested( payload: TaskIngestionRequest, *, backend_type: str, issue_id: str, issue_url: Optional[str], ) -> None: if not payload.work_record_uuid: return svc = MappingService() svc.connect(str(get_config_dir() / "mappings.db")) try: svc.upsert( work_record_uuid=payload.work_record_uuid, work_record_id=payload.work_record_id, work_record_kind=payload.work_record_kind, backend=_mapping_backend_name(backend_type), external_id=issue_id, external_url=issue_url, target_repo=payload.target_repo, direction="outward", ) finally: svc.disconnect() @router.post( "/issues/", response_model=TaskIngestionResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_api_key)], summary="Create an external tracker issue (intentional emit; optional work-record mapping).", ) async def ingest_task(payload: TaskIngestionRequest) -> TaskIngestionResponse: backend, backend_type = _resolve_backend() backend_name: BackendName = _BACKEND_TYPE_TO_NAME.get(backend_type, backend_type) # type: ignore[assignment] mapping_backend = _mapping_backend_name(backend_type) # Idempotent: if work_record_uuid already mapped, return existing issue. if payload.work_record_uuid: map_svc = MappingService() map_svc.connect(str(get_config_dir() / "mappings.db")) try: existing = map_svc.get_active_by_uuid( payload.work_record_uuid, backend=mapping_backend ) if existing: return TaskIngestionResponse( issue_id=existing.external_id, issue_url=existing.external_url, backend=backend_name, ) finally: map_svc.disconnect() draft = _build_issue(payload, backend_type) try: created: Issue = backend.create_issue(draft) except HTTPException: raise except Exception as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Backend rejected the issue: {exc}", ) finally: try: backend.disconnect() except Exception: pass issue_id = created.id or str(created.number) issue_url: Optional[str] = None if created.sync_metadata: issue_url = created.sync_metadata.get("url") or created.sync_metadata.get("html_url") try: _upsert_mapping_if_requested( payload, backend_type=backend_type, issue_id=issue_id, issue_url=issue_url, ) except ValueError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc), ) from exc return TaskIngestionResponse( issue_id=issue_id, issue_url=issue_url, backend=backend_name, )