feat(api): GET/PATCH /issues for worker poll and claim
Expose list/get/claim/close for agent-harness intake against the same auth as POST /issues/ ingestion (HARNESS-WP-0001-T03).
This commit is contained in:
parent
cdbe87d525
commit
f22faaa815
4 changed files with 328 additions and 1 deletions
|
|
@ -6,6 +6,7 @@ from fastapi import FastAPI
|
|||
|
||||
from .. import __version__
|
||||
from .ingest import router as ingest_router
|
||||
from .query import router as query_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
|
@ -13,11 +14,13 @@ def create_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."
|
||||
"POST /issues/ is the ingestion surface for activity-core's IssueSink; "
|
||||
"GET/PATCH /issues/ is the worker poll/claim surface for agent-harness."
|
||||
),
|
||||
version=__version__,
|
||||
)
|
||||
app.include_router(ingest_router)
|
||||
app.include_router(query_router)
|
||||
|
||||
@app.get("/healthz", tags=["meta"])
|
||||
async def healthz() -> dict:
|
||||
|
|
|
|||
211
issue_core/api/query.py
Normal file
211
issue_core/api/query.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""
|
||||
Worker-facing issue query and claim endpoints.
|
||||
|
||||
Complements POST /issues/ (ingestion) so executors like agent-harness can:
|
||||
|
||||
GET /issues/ — list open (or filtered) issues
|
||||
GET /issues/{issue_id} — fetch one issue with ingestion metadata
|
||||
PATCH /issues/{issue_id} — claim (in_progress) or close
|
||||
|
||||
Auth: same shared ISSUE_CORE_API_KEY as ingestion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..core.interfaces import IssueFilter
|
||||
from ..core.models import Issue, IssueState, Label, User
|
||||
from .auth import require_api_key
|
||||
from .ingest import _resolve_backend
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class IssueOut(BaseModel):
|
||||
"""Stable JSON shape for list/get (harness maps this to TaskSpec)."""
|
||||
|
||||
issue_id: str
|
||||
number: int
|
||||
title: str
|
||||
description: str
|
||||
state: str
|
||||
labels: List[str] = Field(default_factory=list)
|
||||
target_repo: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
source_type: Optional[str] = None
|
||||
source_id: Optional[str] = None
|
||||
triggering_event_id: Optional[str] = None
|
||||
activity_definition_id: Optional[str] = None
|
||||
assignee: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
issue_url: Optional[str] = None
|
||||
|
||||
|
||||
class IssuePatch(BaseModel):
|
||||
state: Optional[str] = Field(
|
||||
default=None,
|
||||
description="open | in_progress | closed | blocked",
|
||||
)
|
||||
assignee: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Username to assign (claim); empty string clears",
|
||||
)
|
||||
|
||||
|
||||
def _issue_to_out(issue: Issue) -> IssueOut:
|
||||
labels = [label.name for label in (issue.labels or [])]
|
||||
ingestion: Dict[str, Any] = {}
|
||||
if issue.sync_metadata and isinstance(issue.sync_metadata, dict):
|
||||
raw = issue.sync_metadata.get("ingestion") or {}
|
||||
if isinstance(raw, dict):
|
||||
ingestion = raw
|
||||
priority = None
|
||||
for name in labels:
|
||||
if name.startswith("priority:"):
|
||||
priority = name.split(":", 1)[1]
|
||||
break
|
||||
assignee = None
|
||||
if issue.assignees:
|
||||
assignee = issue.assignees[0].username
|
||||
issue_url = None
|
||||
if issue.sync_metadata:
|
||||
issue_url = issue.sync_metadata.get("url") or issue.sync_metadata.get("html_url")
|
||||
issue_id = issue.id or str(issue.number)
|
||||
return IssueOut(
|
||||
issue_id=issue_id,
|
||||
number=issue.number,
|
||||
title=issue.title,
|
||||
description=issue.description or "",
|
||||
state=issue.state.value if isinstance(issue.state, IssueState) else str(issue.state),
|
||||
labels=labels,
|
||||
target_repo=ingestion.get("target_repo"),
|
||||
priority=priority,
|
||||
source_type=ingestion.get("source_type"),
|
||||
source_id=ingestion.get("source_id"),
|
||||
triggering_event_id=ingestion.get("triggering_event_id"),
|
||||
activity_definition_id=ingestion.get("activity_definition_id"),
|
||||
assignee=assignee,
|
||||
created_at=issue.created_at.isoformat() if issue.created_at else None,
|
||||
updated_at=issue.updated_at.isoformat() if issue.updated_at else None,
|
||||
issue_url=issue_url,
|
||||
)
|
||||
|
||||
|
||||
def _with_backend():
|
||||
backend, _backend_type = _resolve_backend()
|
||||
return backend
|
||||
|
||||
|
||||
@router.get(
|
||||
"/issues/",
|
||||
response_model=List[IssueOut],
|
||||
dependencies=[Depends(require_api_key)],
|
||||
summary="List issues for workers (filter by state and labels).",
|
||||
)
|
||||
async def list_issues(
|
||||
state: Optional[str] = Query(default="open"),
|
||||
label: Optional[List[str]] = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> List[IssueOut]:
|
||||
backend = _with_backend()
|
||||
try:
|
||||
# Fetch a wider page when label-filtering client-side (local backend
|
||||
# does not always apply label predicates in SQL).
|
||||
want_labels = list(label) if label else []
|
||||
fetch_limit = max(limit * 5, limit) if want_labels else limit
|
||||
criteria = IssueFilter(
|
||||
state=state if state and state != "all" else None,
|
||||
labels=want_labels or None,
|
||||
limit=fetch_limit,
|
||||
)
|
||||
issues = backend.list_issues(criteria)
|
||||
if want_labels:
|
||||
required = set(want_labels)
|
||||
issues = [
|
||||
issue
|
||||
for issue in issues
|
||||
if required.issubset({lab.name for lab in (issue.labels or [])})
|
||||
]
|
||||
return [_issue_to_out(issue) for issue in issues[:limit]]
|
||||
finally:
|
||||
try:
|
||||
backend.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.get(
|
||||
"/issues/{issue_id}",
|
||||
response_model=IssueOut,
|
||||
dependencies=[Depends(require_api_key)],
|
||||
summary="Get one issue by id.",
|
||||
)
|
||||
async def get_issue(issue_id: str) -> IssueOut:
|
||||
backend = _with_backend()
|
||||
try:
|
||||
issue = backend.get_issue(issue_id)
|
||||
if issue is None:
|
||||
# Fall back to number lookup for backends that key by number.
|
||||
try:
|
||||
issue = backend.get_issue_by_number(int(issue_id))
|
||||
except (TypeError, ValueError):
|
||||
issue = None
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="issue not found")
|
||||
return _issue_to_out(issue)
|
||||
finally:
|
||||
try:
|
||||
backend.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/issues/{issue_id}",
|
||||
response_model=IssueOut,
|
||||
dependencies=[Depends(require_api_key)],
|
||||
summary="Claim (in_progress + assignee) or close an issue.",
|
||||
)
|
||||
async def patch_issue(issue_id: str, body: IssuePatch) -> IssueOut:
|
||||
backend = _with_backend()
|
||||
try:
|
||||
issue = backend.get_issue(issue_id)
|
||||
if issue is None:
|
||||
try:
|
||||
issue = backend.get_issue_by_number(int(issue_id))
|
||||
except (TypeError, ValueError):
|
||||
issue = None
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="issue not found")
|
||||
|
||||
if body.state is not None:
|
||||
try:
|
||||
issue.state = IssueState.from_string(body.state)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"invalid state: {body.state}",
|
||||
) from exc
|
||||
|
||||
if body.assignee is not None:
|
||||
if body.assignee == "":
|
||||
issue.assignees = []
|
||||
else:
|
||||
issue.assignees = [
|
||||
User(id=body.assignee, username=body.assignee)
|
||||
]
|
||||
|
||||
issue.updated_at = datetime.now(timezone.utc)
|
||||
updated = backend.update_issue(issue)
|
||||
return _issue_to_out(updated)
|
||||
finally:
|
||||
try:
|
||||
backend.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
Loading…
Add table
Add a link
Reference in a new issue