""" GitHub issue capabilities for autopilot agents. Provides: - GitHubIssuesCapability: base capability with three tools for reading and writing issues — get_issue_body, list_issue_comments, comment_on_issue. - DiagnoseIssuesCapability: extends base with after_output_process for the diagnose agent. Module-level helper used by after_output_process: add_comment """ from __future__ import annotations import asyncio import json import subprocess from dataclasses import dataclass from typing import Any from pydantic import BaseModel from pydantic_ai import RunContext from pydantic_ai.output import OutputContext from pydantic_ai.toolsets import AgentToolset, FunctionToolset from capabilities.base import CapabilityWithTools from config import REPO # ── Return types ───────────────────────────────────────────────────────────────── class IssueComment(BaseModel): """A single comment on a GitHub issue.""" author: str body: str createdAt: str # ── Module-level helpers ───────────────────────────────────────────────────────── def add_comment(repo: str, issue_number: int, body: str) -> str: """Post a markdown comment on a GitHub issue. Returns the comment URL.""" cmd = ["gh", "issue", "comment", str(issue_number), "--repo", repo, "--body", body] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: return f"ERROR: {result.stderr.strip()}" return result.stdout.strip() or "Comment added." # ── Toolset functions ──────────────────────────────────────────────────────────── def get_issue_body(number: int, repo: str = "") -> str: """Get the body text of a single GitHub issue. Args-> number: Issue number. repo: GitHub repository in 'owner/repo' format (optional, defaults to current repo). """ cmd = ["gh", "issue", "view", str(number), "--json", "body"] if repo: cmd += ["--repo", repo] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: return f"ERROR: {result.stderr.strip()}" data: dict[str, Any] = json.loads(result.stdout) return data.get("body") or "" def list_issue_comments(number: int, repo: str = "") -> list[IssueComment]: """Return all comments on a GitHub issue as a list of dicts. Each entry has keys: "author", "body", "createdAt". Access with comment["author"], comment["body"], comment["createdAt"]. Args-> number: Issue number. repo: GitHub repository in 'owner/repo' format (optional, defaults to current repo). """ cmd = ["gh", "issue", "view", str(number), "--json", "comments"] if repo: cmd += ["--repo", repo] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: return [] data: dict[str, Any] = json.loads(result.stdout) out: list[IssueComment] = [] for c in data.get("comments", []): author_raw = c.get("author", "") if isinstance(author_raw, dict): author = str(author_raw.get("login", "")) else: author = str(author_raw) out.append( IssueComment( author=author, body=str(c.get("body", "")), createdAt=str(c.get("createdAt", "")), ) ) return out def comment_on_issue(number: int, body: str, repo: str = "") -> str: """Add a comment to an existing GitHub issue. Args-> number: Issue number. body: Comment text (markdown). repo: GitHub repository in 'owner/repo' format (optional, defaults to current repo). """ cmd = ["gh", "issue", "comment", str(number), "--body", body] if repo: cmd += ["--repo", repo] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: return f"ERROR: {result.stderr.strip()}" return result.stdout.strip() or "Comment added." # ── Capability classes ─────────────────────────────────────────────────────────── @dataclass class GitHubIssuesCapability(CapabilityWithTools): """Base capability for reading and writing GitHub issues via the gh CLI. Exposes three tools: - get_issue_body: fetch the body text of one issue. - list_issue_comments: fetch all comments as structured dicts. - comment_on_issue: post a comment on an issue. """ def get_toolset(self) -> AgentToolset[Any] | None: return FunctionToolset( [ get_issue_body, list_issue_comments, comment_on_issue, ] ) def _instructions_header(self) -> str: return """\ ## GitHub Issue Functions Call these as `await function_name(...)` inside `run_code`. Use these functions to read the issue, inspect comments, and post interim updates. Use `asyncio.gather()` for `get_issue_body` and `list_issue_comments` in parallel.\ """ @dataclass class DiagnoseIssuesCapability(GitHubIssuesCapability): """Issues capability for the diagnose agent. Same three read/write tools as GitHubIssuesCapability. Adds after_output_process to post the final AgentIssueUpdate as a GitHub issue comment. """ async def after_output_process( self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any, ) -> Any: """Post the final AgentIssueUpdate as a GitHub issue comment.""" from agent_output import AgentIssueUpdate if ctx.partial_output or not isinstance(output, AgentIssueUpdate): return output if output.autopilot_status is None: body = output.body elif output.autopilot_status == "done" and output.conclusion: body = ( f"\n{output.body}" ) else: body = f"\n{output.body}" await asyncio.to_thread(add_comment, REPO, output.issue_number, body) return output