feat: rename to issue-core and add task ingestion endpoint

Renames the package, distribution, CLI alias, Makefile targets, and
working directory from issue-facade to issue-core, signalling its
role as the authoritative task lifecycle manager for the Coulomb org
(peer to activity-core, rules-core, project-core).

Adds POST /issues/ ingestion endpoint for activity-core's IssueSink,
under a new optional [api] extra. The endpoint is served by `issue
serve`, authenticates via the ISSUE_CORE_API_KEY env var (Bearer or
X-API-Key header), and routes the TaskSpec payload to the configured
default backend with full traceability metadata embedded in
sync_metadata.

- T01: Python package issue_tracker -> issue_core, dir rename
- T02: registered in state hub under custodian domain
- T03: INTENT.md (what it is, what it isn't, how it fits)
- T04: SCOPE.md (in/out-of-scope, integration boundaries)
- T05: POST /issues/ via FastAPI + Uvicorn, 9 unit tests
- T06: docs/nats-task-ingestion.md design stub

Closes ISSC-WP-0001.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 05:16:27 +02:00
parent 99ea1fbc45
commit b605d970e3
38 changed files with 1324 additions and 361 deletions

View File

@@ -1,24 +1,24 @@
"""
Universal Issue Tracking System
A backend-agnostic issue tracking system that supports multiple backends
through a plugin architecture. Designed to be extracted into a standalone
repository for use across multiple projects.
Features:
- Unified issue model across all backends
- Plugin-based backend architecture
- Local SQLite backend for offline work
- Bidirectional synchronization
- CLI-first interface
- Support for GitHub-style and other issue tracking systems
Supported Backends:
- Local SQLite (for offline/standalone use)
- Gitea (GitHub-compatible API)
- Future: GitHub, GitLab, JIRA, Redmine, etc.
"""
__version__ = "0.1.0"
__author__ = "MarkiTect Project"
__description__ = "Universal Issue Tracking System with Plugin Architecture"
"""
issue-core — Authoritative Task Lifecycle Manager
The single observable place in the Coulomb org where tasks land —
regardless of whether they were created by a human, by activity-core,
or by an agent. Backend-agnostic via a plugin architecture.
Features:
- Unified issue model across all backends
- Plugin-based backend architecture
- Local SQLite backend for offline work
- Bidirectional synchronization
- CLI-first interface
- REST ingestion endpoint for activity-core's IssueSink
Supported Backends:
- Local SQLite (offline/standalone)
- Gitea (GitHub-compatible API)
- Future: GitHub, GitLab, JIRA, Redmine
"""
__version__ = "0.2.0"
__author__ = "Coulomb / MarkiTect Project"
__description__ = "Authoritative task lifecycle manager with plugin architecture"

View File

@@ -0,0 +1,14 @@
"""
issue-core REST API.
Ingestion surface for external emitters — primarily activity-core's
IssueSink, which calls POST /issues/ to file tasks against the org's
configured backends.
Run via the CLI: `issue serve --host 0.0.0.0 --port 8765`
Requires the [api] extra: `pip install issue-core[api]`.
"""
from .app import create_app
__all__ = ["create_app"]

26
issue_core/api/app.py Normal file
View File

@@ -0,0 +1,26 @@
"""
FastAPI application factory for issue-core.
"""
from fastapi import FastAPI
from .. import __version__
from .ingest import router as ingest_router
def create_app() -> FastAPI:
app = FastAPI(
title="issue-core",
description=(
"Authoritative task lifecycle manager for the Coulomb org. "
"POST /issues/ is the ingestion surface for activity-core's IssueSink."
),
version=__version__,
)
app.include_router(ingest_router)
@app.get("/healthz", tags=["meta"])
async def healthz() -> dict:
return {"status": "ok", "version": __version__}
return app

66
issue_core/api/auth.py Normal file
View File

@@ -0,0 +1,66 @@
"""
API key authentication for the issue-core REST API.
A single shared key is read from the ISSUE_CORE_API_KEY environment variable.
Clients send it either as `Authorization: Bearer <key>` or as `X-API-Key: <key>`.
If ISSUE_CORE_API_KEY is unset, the server refuses to start — the workplan
explicitly forbids an unauthenticated ingestion surface.
"""
import os
import secrets
from typing import Optional
from fastapi import Header, HTTPException, status
API_KEY_ENV_VAR = "ISSUE_CORE_API_KEY"
class AuthConfigError(RuntimeError):
"""Raised at startup when no API key is configured."""
def get_configured_api_key() -> str:
key = os.environ.get(API_KEY_ENV_VAR, "").strip()
if not key:
raise AuthConfigError(
f"{API_KEY_ENV_VAR} is not set. The ingestion endpoint requires an API key. "
f"Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'"
)
return key
def _extract_token(
authorization: Optional[str],
x_api_key: Optional[str],
) -> Optional[str]:
if x_api_key:
return x_api_key.strip()
if authorization:
scheme, _, value = authorization.partition(" ")
if scheme.lower() == "bearer" and value:
return value.strip()
return None
async def require_api_key(
authorization: Optional[str] = Header(default=None),
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
) -> None:
"""FastAPI dependency enforcing the shared API key."""
try:
expected = get_configured_api_key()
except AuthConfigError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
)
presented = _extract_token(authorization, x_api_key)
if not presented or not secrets.compare_digest(presented, expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid API key.",
headers={"WWW-Authenticate": "Bearer"},
)

139
issue_core/api/ingest.py Normal file
View File

@@ -0,0 +1,139 @@
"""
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,
)

50
issue_core/api/schemas.py Normal file
View File

@@ -0,0 +1,50 @@
"""
Pydantic schemas for the issue-core REST API.
The TaskIngestionRequest schema matches activity-core's IssueSink TaskSpec
payload exactly. See:
- SCOPE.md "TaskSpec payload" section
- activity-core docs/adr/adr-001-event-bridge-architecture.md
"""
from typing import List, Literal, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
SourceType = Literal["rule", "instruction"]
Priority = Literal["high", "medium", "low"]
BackendName = Literal["gitea", "sqlite", "github"]
class TaskIngestionRequest(BaseModel):
"""TaskSpec payload from activity-core's IssueSink (POST /issues/)."""
model_config = ConfigDict(extra="forbid")
title: str = Field(..., min_length=1, max_length=500)
description: str = ""
target_repo: str = Field(..., min_length=1)
priority: Priority = "medium"
labels: List[str] = Field(default_factory=list)
due_in_days: Optional[int] = Field(default=None, ge=0)
source_type: SourceType
source_id: str = Field(..., min_length=1)
triggering_event_id: UUID
activity_definition_id: str = Field(..., min_length=1)
class TaskIngestionResponse(BaseModel):
"""Response returned to the emitter after a successful ingestion."""
issue_id: str
issue_url: Optional[str] = None
backend: BackendName
class ErrorResponse(BaseModel):
"""Uniform error envelope."""
error: str
detail: Optional[str] = None

View File

@@ -11,11 +11,12 @@ from pathlib import Path
from .commands import issue_group
from .backend_commands import backend_group
from .sync_commands import sync_group
from .serve_command import serve_command
from .. import __version__
@click.group()
@click.version_option(version=__version__, package_name='issue-tracker')
@click.version_option(version=__version__, package_name='issue-core')
@click.option('--config', type=click.Path(), help='Configuration file path')
@click.option('--backend', help='Backend to use (local, gitea)')
@click.option('--verbose', '-v', is_flag=True, help='Verbose output')
@@ -52,6 +53,7 @@ def cli(ctx, config, backend, verbose):
cli.add_command(issue_group, name='issue')
cli.add_command(backend_group, name='backend')
cli.add_command(sync_group, name='sync')
cli.add_command(serve_command)
# Convenience aliases - direct issue commands

View File

@@ -0,0 +1,43 @@
"""
`issue serve` — launch the issue-core REST API.
Requires the [api] extra: `pip install issue-core[api]`.
"""
import click
@click.command("serve")
@click.option("--host", default="127.0.0.1", show_default=True, help="Bind address.")
@click.option("--port", default=8765, show_default=True, type=int, help="Bind port.")
@click.option("--reload", is_flag=True, default=False, help="Auto-reload on code change (dev only).")
@click.option("--log-level", default="info", show_default=True, help="Uvicorn log level.")
def serve_command(host: str, port: int, reload: bool, log_level: str) -> None:
"""Launch the issue-core REST API (POST /issues/ ingestion endpoint).
Requires ISSUE_CORE_API_KEY to be set in the environment.
"""
try:
import uvicorn # noqa: F401
except ImportError:
raise click.ClickException(
"The 'api' extra is not installed. Run: pip install 'issue-core[api]'"
)
from ..api.auth import AuthConfigError, get_configured_api_key
try:
get_configured_api_key()
except AuthConfigError as exc:
raise click.ClickException(str(exc))
import uvicorn
uvicorn.run(
"issue_core.api.app:create_app",
host=host,
port=port,
reload=reload,
log_level=log_level,
factory=True,
)