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.
This commit is contained in:
@@ -122,6 +122,30 @@ class IssueSummary:
|
||||
is_pull_request: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class IssueIndexEntry:
|
||||
"""Full projection of an issue/PR for the local search index (includes body).
|
||||
|
||||
Produced by `GitHubClient.list_issue_index_entries` / webhook payloads and
|
||||
stored verbatim in the orchestrator's `issue_index` table.
|
||||
"""
|
||||
|
||||
repo: str
|
||||
number: int
|
||||
is_pull_request: bool
|
||||
title: str
|
||||
body: str
|
||||
state: str # open | closed
|
||||
state_reason: str # completed | not_planned | reopened | ""
|
||||
merged_at: str # ISO timestamp for merged PRs; "" otherwise
|
||||
author: str
|
||||
labels: tuple[str, ...]
|
||||
comments: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
html_url: str
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ReactionInfo:
|
||||
"""A reaction on an issue/comment.
|
||||
@@ -359,6 +383,31 @@ class GitHubClient:
|
||||
items = (data or {}).get("items") or []
|
||||
return [_summary_from_item(repo, item) for item in items]
|
||||
|
||||
async def list_issue_index_entries(
|
||||
self,
|
||||
repo: str,
|
||||
*,
|
||||
since: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 100,
|
||||
) -> list[IssueIndexEntry]:
|
||||
"""One page of issues AND PRs (with bodies) for the local search index.
|
||||
|
||||
`since` is GitHub's ISO `updated_at` lower bound; omit for a full
|
||||
backfill. Callers page from 1 until a short page comes back.
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
"state": "all",
|
||||
"per_page": max(1, min(int(per_page), 100)),
|
||||
"page": max(1, int(page)),
|
||||
"sort": "updated",
|
||||
"direction": "asc",
|
||||
}
|
||||
if since:
|
||||
params["since"] = since
|
||||
data = await self.request("GET", f"/repos/{repo}/issues", params=params)
|
||||
return [index_entry_from_issue_object(repo, item) for item in (data or [])]
|
||||
|
||||
async def list_comments(self, repo: str, number: int) -> list[CommentInfo]:
|
||||
data = await self.request("GET", f"/repos/{repo}/issues/{number}/comments", params={"per_page": 100})
|
||||
return [_comment_from_payload(item) for item in (data or [])]
|
||||
@@ -602,6 +651,60 @@ def _summary_from_item(repo: str, item: Mapping[str, Any]) -> IssueSummary:
|
||||
)
|
||||
|
||||
|
||||
def index_entry_from_issue_object(repo: str, item: Mapping[str, Any]) -> IssueIndexEntry:
|
||||
"""Build an `IssueIndexEntry` from a REST *issue-shaped* object.
|
||||
|
||||
Accepts both plain issues and the issue representation of a PR (webhook
|
||||
`issues`/`issue_comment` payloads, `/repos/{repo}/issues` items): PRs carry
|
||||
a `pull_request` sub-object holding `merged_at`.
|
||||
"""
|
||||
user = item.get("user") or {}
|
||||
labels_raw = item.get("labels") or []
|
||||
pr_obj = item.get("pull_request")
|
||||
is_pr = pr_obj is not None
|
||||
merged_at = str(pr_obj.get("merged_at") or "") if isinstance(pr_obj, Mapping) else ""
|
||||
return IssueIndexEntry(
|
||||
repo=repo,
|
||||
number=int(item["number"]),
|
||||
is_pull_request=is_pr,
|
||||
title=str(item.get("title") or ""),
|
||||
body=str(item.get("body") or ""),
|
||||
state=str(item.get("state") or "open"),
|
||||
state_reason=str(item.get("state_reason") or ""),
|
||||
merged_at=merged_at,
|
||||
author=str(user.get("login") or ""),
|
||||
labels=tuple(str(lbl["name"]) if isinstance(lbl, dict) else str(lbl) for lbl in labels_raw),
|
||||
comments=int(item.get("comments") or 0),
|
||||
created_at=str(item.get("created_at") or ""),
|
||||
updated_at=str(item.get("updated_at") or ""),
|
||||
html_url=str(item.get("html_url") or ""),
|
||||
)
|
||||
|
||||
|
||||
def index_entry_from_pr_object(repo: str, item: Mapping[str, Any]) -> IssueIndexEntry:
|
||||
"""Build an `IssueIndexEntry` from a REST *pull-request-shaped* object
|
||||
(webhook `pull_request*` payloads), where `merged_at` sits at the top level.
|
||||
"""
|
||||
user = item.get("user") or {}
|
||||
labels_raw = item.get("labels") or []
|
||||
return IssueIndexEntry(
|
||||
repo=repo,
|
||||
number=int(item["number"]),
|
||||
is_pull_request=True,
|
||||
title=str(item.get("title") or ""),
|
||||
body=str(item.get("body") or ""),
|
||||
state=str(item.get("state") or "open"),
|
||||
state_reason="",
|
||||
merged_at=str(item.get("merged_at") or ""),
|
||||
author=str(user.get("login") or ""),
|
||||
labels=tuple(str(lbl["name"]) if isinstance(lbl, dict) else str(lbl) for lbl in labels_raw),
|
||||
comments=int(item.get("comments") or 0),
|
||||
created_at=str(item.get("created_at") or ""),
|
||||
updated_at=str(item.get("updated_at") or ""),
|
||||
html_url=str(item.get("html_url") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _pr_file_from_payload(data: Mapping[str, Any]) -> PullRequestFileInfo:
|
||||
return PullRequestFileInfo(
|
||||
path=str(data.get("filename") or data.get("path") or ""),
|
||||
@@ -663,6 +766,7 @@ __all__ = [
|
||||
"CommentInfo",
|
||||
"GitHubClient",
|
||||
"GitHubError",
|
||||
"IssueIndexEntry",
|
||||
"IssueInfo",
|
||||
"IssueSummary",
|
||||
"PullRequestFileInfo",
|
||||
@@ -671,5 +775,7 @@ __all__ = [
|
||||
"ReactionInfo",
|
||||
"RepoInfo",
|
||||
"ReviewCommentInfo",
|
||||
"index_entry_from_issue_object",
|
||||
"index_entry_from_pr_object",
|
||||
"parse_issue_payload",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user