issue-core/issue_core/api/ingest.py
tegwick f88e9a28fe
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 1m22s
feat: implement ISSUE-WP-0005 connector mapping and scope alignment
Add MappingService (work_record_uuid ↔ external id), project/map CLI with
outward push-status, optional TaskSpec work_record fields, boundary-sync
policy, and packaging/capability framing cleanup. Mark WP-0005 finished.
2026-07-22 00:25:06 +02:00

209 lines
6.9 KiB
Python

"""
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,
)