issue-core/issue_core/api/query.py
tegwick f22faaa815
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 48s
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).
2026-07-18 11:30:38 +02:00

211 lines
6.8 KiB
Python

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