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