Expose list/get/claim/close for agent-harness intake against the same auth as POST /issues/ ingestion (HARNESS-WP-0001-T03).
211 lines
6.8 KiB
Python
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
|