issue-core/issue_core/api/ingest.py

225 lines
7.5 KiB
Python
Raw Permalink 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.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 _public_issue_id(issue: Issue, backend_type: str) -> str:
"""Return the identifier accepted by the backend's issue routes.
Forgejo/Gitea exposes both a database-wide ``id`` and a repository-local
issue ``number``. Its issue CRUD routes address the latter, so returning
the database ID from ingestion makes the documented POST -> PATCH lifecycle
fail with 404. Local backends continue to expose their native string ID.
"""
if backend_type in {"gitea", "github"} and issue.number:
return str(issue.number)
if issue.id:
return issue.id
return str(issue.number)
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 = _public_issue_id(created, backend_type)
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,
)