Files
oh-my-pi/python/robomp/src/tasks.py
T
can1357 4cd86383c0 feat(python/robomp): added followup thread context to comment handling
- Normalized reviewer-bot matching by stripping a trailing `[bot]` suffix when resolving configured bot logins.
- Fetched PR thread history for followup comments without directives and carried it through task execution.
- Updated followup prompt rendering to include prior conversation context for handle_comment tasks.
2026-05-25 20:11:03 +02:00

721 lines
26 KiB
Python

"""Task entry points dispatched off the durable event queue."""
from __future__ import annotations
import logging
from collections.abc import Mapping
from typing import Any
from robomp import persona
from robomp.config import Settings
from robomp.db import Database, IssueRow, IssueState, issue_key
from robomp.github_backend import GitHubBackend
from robomp.github_client import (
CommentInfo,
GitHubError,
IssueInfo,
PullRequestInfo,
RepoInfo,
parse_issue_payload,
)
from robomp.sandbox import GitTransport, SandboxManager
from robomp.worker import DirectiveInfo, TaskInputs, ThreadMessage, run_task
log = logging.getLogger(__name__)
def _comment_from_payload(payload: Mapping[str, Any]) -> CommentInfo:
c = payload.get("comment") or {}
user = c.get("user") or {}
return CommentInfo(
id=int(c.get("id") or 0),
author=str(user.get("login") or ""),
body=str(c.get("body") or ""),
created_at=str(c.get("created_at") or ""),
)
def _directive_from_payload(payload: Mapping[str, Any]) -> DirectiveInfo | None:
"""Extract the maintainer directive the webhook handler stashed, if any."""
raw = payload.get("_robomp_directive")
if not isinstance(raw, Mapping):
return None
body = raw.get("body")
author = raw.get("author")
if not isinstance(body, str) or not body.strip():
return None
if not isinstance(author, str) or not author.strip():
return None
pragmas: list[tuple[str, str]] = []
raw_pragmas = raw.get("pragmas")
if isinstance(raw_pragmas, list):
for entry in raw_pragmas:
if isinstance(entry, (list, tuple)) and len(entry) == 2:
k, v = entry
if isinstance(k, str) and isinstance(v, str):
pragmas.append((k, v))
return DirectiveInfo(body=body, author=author, pragmas=tuple(pragmas))
async def _fetch_thread(
github: GitHubBackend,
repo: str,
number: int,
*,
is_pr: bool,
) -> tuple[ThreadMessage, ...]:
"""Pull the full conversation thread (body + comments + reviews) for `number`.
Best-effort: any sub-fetch that fails is logged + dropped so a stale
review-comments endpoint doesn't block the directive from running.
"""
messages: list[ThreadMessage] = []
# 1. The issue / PR body itself. Use get_issue (issues endpoint also
# returns PRs in GitHub's data model).
try:
item = await github.get_issue(repo, number)
if item.body and item.body.strip():
messages.append(
ThreadMessage(
kind="pr_body" if is_pr else "issue_body",
author=item.author or "",
body=item.body,
created_at="", # not exposed by IssueInfo
)
)
except GitHubError as exc:
log.warning("thread body fetch failed", extra={"repo": repo, "n": number, "err": str(exc)})
# 2. Conversation comments (issue OR PR conversation).
try:
for c in await github.list_comments(repo, number):
messages.append(
ThreadMessage(
kind="comment",
author=c.author,
body=c.body,
created_at=c.created_at,
)
)
except GitHubError as exc:
log.warning("thread comments fetch failed", extra={"err": str(exc)})
if is_pr:
# 3. Inline review comments (attached to a path:line).
try:
for r in await github.list_review_comments(repo, number):
messages.append(
ThreadMessage(
kind="review_comment",
author=r.author,
body=r.body,
created_at=r.created_at,
path=r.path,
line=r.line,
)
)
except GitHubError as exc:
log.warning("thread review-comments fetch failed", extra={"err": str(exc)})
# 4. Top-level reviews (summaries).
try:
for rv in await github.list_pr_reviews(repo, number):
messages.append(
ThreadMessage(
kind="review",
author=rv.author,
body=rv.body,
created_at=rv.submitted_at,
state=rv.state,
)
)
except GitHubError as exc:
log.warning("thread reviews fetch failed", extra={"err": str(exc)})
# ISO 8601 strings sort chronologically. Body has no timestamp so it
# sorts first (empty string < any "2026-…" string).
messages.sort(key=lambda m: m.created_at or "")
return tuple(messages)
async def _attach_thread(
github: GitHubBackend,
directive: DirectiveInfo | None,
repo: str,
number: int,
*,
is_pr: bool,
) -> DirectiveInfo | None:
"""Hydrate a directive with the live conversation thread (or no-op if None)."""
if directive is None:
return None
thread = await _fetch_thread(github, repo, number, is_pr=is_pr)
return DirectiveInfo(body=directive.body, author=directive.author, thread=thread, pragmas=directive.pragmas)
async def _resolve_repo_and_issue(
github: GitHubBackend,
payload: Mapping[str, Any],
) -> tuple[RepoInfo, IssueInfo]:
repo, issue = parse_issue_payload(payload)
if not issue.body:
# Webhook payloads sometimes omit body; refetch to be safe.
try:
issue = await github.get_issue(repo.full_name, issue.number)
except GitHubError as exc:
log.warning("issue refetch failed", extra={"err": str(exc)})
return repo, issue
async def _resolve_issue_row_for_pr(
*,
db: Database,
github: GitHubBackend,
repo_full: str,
pr_number: int,
) -> tuple[IssueRow | None, PullRequestInfo | None]:
"""Find the originating issue row for a PR, repairing stale mappings when possible."""
issue_row = db.find_issue_by_pr(repo_full, pr_number)
pr_info: PullRequestInfo | None = None
if issue_row is None or issue_row.branch is None:
try:
pr_info = await github.get_pull_request(repo_full, pr_number)
except GitHubError as exc:
log.warning("PR metadata fetch failed", extra={"repo": repo_full, "pr": pr_number, "err": str(exc)})
return issue_row, None
if issue_row is None and pr_info is not None and pr_info.head_ref:
issue_row = db.find_issue_by_branch(repo_full, pr_info.head_ref)
if issue_row is not None:
db.set_issue_pr(issue_row.key, pr_number)
issue_row = db.get_issue(issue_row.key) or issue_row
elif issue_row is not None and issue_row.branch is None and pr_info is not None and pr_info.head_ref:
db.set_issue_branch(issue_row.key, pr_info.head_ref)
issue_row = db.get_issue(issue_row.key) or issue_row
return issue_row, pr_info
def _can_handle_pr_directly(*, settings: Settings, repo_full: str, pr: PullRequestInfo) -> bool:
"""Only bot-owned same-repo PR branches are safe to amend directly."""
if not pr.head_ref:
log.info("skip: PR has no head ref", extra={"repo": repo_full, "pr": pr.number})
return False
if pr.author.lower() != settings.bot_login.lower():
log.info(
"skip: unmapped PR not authored by bot",
extra={"repo": repo_full, "pr": pr.number, "author": pr.author},
)
return False
if pr.head_repo.lower() != repo_full.lower():
log.info(
"skip: unmapped PR head is not this repo",
extra={"repo": repo_full, "pr": pr.number, "head_repo": pr.head_repo},
)
return False
return True
async def triage_issue(
*,
settings: Settings,
db: Database,
github: GitHubBackend,
sandbox: SandboxManager,
git_transport: GitTransport,
payload: Mapping[str, Any],
delivery_id: str,
attempts: int = 0,
slot_uid: int | None = None,
) -> None:
repo, issue = await _resolve_repo_and_issue(github, payload)
if issue.is_pull_request:
log.info("skip: triage on PR-like issue", extra={"repo": repo.full_name, "n": issue.number})
return
key = issue_key(repo.full_name, issue.number)
if db.get_issue(key) is None:
# First-time triage: bail if a PR (human or another bot) already
# claims to close this issue via Closes/Fixes/Resolves syntax or
# the Development panel. We never replay closing-PR detection on
# a follow-up because by then the bot has already committed
# resources (workspace, omp session) to this issue.
try:
closing_prs = await github.list_closing_pull_requests(repo.full_name, issue.number)
except GitHubError as exc:
# Fail-open: a transient timeline fetch failure shouldn't
# block legitimate triage. Worst case we do redundant work.
log.warning(
"closing-PR check failed; proceeding with triage",
extra={"key": key, "err": str(exc)},
)
closing_prs = ()
if closing_prs:
log.info(
"skip: issue already covered by an open PR",
extra={"key": key, "prs": list(closing_prs)},
)
return
db.upsert_issue(key=key, repo=repo.full_name, number=issue.number, state="reproducing")
clone_url = repo.clone_url
workspace = sandbox.ensure_workspace(
repo=repo.full_name,
number=issue.number,
title=issue.title,
clone_url=clone_url,
default_branch=repo.default_branch,
author_name=settings.resolved_author_name,
author_email=settings.git_author_email,
slot_uid=slot_uid,
)
db.upsert_issue(
key=key,
repo=repo.full_name,
number=issue.number,
state="reproducing",
branch=workspace.branch,
session_dir=str(workspace.session_dir),
)
inputs = TaskInputs(
settings=settings,
db=db,
github=github,
git_transport=git_transport,
repo=repo,
issue=issue,
workspace=workspace,
delivery_id=delivery_id,
attempts=attempts,
slot_uid=slot_uid,
natives_cache=sandbox.natives_cache,
)
await run_task(task_kind="triage_issue", inputs=inputs)
async def handle_comment(
*,
settings: Settings,
db: Database,
github: GitHubBackend,
sandbox: SandboxManager,
git_transport: GitTransport,
payload: Mapping[str, Any],
delivery_id: str,
attempts: int = 0,
slot_uid: int | None = None,
) -> None:
repo, issue = await _resolve_repo_and_issue(github, payload)
key = issue_key(repo.full_name, issue.number)
existing = db.get_issue(key)
directive = _directive_from_payload(payload)
comment = _comment_from_payload(payload)
clone_url = repo.clone_url
if existing is None:
if directive is None:
log.info("skip: comment on unknown issue", extra={"key": key})
return
# Maintainer summon on an untriaged issue: bootstrap a row + workspace,
# then route through triage-with-directive so the agent classifies
# first and executes the directive in the same RPC turn.
log.info("directive bootstrap", extra={"key": key, "author": directive.author})
db.upsert_issue(key=key, repo=repo.full_name, number=issue.number, state="reproducing")
workspace = sandbox.ensure_workspace(
repo=repo.full_name,
number=issue.number,
title=issue.title,
clone_url=clone_url,
default_branch=repo.default_branch,
author_name=settings.resolved_author_name,
author_email=settings.git_author_email,
slot_uid=slot_uid,
)
db.upsert_issue(
key=key,
repo=repo.full_name,
number=issue.number,
state="reproducing",
branch=workspace.branch,
session_dir=str(workspace.session_dir),
)
inputs = TaskInputs(
settings=settings,
db=db,
github=github,
git_transport=git_transport,
repo=repo,
issue=issue,
workspace=workspace,
delivery_id=delivery_id,
attempts=attempts,
slot_uid=slot_uid,
natives_cache=sandbox.natives_cache,
)
directive = await _attach_thread(github, directive, repo.full_name, issue.number, is_pr=False)
await run_task(task_kind="triage_issue", inputs=inputs, directive=directive)
return
if existing.state in ("merged", "closed", "abandoned"):
if directive is None:
log.info("skip: comment on finalized issue", extra={"key": key, "state": existing.state})
try:
await github.post_comment(
repo.full_name,
issue.number,
persona.finalized_issue_comment(),
)
except GitHubError as exc:
log.warning("ack comment failed", extra={"err": str(exc)})
return
# Maintainer reopen: tear down stale workspace, reset state, branch
# afresh from default. The old branch may have been merged/deleted.
log.info("directive reopen", extra={"key": key, "from_state": existing.state, "author": directive.author})
sandbox.remove_workspace(repo=repo.full_name, number=issue.number)
db.upsert_issue(key=key, repo=repo.full_name, number=issue.number, state="reproducing")
workspace = sandbox.ensure_workspace(
repo=repo.full_name,
number=issue.number,
title=issue.title,
clone_url=clone_url,
default_branch=repo.default_branch,
author_name=settings.resolved_author_name,
author_email=settings.git_author_email,
slot_uid=slot_uid,
)
db.upsert_issue(
key=key,
repo=repo.full_name,
number=issue.number,
state="reproducing",
branch=workspace.branch,
session_dir=str(workspace.session_dir),
)
inputs = TaskInputs(
settings=settings,
db=db,
github=github,
git_transport=git_transport,
repo=repo,
issue=issue,
workspace=workspace,
delivery_id=delivery_id,
attempts=attempts,
slot_uid=slot_uid,
natives_cache=sandbox.natives_cache,
)
directive = await _attach_thread(github, directive, repo.full_name, issue.number, is_pr=False)
await run_task(task_kind="handle_comment", inputs=inputs, comment=comment, directive=directive)
return
workspace = sandbox.ensure_workspace(
repo=repo.full_name,
number=issue.number,
title=issue.title,
clone_url=clone_url,
default_branch=repo.default_branch,
existing_branch=existing.branch,
author_name=settings.resolved_author_name,
author_email=settings.git_author_email,
slot_uid=slot_uid,
)
inputs = TaskInputs(
settings=settings,
db=db,
github=github,
git_transport=git_transport,
repo=repo,
issue=issue,
workspace=workspace,
delivery_id=delivery_id,
attempts=attempts,
slot_uid=slot_uid,
natives_cache=sandbox.natives_cache,
)
directive = await _attach_thread(github, directive, repo.full_name, issue.number, is_pr=False)
await run_task(task_kind="handle_comment", inputs=inputs, comment=comment, directive=directive)
async def handle_review(
*,
settings: Settings,
db: Database,
github: GitHubBackend,
sandbox: SandboxManager,
git_transport: GitTransport,
payload: Mapping[str, Any],
delivery_id: str,
attempts: int = 0,
slot_uid: int | None = None,
) -> None:
pr = payload.get("pull_request") or {}
pr_number = int(pr.get("number") or 0)
if pr_number <= 0:
log.info("skip: review without PR number")
return
repo_payload = payload.get("repository") or {}
repo_full = str(repo_payload.get("full_name") or "")
if not repo_full:
log.info("skip: review without repo")
return
issue_row, pr_info = await _resolve_issue_row_for_pr(
db=db,
github=github,
repo_full=repo_full,
pr_number=pr_number,
)
if issue_row is None:
if pr_info is None or not _can_handle_pr_directly(settings=settings, repo_full=repo_full, pr=pr_info):
return
issue_number = pr_number
existing_branch = pr_info.head_ref
else:
if issue_row.branch is None:
log.info("skip: review PR missing branch mapping", extra={"repo": repo_full, "pr": pr_number})
return
issue_number = issue_row.number
existing_branch = issue_row.branch
try:
repo = await github.get_repo(repo_full)
issue = await github.get_issue(repo_full, issue_number)
except GitHubError as exc:
log.warning("review fetch failed", extra={"err": str(exc)})
return
clone_url = repo.clone_url
workspace = sandbox.ensure_workspace(
repo=repo.full_name,
number=issue.number,
title=issue.title,
clone_url=clone_url,
default_branch=repo.default_branch,
existing_branch=existing_branch,
author_name=settings.resolved_author_name,
author_email=settings.git_author_email,
slot_uid=slot_uid,
)
if issue_row is None:
db.upsert_issue(
key=issue_key(repo_full, pr_number),
repo=repo_full,
number=pr_number,
state="opened",
branch=workspace.branch,
session_dir=str(workspace.session_dir),
pr_number=pr_number,
)
comment = payload.get("comment") or {}
user = comment.get("user") or {}
review_payload = {
"author": str(user.get("login") or ""),
"body": str(comment.get("body") or ""),
"path": str(comment.get("path") or ""),
"line": comment.get("line"),
"start_line": comment.get("start_line"),
"original_line": comment.get("original_line"),
}
inputs = TaskInputs(
settings=settings,
db=db,
github=github,
git_transport=git_transport,
repo=repo,
issue=issue,
workspace=workspace,
delivery_id=delivery_id,
attempts=attempts,
slot_uid=slot_uid,
natives_cache=sandbox.natives_cache,
)
await run_task(
task_kind="handle_review",
inputs=inputs,
pr_number=pr_number,
review_payload=review_payload,
)
async def handle_pr_conversation(
*,
settings: Settings,
db: Database,
github: GitHubBackend,
sandbox: SandboxManager,
git_transport: GitTransport,
payload: Mapping[str, Any],
delivery_id: str,
attempts: int = 0,
slot_uid: int | None = None,
) -> None:
"""Handle a regular (non-review) comment on a bot-authored PR.
The `issue_comment.created` payload's `issue.number` IS the PR number on
these events; we resolve back to the originating issue via the DB and
drive `handle_comment` so the agent works on the same session/branch.
"""
repo_payload = payload.get("repository") or {}
repo_full = str(repo_payload.get("full_name") or "")
issue_payload = payload.get("issue") or {}
pr_number = issue_payload.get("number")
if not repo_full or not isinstance(pr_number, int):
log.info("skip: pr-conversation missing repo/number")
return
issue_row, pr_info = await _resolve_issue_row_for_pr(
db=db,
github=github,
repo_full=repo_full,
pr_number=pr_number,
)
if issue_row is None:
if pr_info is None or not _can_handle_pr_directly(settings=settings, repo_full=repo_full, pr=pr_info):
return
directive = _directive_from_payload(payload)
if issue_row is not None and issue_row.state in ("merged", "closed", "abandoned"):
if directive is None:
log.info("skip: pr-conversation on finalized issue", extra={"key": issue_row.key, "state": issue_row.state})
# Still acknowledge so the reporter knows the bot saw it.
try:
await github.post_comment(
repo_full,
pr_number,
persona.finalized_pr_comment(),
)
except GitHubError as exc:
log.warning("ack comment failed", extra={"err": str(exc)})
return
# Maintainer reopen on a finalized PR: tear down stale workspace and
# branch afresh on the originating issue. The agent will open a new
# PR if code changes ship.
log.info(
"directive reopen (pr)",
extra={"key": issue_row.key, "from_state": issue_row.state, "author": directive.author},
)
sandbox.remove_workspace(repo=issue_row.repo, number=issue_row.number)
db.upsert_issue(key=issue_row.key, repo=issue_row.repo, number=issue_row.number, state="reproducing")
issue_row = db.get_issue(issue_row.key) or issue_row
# Bare @mention with no request body — the route stashes an empty
# _robomp_directive; _directive_from_payload rejects it but the key
# being present tells us a mention happened. Reply cheaply without omp.
if directive is None and payload.get("_robomp_directive") is not None:
comment = _comment_from_payload(payload)
log.info(
"bare mention, prompting for request", extra={"repo": repo_full, "pr": pr_number, "author": comment.author}
)
try:
await github.post_comment(repo_full, pr_number, persona.bare_mention_reply())
except GitHubError as exc:
log.warning("bare mention reply failed", extra={"err": str(exc)})
return
issue_number = issue_row.number if issue_row is not None else pr_number
try:
repo = await github.get_repo(repo_full)
issue = await github.get_issue(repo_full, issue_number)
except GitHubError as exc:
log.warning("pr-conversation fetch failed", extra={"err": str(exc)})
return
clone_url = repo.clone_url
if issue_row is None:
assert pr_info is not None
existing_branch = pr_info.head_ref
else:
# On a reopen the prior branch is stale (merged/deleted), so branch from
# default; otherwise reuse the existing branch.
existing_branch = (
None if directive and issue_row.state == "reproducing" and issue_row.branch is None else issue_row.branch
)
if existing_branch is None and not (directive and issue_row.state == "reproducing"):
log.info("skip: pr-conversation PR missing branch mapping", extra={"repo": repo_full, "pr": pr_number})
return
workspace = sandbox.ensure_workspace(
repo=repo.full_name,
number=issue.number,
title=issue.title,
clone_url=clone_url,
default_branch=repo.default_branch,
existing_branch=existing_branch,
author_name=settings.resolved_author_name,
author_email=settings.git_author_email,
slot_uid=slot_uid,
)
if issue_row is None:
db.upsert_issue(
key=issue_key(repo_full, pr_number),
repo=repo_full,
number=pr_number,
state="opened",
branch=workspace.branch,
session_dir=str(workspace.session_dir),
pr_number=pr_number,
)
elif directive is not None and (issue_row.branch is None or issue_row.branch != workspace.branch):
db.upsert_issue(
key=issue_row.key,
repo=issue_row.repo,
number=issue_row.number,
state="reproducing",
branch=workspace.branch,
session_dir=str(workspace.session_dir),
)
comment = _comment_from_payload(payload)
inputs = TaskInputs(
settings=settings,
db=db,
github=github,
git_transport=git_transport,
repo=repo,
issue=issue,
workspace=workspace,
delivery_id=delivery_id,
attempts=attempts,
slot_uid=slot_uid,
natives_cache=sandbox.natives_cache,
)
thread: tuple[ThreadMessage, ...] = ()
if directive is None:
thread = await _fetch_thread(github, repo_full, pr_number, is_pr=True)
else:
directive = await _attach_thread(github, directive, repo_full, pr_number, is_pr=True)
await run_task(
task_kind="handle_comment",
inputs=inputs,
comment=comment,
pr_number=pr_number,
directive=directive,
thread=thread,
)
async def cleanup_workspace(
*,
settings: Settings,
db: Database,
sandbox: SandboxManager,
payload: Mapping[str, Any],
target_state: IssueState,
) -> None:
"""Tear down the workspace for a finished issue/PR."""
repo_payload = payload.get("repository") or {}
repo_full = str(repo_payload.get("full_name") or "")
if not repo_full:
return
issue_payload = payload.get("issue") or payload.get("pull_request") or {}
number = issue_payload.get("number")
if not isinstance(number, int):
return
# If this is a PR close, map to the originating issue.
issue_row: IssueRow | None
if "pull_request" in payload:
issue_row = db.find_issue_by_pr(repo_full, number)
else:
issue_row = db.get_issue(issue_key(repo_full, number))
if issue_row is None:
return
sandbox.remove_workspace(repo=issue_row.repo, number=issue_row.number)
db.set_issue_state(issue_row.key, target_state)
log.info("cleanup", extra={"key": issue_row.key, "state": target_state})
__all__ = [
"cleanup_workspace",
"handle_comment",
"handle_pr_conversation",
"handle_review",
"triage_issue",
]