From ffc868ab26456805e39b13f00daed0737107af8c Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 9 Sep 2025 00:04:04 +0530 Subject: [PATCH] new example --- 02-agents/auto-pr-agent/.env.example | 7 + 02-agents/auto-pr-agent/Dockerfile | 40 +++ 02-agents/auto-pr-agent/README.md | 170 +++++++++++++ 02-agents/auto-pr-agent/requirements.txt | 5 + 02-agents/auto-pr-agent/tools/__init__.py | 0 02-agents/auto-pr-agent/tools/pr_tools.py | 277 +++++++++++++++++++++ 02-agents/auto-pr-agent/xpander_handler.py | 62 +++++ 7 files changed, 561 insertions(+) create mode 100644 02-agents/auto-pr-agent/.env.example create mode 100644 02-agents/auto-pr-agent/Dockerfile create mode 100644 02-agents/auto-pr-agent/README.md create mode 100644 02-agents/auto-pr-agent/requirements.txt create mode 100644 02-agents/auto-pr-agent/tools/__init__.py create mode 100644 02-agents/auto-pr-agent/tools/pr_tools.py create mode 100644 02-agents/auto-pr-agent/xpander_handler.py diff --git a/02-agents/auto-pr-agent/.env.example b/02-agents/auto-pr-agent/.env.example new file mode 100644 index 0000000..644b1ad --- /dev/null +++ b/02-agents/auto-pr-agent/.env.example @@ -0,0 +1,7 @@ +SLACK_BOT_TOKEN=xoxb-your-slack-bot-token +SLACK_CHANNEL_ID=your-slack-channel-id +GITHUB_TOKEN="your-github-token" +XPANDER_API_KEY="your-xpander-api-key" +XPANDER_ORGANIZATION_ID="your-xpander-organization-id" +XPANDER_AGENT_ID="your-xpander-agent-id" +OPENAI_API_KEY="your-openai-api-key" \ No newline at end of file diff --git a/02-agents/auto-pr-agent/Dockerfile b/02-agents/auto-pr-agent/Dockerfile new file mode 100644 index 0000000..4bc1156 --- /dev/null +++ b/02-agents/auto-pr-agent/Dockerfile @@ -0,0 +1,40 @@ +# Use Alpine-based Python image +FROM python:3.12-alpine AS builder + +# Set environment vars for venv +ENV VIRTUAL_ENV=/venv +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +ENV PYTHONUNBUFFERED=1 + +# Set working directory +WORKDIR /usr/src/app + +# Install dependencies +RUN apk add --no-cache \ + curl \ + bash \ + gcc \ + g++ \ + libffi-dev \ + musl-dev \ + openssl-dev \ + make \ + python3-dev \ + npm \ + rust \ + cargo \ + git \ + nodejs + +# Create and activate virtualenv +RUN python3 -m venv $VIRTUAL_ENV + +# Copy app files +COPY . . + +# Install Python dependencies +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Run your main.py script (adjust path if needed) +CMD ["python", "xpander_handler.py"] diff --git a/02-agents/auto-pr-agent/README.md b/02-agents/auto-pr-agent/README.md new file mode 100644 index 0000000..8fee151 --- /dev/null +++ b/02-agents/auto-pr-agent/README.md @@ -0,0 +1,170 @@ +

+ + + + xpander.ai Logo + +

+ +
+

Xpander.ai GitHub PR Review Agent

+ + +License PyPI Version NPM Version Platform Login + +
+ + + +An intelligent AI-powered assistant for automated GitHub pull request reviews, Slack-based summaries, and optional auto-merge, powered by Xpander SDK and Agno Framework. + + +## πŸš€ Features + +- Summary Generation: Auto-summarizes code changes file-by-file in a clean, structured format. +- Smart Scoring: Assigns a PR score based on heuristics like size, test coverage, and description quality. +- Action Recommendations: Flags missing test cases, large diffs, or policy violations with suggestions. +- Final Decisioning: Produces a clear "βœ… Approve" or "❌ Push Back" verdict. +- Slack Integration: Sends concise PR reports to Slack with action items and decision. +- Auto-Merge Support: Automatically merges approved PRs if no conflicts or branch rules block it. + + + +## 🧱 Project Structure + +``` +Xpander_Auto_PR/ +β”œβ”€β”€ tools/ +β”‚ └── pr_tools.py # PR review tools (GitHub REST + Slack) +β”œβ”€β”€ xpander_handler.py # Agent orchestrator (Agno + Xpander SDK) +β”œβ”€β”€ requirements.txt # Python dependencies +β”œβ”€β”€ Dockerfile # Container setup +└── .env # Environment variables (not committed) +``` + + +## Prerequisites + +- Python 3.10+ +- Access to the Xpander SDK (installed via requirements) +- GitHub token with repo:read and merge permissions +- Slack Bot token with chat:write and target channel ID +- OpenAI API key (for the Agno agent in `xpander_handler.py`) + + + +## Environment Configuration + +Create a `.env` file with the following keys: + +```env +GITHUB_TOKEN=ghp_xxx_or_fine_grained_token +SLACK_BOT_TOKEN=xoxb-xxx +SLACK_CHANNEL_ID=C0123456789 +OPENAI_API_KEY=sk-xxx +``` + +--- + +## Installation + +```bash +# Clone the repository + git clone + cd Xpander_Auto_PR + +# Install dependencies + pip install -r requirements.txt +``` + +--- + +## Usage + +### Run the Agent (streaming CLI / Xpander) + +Start the agent orchestrator that can call tools like `review_pr` and stream responses: + +```bash +python3 xpander_handler.py +``` + +> πŸ’‘ Use your own `xpander_config.json` and API credentials to avoid auth failures. + +--- + +### Run as a Docker Container + +```bash +# Build +docker build -t xpander-pr-review . + +# Provide env at runtime (recommended) and execute agent +docker run --rm \ + -e GITHUB_TOKEN \ + -e SLACK_BOT_TOKEN \ + -e SLACK_CHANNEL_ID \ + -e OPENAI_API_KEY \ + xpander-pr-review python3 xpander_handler.py +``` + +--- + +## Example Interactions + +``` +You: Review this PR β€” https://github.com/username/repo/pull/42 +Agent: PR #42 in username/repo β€” Score: 9/10 +Summary: 3 files changed, 120 additions, 10 deletions +Action Items: Add tests for new utility +Final Decision: βœ… Approve +Merge Status: Success +``` + +--- + +## Development Guide + +### Add a New Review Heuristic + +1. Update scoring in `tools/pr_tools.py` (function `score_pr_impl`): + + ```python + def score_pr_impl(pr): + score = 10 + if pr.additions > 500: + score -= 2 + if not pr.body: + score -= 1 + # Add your custom rules here + return max(score, 0) + ``` + +2. Update final decision logic if needed. + + +## πŸ“š API Reference + +- GitHub PR Parsing: Accepts PR URLs like `https://github.com/org/repo/pull/123`. Fetches PR metadata and files via GitHub REST (`httpx`). +- Slack Messaging: Sends a compact review summary to Slack using `chat.postMessage` with simple retry on rate limiting. +- Auto‑Merge: If the PR is approved (score β‰₯ 7), attempts a merge via the GitHub merge endpoint. + + + +## 🀝 Contributing + +1. Fork this repo +2. Create a new feature branch +3. Submit a pull request + + + +## πŸ“ License + +MIT License + + diff --git a/02-agents/auto-pr-agent/requirements.txt b/02-agents/auto-pr-agent/requirements.txt new file mode 100644 index 0000000..e2a87f3 --- /dev/null +++ b/02-agents/auto-pr-agent/requirements.txt @@ -0,0 +1,5 @@ +python-dotenv +agno[all] +xpander-sdk[agno] +httpx>=0.27 +openai \ No newline at end of file diff --git a/02-agents/auto-pr-agent/tools/__init__.py b/02-agents/auto-pr-agent/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/02-agents/auto-pr-agent/tools/pr_tools.py b/02-agents/auto-pr-agent/tools/pr_tools.py new file mode 100644 index 0000000..8170180 --- /dev/null +++ b/02-agents/auto-pr-agent/tools/pr_tools.py @@ -0,0 +1,277 @@ +import os +import asyncio +import httpx +from xpander_sdk import register_tool +from types import SimpleNamespace + + + + +# ------------------ PR Analysis Helpers ------------------ +def summarize_diff_impl(pr_files) -> str: + if not pr_files: + return "No files changed in this PR." + + summary_lines = [] + for f in pr_files: + filename = f.filename + additions = f.additions + deletions = f.deletions + changes = f.changes + patch = getattr(f, "patch", "") or "" + + summary_lines.append( + f"πŸ“„ **{filename}**\n" + f"- βž• {additions} additions\n" + f"- βž– {deletions} deletions\n" + f"- πŸ”„ {changes} total changes\n" + ) + if patch: + diff_preview = "\n".join(patch.splitlines()[:10]) + summary_lines.append(f"```diff\n{diff_preview}\n...```") + + summary_lines.append("") + + return "\n".join(summary_lines) + + +def score_pr_impl(pr): + score = 10 + if pr.additions > 500: + score -= 2 + if pr.deletions > pr.additions: + score -= 1 + if not pr.body: + score -= 1 + return max(score, 0) + + +def generate_action_items_impl(pr): + items = [] + if "fix" in pr.title.lower() and "test" not in pr.title.lower(): + items.append("Add test cases for the fix.") + if pr.additions > 1000: + items.append("Break the PR into smaller parts.") + return items + + +def final_decision_impl(score): + return "βœ… Approve" if score >= 7 else "❌ Push Back" + + +# ------------------ Tools ------------------ +@register_tool +async def send_slack_message(text: str) -> dict: + """ + Send a formatted message to a Slack channel. + Requires SLACK_BOT_TOKEN and SLACK_CHANNEL_ID in .env + """ + slack_token = os.getenv("SLACK_BOT_TOKEN") + slack_channel = os.getenv("SLACK_CHANNEL_ID") + + if not slack_token or not slack_channel: + return {"status": "error", "message": "Slack credentials not set in environment variables."} + + headers = { + "Authorization": f"Bearer {slack_token}", + "Content-Type": "application/json" + } + payload = { + "channel": slack_channel, + "text": text, + "unfurl_links": False + } + + try: + max_retries = 3 + async with httpx.AsyncClient(timeout=15) as client: + for attempt in range(max_retries): + response = await client.post( + "https://slack.com/api/chat.postMessage", + headers=headers, + json=payload, + ) + # Handle HTTP-level rate limiting + if response.status_code == 429: + retry_after = response.headers.get("Retry-After") + wait_s = int(retry_after) if retry_after and retry_after.isdigit() else (2 ** attempt) + await asyncio.sleep(wait_s) + continue + + result = response.json() + if result.get("ok"): + return {"status": "sent", "response": result} + + # Slack JSON-level rate limiting + if result.get("error") == "ratelimited" and attempt < max_retries - 1: + retry_after = response.headers.get("Retry-After") + wait_s = int(retry_after) if retry_after and retry_after.isdigit() else (2 ** attempt) + await asyncio.sleep(wait_s) + continue + + # Other errors: return immediately + return { + "status": "error", + "message": result.get("error") or "Slack API returned error", + "response": result, + "status_code": response.status_code, + } + + return { + "status": "error", + "message": "Slack API rate-limited or transient failure after retries", + } + except Exception as e: + return {"status": "error", "message": str(e)} + + +# Public tool wrappers for helper functions +@register_tool +async def summarize_diff(pr_files) -> str: + return summarize_diff_impl(pr_files) + + +@register_tool +async def score_pr(pr) -> int: + return score_pr_impl(pr) + + +@register_tool +async def generate_action_items(pr) -> list: + return generate_action_items_impl(pr) + + +@register_tool +async def final_decision(score: int) -> str: + return final_decision_impl(score) + + +# ------------------ Composite Tool: Review a PR ------------------ +def extract_repo_and_number(pr_url: str): + parts = pr_url.strip("/").split("/") + repo_name = "/".join(parts[-4:-2]) + pr_number = int(parts[-1]) + return repo_name, pr_number + + +@register_tool +async def review_pr(pr_url: str) -> dict: + """ + Analyze a GitHub PR: summarize diff, score, action items, final decision; + attempt auto-merge if approved; send Slack summary. + Requires GITHUB_TOKEN, SLACK_BOT_TOKEN, SLACK_CHANNEL_ID in env. + """ + github_token = os.getenv("GITHUB_TOKEN") + slack_token = os.getenv("SLACK_BOT_TOKEN") + slack_channel = os.getenv("SLACK_CHANNEL_ID") + + if not github_token or not slack_token or not slack_channel: + return { + "status": "error", + "message": "Missing GITHUB_TOKEN or Slack credentials in environment variables.", + } + + repo_name, pr_number = extract_repo_and_number(pr_url) + + headers = { + "Authorization": f"Bearer {github_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + async with httpx.AsyncClient(timeout=20) as client: + # Fetch PR metadata + pr_resp = await client.get(f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}", headers=headers) + if pr_resp.status_code >= 400: + return {"status": "error", "message": f"GitHub PR fetch failed: {pr_resp.status_code}", "response": pr_resp.text} + pr_json = pr_resp.json() + + # Fetch PR files + files_resp = await client.get( + f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}/files", + headers=headers, + params={"per_page": 300}, + ) + if files_resp.status_code >= 400: + return {"status": "error", "message": f"GitHub files fetch failed: {files_resp.status_code}", "response": files_resp.text} + files_json = files_resp.json() + + # Wrap JSON into simple objects + pr_obj = SimpleNamespace( + additions=pr_json.get("additions", 0), + deletions=pr_json.get("deletions", 0), + body=pr_json.get("body") or "", + title=pr_json.get("title") or "", + ) + pr_files_objs = [ + SimpleNamespace( + filename=f.get("filename"), + additions=f.get("additions", 0), + deletions=f.get("deletions", 0), + changes=f.get("changes", 0), + patch=f.get("patch") or "", + ) + for f in files_json + ] + + # βœ… Use the registered tools + summary = await summarize_diff(pr_files_objs) + score = await score_pr(pr_obj) + actions = await generate_action_items(pr_obj) + decision = await final_decision(score) + + # Attempt auto-merge if approved + merge_status = "Skipped" + if decision == "βœ… Approve": + try: + async with httpx.AsyncClient(timeout=20) as client: + merge_resp = await client.put( + f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}/merge", + headers=headers, + json={"commit_message": "Auto-merged by PR Review Agent βœ…"}, + ) + if merge_resp.status_code in (200, 201): + merge_status = "Success" + else: + if merge_resp.status_code == 403: + # Provide actionable guidance on permissions/scopes + xoauth = merge_resp.headers.get("X-OAuth-Scopes", "") + xaccepted = merge_resp.headers.get("X-Accepted-OAuth-Scopes", "") + guidance = ( + "GitHub returned 403 β€” token lacks permissions to merge. " + "Ensure the token has: Pull requests: write, Contents: write, Metadata: read, and explicit repo access (for fine-grained PATs). " + "Classic PATs should have 'repo' scope." + ) + scopes_info = f"X-OAuth-Scopes={xoauth} | X-Accepted-OAuth-Scopes={xaccepted}" if (xoauth or xaccepted) else "" + merge_status = ( + f"Failed: 403 Resource not accessible by token. {guidance} {scopes_info} " + f"Body: {merge_resp.text[:200]}" + ) + else: + merge_status = f"Failed: {merge_resp.status_code} {merge_resp.text[:200]}" + except Exception as e: + merge_status = f"Failed: {str(e)}" + + # Send Slack summary + action_lines = "\n- ".join(actions) if actions else "None" + slack_msg = ( + f"*πŸ” GitHub PR Review Summary*\n" + f"πŸ“¦ Repo: `{repo_name}`\n" + f"πŸ”’ PR: #{pr_number} β€” *{pr_json.get('title') or ''}*\n" + f"πŸ“Š Score: `{score}/10`\n" + f"πŸ“ Summary:\n{summary[:1000]}\n\n" + f"βš™οΈ Action Items:\n- {action_lines}\n\n" + f"βœ… Final Decision: *{decision}*\n" + f"πŸ” Merge Status: `{merge_status}`" + ) + + await send_slack_message(slack_msg) + + return { + "title": pr_json.get("title") or "", + "summary": summary, + "score": score, + "action_items": actions, + "final_decision": decision, + "merge_status": merge_status, + } diff --git a/02-agents/auto-pr-agent/xpander_handler.py b/02-agents/auto-pr-agent/xpander_handler.py new file mode 100644 index 0000000..432b4fa --- /dev/null +++ b/02-agents/auto-pr-agent/xpander_handler.py @@ -0,0 +1,62 @@ +import asyncio +from dotenv import load_dotenv +from xpander_sdk import Backend, Task, on_task +from agno.agent import Agent +from tools.pr_tools import send_slack_message, review_pr + +load_dotenv() + + +class MyAgent(Agent): + def __init__(self, backend: Backend, **kwargs): + super().__init__(**kwargs) + self.backend = backend + self.tools.extend([ + send_slack_message, + review_pr, + ]) + + async def run(self, message, user_id, session_id, cli=False): + if cli: + return await self.aprint_response( + message, + user_id=user_id, + session_id=session_id, + stream=True, + stream_intermediate_steps=True, + ) + else: + return await self.arun( + message, + user_id=user_id, + session_id=session_id + ) + + +@on_task +async def my_agent_handler(task: Task): + """ + Handles incoming Xpander tasks using MyAgent (subclass of Agent) + """ + backend = Backend(configuration=task.configuration) + agno_args = await backend.aget_args(task=task) + + my_agent = MyAgent(backend, **agno_args) + result = await my_agent.arun(message=task.to_message()) + + task.result = result.content + return task + +if __name__ == "__main__": + async def main(): + backend = Backend() + async with MyAgent(backend) as agent: + response = await orchestrator.run( + message="Review this PR for code quality issues: https://github.com/example/repo/pull/123", + user_id="cli-user", + session_id="cli-session", + cli=True, + ) + print("Response:", response) + + asyncio.run(main())