Files
oh-my-pi/python/robomp/src/issue_index.py
T
can1357 e426186e46 feat(robomp): added local issue indexing and commit-search tool support
- Added `ROBOMP_ISSUE_INDEX_SYNC_SECONDS` configuration and lifecycle-managed issue indexing through startup/shutdown hooks.
- Added issue/PR index tables with FTS5 triggers plus upsert and keyword/filter search helpers for indexed records.
- Added GitHub backend/proxy support for `IssueIndexEntry` and issue-index page retrieval, including webhook ingestion and periodic watermark-driven sync.
- Updated `gh_search_issues` to prefer local index queries when synchronized and added `search_commits` host tool with query modes and validation.
2026-07-15 00:46:08 +02:00

252 lines
9.0 KiB
Python

"""Local issue/PR search index: webhook ingest, periodic reconcile, query parsing.
The `issue_index` table mirrors every issue and PR of the allowlisted repos so
`gh_search_issues` answers from SQLite FTS5 instead of the GitHub search API.
Freshness comes from two directions:
- `ingest_webhook_payload` upserts on every `issues` / `issue_comment` /
`pull_request*` delivery, keeping the hot path current in real time.
- `IssueIndexSync` reconciles each repo every `issue_index_sync_seconds`
(and backfills on first run) via `/repos/{repo}/issues?since=…`, catching
anything webhooks missed while the orchestrator was down.
`parse_search_query` translates the GitHub-search-flavored tool query into the
structured filters `Database.search_issue_index` takes, so the agent keeps one
query language whether the lookup is served locally or remotely.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from robomp.config import Settings
from robomp.db import Database
from robomp.github_backend import GitHubBackend
from robomp.github_client import (
GitHubError,
index_entry_from_issue_object,
index_entry_from_pr_object,
)
log = logging.getLogger(__name__)
# Overlap subtracted from the watermark on every reconcile so a sync that
# raced a concurrent update can never permanently skip it.
_SYNC_OVERLAP = timedelta(minutes=2)
_PAGE_SIZE = 100
_MAX_PAGES_PER_TICK = 30
@dataclass(slots=True, frozen=True)
class ParsedSearchQuery:
"""Structured form of a GitHub-issue-search style query string."""
keywords: tuple[str, ...]
is_pr: bool | None = None
state: str | None = None
merged: bool | None = None
label: str | None = None
author: str | None = None
def parse_search_query(query: str) -> ParsedSearchQuery:
"""Split a GitHub-search style string into keywords + structured filters.
Supported qualifiers: `is:pr` / `is:issue` / `is:open` / `is:closed` /
`is:merged`, `label:<name>`, `author:<login>`. Unrecognized `key:value`
qualifiers are dropped rather than fed to FTS5 (a bare `in:title` token
would otherwise be a syntax error). Everything else is a keyword.
"""
keywords: list[str] = []
is_pr: bool | None = None
state: str | None = None
merged: bool | None = None
label: str | None = None
author: str | None = None
for token in query.split():
key, sep, value = token.partition(":")
if not sep or not value or " " in key:
keywords.append(token)
continue
key = key.lower()
if key == "is":
v = value.lower()
if v == "pr":
is_pr = True
elif v == "issue":
is_pr = False
elif v in ("open", "closed"):
state = v
elif v == "merged":
is_pr = True
merged = True
elif key == "label":
label = value.strip('"')
elif key == "author":
author = value.lstrip("@")
# Any other qualifier (in:, sort:, created:, …) is intentionally dropped.
return ParsedSearchQuery(
keywords=tuple(keywords),
is_pr=is_pr,
state=state,
merged=merged,
label=label,
author=author,
)
def ingest_webhook_payload(db: Database, repo: str, event_type: str, payload: Mapping[str, object]) -> bool:
"""Upsert the issue/PR carried by a webhook delivery into the index.
Returns True when the payload contained an indexable object. Runs before
routing so even deliveries the router skips (bot comments, unhandled
actions) still refresh the index.
"""
if event_type in ("issues", "issue_comment"):
obj = payload.get("issue")
if isinstance(obj, Mapping) and obj.get("number") is not None:
db.upsert_issue_index(index_entry_from_issue_object(repo, obj))
return True
return False
if event_type.startswith("pull_request"):
obj = payload.get("pull_request")
if isinstance(obj, Mapping) and obj.get("number") is not None:
db.upsert_issue_index(index_entry_from_pr_object(repo, obj))
return True
return False
return False
def _utcnow_iso() -> str:
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def _overlapped(watermark: str) -> str:
"""Rewind an ISO watermark by the sync overlap; fall back to the raw value."""
try:
parsed = datetime.strptime(watermark, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
except ValueError:
return watermark
return (parsed - _SYNC_OVERLAP).strftime("%Y-%m-%dT%H:%M:%SZ")
class IssueIndexSync:
"""Background reconciler for the local issue index.
First tick per repo backfills everything (`since=None`); later ticks pull
only issues updated after the stored watermark (minus a small overlap).
Pagination is bounded per tick — a huge backlog finishes across ticks
rather than hogging one.
"""
def __init__(self, *, settings: Settings, db: Database, github: GitHubBackend) -> None:
self._settings = settings
self._db = db
self._github = github
self._task: asyncio.Task[None] | None = None
self._stop_event: asyncio.Event | None = None
@property
def enabled(self) -> bool:
return self._settings.issue_index_sync_seconds > 0
async def start(self) -> None:
"""Spawn the background loop. No-op when disabled."""
if not self.enabled:
log.info("issue index sync disabled")
return
if self._task is not None:
return
self._stop_event = asyncio.Event()
self._task = asyncio.create_task(self._run(), name="issue-index-sync")
log.info(
"issue index sync started",
extra={"interval_seconds": self._settings.issue_index_sync_seconds},
)
async def stop(self) -> None:
"""Signal the loop to exit and await its termination."""
if self._task is None:
return
assert self._stop_event is not None
self._stop_event.set()
try:
await asyncio.wait_for(self._task, timeout=5.0)
except TimeoutError:
self._task.cancel()
try:
await self._task
except (asyncio.CancelledError, Exception):
pass
finally:
self._task = None
self._stop_event = None
async def _run(self) -> None:
assert self._stop_event is not None
interval = float(self._settings.issue_index_sync_seconds)
while not self._stop_event.is_set():
try:
await self.tick()
except Exception:
log.exception("issue index sync tick failed")
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=interval)
except TimeoutError:
continue
async def tick(self) -> None:
"""Reconcile every allowlisted repo once."""
for repo in self._settings.repo_allowlist:
try:
await self.sync_repo(repo)
except GitHubError as exc:
log.warning(
"issue index sync failed; will retry next tick",
extra={"repo": repo, "status": exc.status, "gh_message": exc.message},
)
async def sync_repo(self, repo: str) -> int:
"""Pull updated issues/PRs for one repo into the index. Returns count ingested."""
started_at = _utcnow_iso()
watermark = self._db.issue_index_watermark(repo)
since = _overlapped(watermark) if watermark else None
ingested = 0
exhausted = False
last_seen = ""
for page in range(1, _MAX_PAGES_PER_TICK + 1):
batch = await self._github.list_issue_index_entries(repo, since=since, page=page, per_page=_PAGE_SIZE)
for entry in batch:
self._db.upsert_issue_index(entry)
if entry.updated_at > last_seen:
last_seen = entry.updated_at
ingested += len(batch)
if len(batch) < _PAGE_SIZE:
exhausted = True
break
if exhausted:
# Everything up to tick start is ingested; later updates are the
# next tick's problem (or a webhook's).
self._db.set_issue_index_watermark(repo, started_at)
elif last_seen:
# Page budget hit mid-backfill: advance the watermark only to the
# newest updated_at actually ingested, so the next tick resumes there.
self._db.set_issue_index_watermark(repo, last_seen)
log.info(
"issue index synced",
extra={"repo": repo, "ingested": ingested, "backfill": watermark is None, "complete": exhausted},
)
return ingested
__all__ = [
"IssueIndexSync",
"ParsedSearchQuery",
"ingest_webhook_payload",
"parse_search_query",
]