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 GitHub PR Review Agent
+
+

+

+
+
+
+
+
+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())