issue-core/issue_core/api/ingest.py

140 lines
4.7 KiB
Python
Raw Normal View History

"""
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.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}
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,
)
@router.post(
"/issues/",
response_model=TaskIngestionResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_key)],
summary="Ingest a task from an external emitter (e.g. activity-core).",
)
async def ingest_task(payload: TaskIngestionRequest) -> TaskIngestionResponse:
backend, backend_type = _resolve_backend()
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")
backend_name: BackendName = _BACKEND_TYPE_TO_NAME.get(backend_type, backend_type) # type: ignore[assignment]
return TaskIngestionResponse(
issue_id=issue_id,
issue_url=issue_url,
backend=backend_name,
)