🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@openhands/extensions

Package Overview
Dependencies
Maintainers
3
Versions
13
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@openhands/extensions - npm Package Compare versions

Comparing version
0.4.0
to
0.4.1
+98
skills/github-pr-reviewer/references/state-schema.md
# State File Schema
The automation maintains a JSON state file that persists across polling runs.
It is the source of truth for which PRs have been reviewed and which
conversations are still active.
---
## File Location
```
{WORKSPACE_BASE_ROOT}/automation-state/github_pr_reviewer_{automation_id}.json
```
`WORKSPACE_BASE_ROOT` is derived by going two levels up from the `WORKSPACE_BASE`
environment variable (stripping `automation-runs/{run_id}`).
Example on a local install:
```
~/.openhands/workspaces/automation-state/github_pr_reviewer_abc12345-….json
```
The `automation_id` is read from the `AUTOMATION_EVENT_PAYLOAD` environment
variable (field `automation_id`).
---
## Top-Level Schema
```jsonc
{
"version": 1, // schema version (integer)
"repo": "owner/repo", // the monitored repository
"conversations": { } // see ConversationRecord below
}
```
---
## `conversations` Map
Key: `"{pr_number}"` (string) — uniquely identifies a PR in the repo.
Value: **ConversationRecord**
```jsonc
{
// Always present
"pr_number": 42, // GitHub PR number (integer)
"html_url": "https://github.com/owner/repo/pull/42", // PR URL
"status": "active", // "active" | "closed" | "skipped"
"last_activity": 1717200000.0, // Unix timestamp of last state change
// Present when status is "active" or "closed"
"conversation_id": "550e8400-e29b-41d4-a716-446655440000",
// OpenHands conversation UUID
// Present when status is "skipped"
"reason": "diff too large (6000 lines)"
}
```
---
## Conversation Lifecycle
```
PR opened on GitHub
[active] ── conversation created, acknowledgement comment posted
▼ (on a later cron run, when conversation reaches terminal status)
[closed] ── review posted to PR as GitHub comment
▼ (if PR is merged/closed before review is ready)
[closed] ── no comment posted (silently closed)
── or ──
PR diff too large
[skipped] ── explanatory comment posted, PR never re-queued
```
---
## Resetting State
To force a re-review of all PRs (e.g. after changing the review tone):
1. Delete the state file:
```bash
rm ~/.openhands/workspaces/automation-state/github_pr_reviewer_<id>.json
```
2. The next cron run will treat all open PRs as new and queue reviews for each.
"""
GitHub PR Reviewer - OpenHands Automation Script
Polls a GitHub repository on a cron schedule. For each open pull request
that has not yet been reviewed it:
1. Fetches the PR metadata and diff.
2. Creates an OpenHands conversation with a targeted review prompt.
3. When the conversation completes, posts the AI review as a GitHub comment.
On subsequent runs:
- Already-reviewed PRs are skipped (tracked in the state file).
- Active conversations are checked for completion and results posted.
Configuration constants are embedded at automation-creation time by the skill.
See SKILL.md for the full setup workflow.
Required secret (set in OpenHands Settings → Secrets):
GITHUB_PERSONAL_ACCESS_TOKEN - Personal Access Token
Classic PAT: 'repo' scope (private) or 'public_repo' (public)
Fine-grained PAT: Pull requests: Read and Write
Optional secret:
OPENHANDS_URL - base URL for conversation links (default: http://localhost:8000)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlencode
# ── Embedded configuration (filled in by the skill at creation time) ──────────
REPO = "owner/repo" # e.g. "myorg/backend"
REVIEW_TONE = "thorough" # "thorough" | "concise" | "friendly"
REVIEW_STYLE_INSTRUCTIONS = "" # extra free-form persona / style notes
DEFAULT_OPENHANDS_URL = "http://localhost:8000"
# Max diff lines to include in the review prompt (avoids token overrun).
MAX_DIFF_LINES = 500
# PRs whose diff exceeds this many lines are skipped with an explanatory comment.
MAX_DIFF_LINES_SKIP = 5000
# Prevent posting summaries in the same run that created the conversation.
DONE_DEBOUNCE = 15
# ── Stdlib helpers ─────────────────────────────────────────────────────────────
def _get_env_key() -> str:
return (
os.environ.get("SESSION_API_KEY")
or os.environ.get("OH_SESSION_API_KEYS_0")
or ""
)
def get_secret(name: str) -> str:
"""Fetch a named secret from the agent server."""
url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
key = _get_env_key()
req = urllib.request.Request(
f"{url}/api/settings/secrets/{name}",
headers={"X-Session-API-Key": key},
)
with urllib.request.urlopen(req) as r:
return r.read().decode().strip()
def fire_callback(
status: str = "COMPLETED",
error: str | None = None,
conversation_id: str | None = None,
) -> None:
"""Signal run completion to the automation service."""
url = os.environ.get("AUTOMATION_CALLBACK_URL", "")
if not url:
return
body: dict = {"status": status, "run_id": os.environ.get("AUTOMATION_RUN_ID", "")}
if error:
body["error"] = error
if conversation_id:
body["conversation_id"] = conversation_id
req = urllib.request.Request(
url,
data=json.dumps(body).encode(),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}",
},
)
try:
urllib.request.urlopen(req)
except Exception as exc:
print(f"Callback error (non-fatal): {exc}")
# ── State management ───────────────────────────────────────────────────────────
def _state_file_path() -> str:
"""Derive a persistent storage path from WORKSPACE_BASE.
WORKSPACE_BASE = {root}/automation-runs/{run_id}
State lives two levels up at {root}/automation-state/.
"""
workspace_base = os.environ.get("WORKSPACE_BASE", "")
event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
automation_id = event_payload.get("automation_id", "default")
if workspace_base:
root = Path(workspace_base).resolve().parent.parent
else:
root = Path.home() / ".openhands" / "workspaces"
state_dir = root / "automation-state"
state_dir.mkdir(parents=True, exist_ok=True)
return str(state_dir / f"github_pr_reviewer_{automation_id}.json")
def load_state(path: str) -> dict:
if os.path.exists(path):
try:
with open(path) as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as exc:
print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
return {
"version": 1,
"repo": REPO,
"conversations": {}, # pr_number (str) → ConversationRecord
}
def save_state(path: str, state: dict) -> None:
with open(path, "w") as f:
json.dump(state, f, indent=2)
# ── GitHub API helpers ─────────────────────────────────────────────────────────
def _github_request(
token: str,
method: str,
path: str,
params: dict | None = None,
body: dict | None = None,
accept: str = "application/vnd.github+json",
) -> tuple:
"""Low-level GitHub API call. Returns (parsed_body, response_headers)."""
base = "https://api.github.com"
url = f"{base}{path}"
if params:
url = f"{url}?{urlencode(params)}"
headers = {
"Authorization": f"Bearer {token}",
"Accept": accept,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
}
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req) as r:
resp_headers = dict(r.headers)
raw = r.read()
if accept == "application/vnd.github.diff":
return raw.decode("utf-8", errors="replace"), resp_headers
return (json.loads(raw) if raw.strip() else {}), resp_headers
def _github_paginate(token: str, path: str, params: dict | None = None) -> list:
"""Fetch all pages from a GitHub list endpoint."""
results = []
page = 1
base_params = dict(params or {})
base_params.setdefault("per_page", 100)
while True:
base_params["page"] = page
data, _ = _github_request(token, "GET", path, params=base_params)
if not isinstance(data, list):
break
results.extend(data)
if len(data) < base_params["per_page"]:
break
page += 1
return results
def _resolve_github_token() -> str:
try:
token = get_secret("GITHUB_PERSONAL_ACCESS_TOKEN")
if token:
return token
except Exception:
pass
raise RuntimeError(
"GITHUB_PERSONAL_ACCESS_TOKEN secret is not set. "
"Go to OpenHands Settings → Secrets and add your GitHub Personal Access Token."
)
def _verify_token_and_repo(token: str, repo: str) -> str:
"""Verify token validity and repo access. Returns the authenticated GitHub username."""
try:
user_data, _ = _github_request(token, "GET", "/user")
except urllib.error.HTTPError as exc:
if exc.code == 401:
raise RuntimeError(
"GITHUB_PERSONAL_ACCESS_TOKEN is invalid or expired. "
"Update it in OpenHands Settings → Secrets."
)
raise RuntimeError(f"GitHub /user check failed: {exc.code}")
username: str = user_data.get("login", "?")
print(f"Authenticated as GitHub user: {username}")
try:
_github_request(token, "GET", f"/repos/{repo}")
except urllib.error.HTTPError as exc:
if exc.code == 404:
raise RuntimeError(
f"Repository '{repo}' not found or not accessible with the current token."
)
raise RuntimeError(f"GitHub /repos/{repo} check failed: {exc.code}")
print(f"Repository '{repo}' accessible.")
return username
def _list_open_prs(token: str, repo: str) -> list[dict]:
"""Fetch all open pull requests, oldest first."""
return _github_paginate(
token,
f"/repos/{repo}/pulls",
{"state": "open", "sort": "created", "direction": "asc"},
)
def _get_pr_diff(token: str, repo: str, pr_number: int) -> str:
"""Fetch the unified diff for a pull request."""
diff, _ = _github_request(
token, "GET", f"/repos/{repo}/pulls/{pr_number}",
accept="application/vnd.github.diff",
)
return diff
def _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> None:
"""Post a comment on a pull request."""
try:
_github_request(
token, "POST",
f"/repos/{repo}/issues/{pr_number}/comments",
body={"body": body},
)
except Exception as exc:
print(f" Warning: failed to post comment on PR #{pr_number}: {exc}")
# ── OpenHands conversation helpers ────────────────────────────────────────────
def _oh_request(
agent_url: str, api_key: str, method: str, path: str, body: dict | None = None
) -> dict:
url = f"{agent_url}{path}"
headers = {"X-Session-API-Key": api_key, "Content-Type": "application/json"}
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as r:
raw = r.read()
return json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as exc:
body_text = exc.read().decode()
raise RuntimeError(f"Agent API {method} {path} → {exc.code}: {body_text}") from exc
def _fetch_settings(agent_url: str, api_key: str) -> dict:
url = f"{agent_url}/api/settings"
headers = {"X-Session-API-Key": api_key, "X-Expose-Secrets": "plaintext"}
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def _get_agent_dict(agent_url: str, api_key: str) -> dict:
data = _fetch_settings(agent_url, api_key)
agent_settings = data.get("agent_settings", {})
llm = agent_settings.get("llm", {})
return {
"kind": "Agent",
"llm": llm,
"tools": [{"name": "terminal"}, {"name": "file_editor"}],
}
def _get_mcp_config(agent_url: str, api_key: str) -> dict | None:
try:
data = _fetch_settings(agent_url, api_key)
mcp_config = data.get("agent_settings", {}).get("mcp_config")
if isinstance(mcp_config, dict) and mcp_config.get("mcpServers"):
return mcp_config
except Exception as exc:
print(f"Warning: could not fetch MCP config: {exc}")
return None
def _list_secret_names(agent_url: str, api_key: str) -> list[dict]:
try:
result = _oh_request(agent_url, api_key, "GET", "/api/settings/secrets")
return result.get("secrets", [])
except Exception as exc:
print(f"Warning: could not list secrets: {exc}")
return []
def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
secrets_list = _list_secret_names(agent_url, api_key)
secrets: dict = {}
for secret in secrets_list:
name = secret.get("name", "")
if not name:
continue
lookup: dict = {
"kind": "LookupSecret",
"url": f"/api/settings/secrets/{name}",
}
if api_key:
lookup["headers"] = {"X-Session-API-Key": api_key}
desc = secret.get("description")
if desc:
lookup["description"] = desc
secrets[name] = lookup
return secrets
def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str:
"""Create an OpenHands conversation and return its ID."""
workspace_dir = os.environ.get("WORKSPACE_BASE", "/workspace")
agent = _get_agent_dict(agent_url, api_key)
payload: dict = {
"workspace": {"working_dir": workspace_dir},
"agent": agent,
"initial_message": {"content": [{"text": initial_message}]},
}
secrets = _build_secrets_payload(agent_url, api_key)
if secrets:
payload["secrets"] = secrets
mcp_config = _get_mcp_config(agent_url, api_key)
if mcp_config:
payload["mcp_config"] = mcp_config
result = _oh_request(agent_url, api_key, "POST", "/api/conversations", payload)
return result["id"]
def conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:
result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}")
return result.get("execution_status", "unknown")
def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:
result = _oh_request(
agent_url, api_key, "GET",
f"/api/conversations/{conv_id}/agent_final_response",
)
return result.get("response", "")
# ── Prompt building ────────────────────────────────────────────────────────────
_TONE_INSTRUCTIONS: dict[str, str] = {
"thorough": (
"Provide a comprehensive review. Cover correctness, security vulnerabilities, "
"missing or inadequate tests, code style, maintainability, and potential edge "
"cases. Reference specific files and line numbers where relevant."
),
"concise": (
"Provide a brief, high-signal review. Focus only on the most important issues — "
"bugs, security problems, or significant design flaws. Omit minor style feedback."
),
"friendly": (
"Provide a constructive, encouraging review. Acknowledge what is done well before "
"raising concerns. Be positive and supportive while still noting real issues."
),
}
def _build_review_prompt(pr: dict, diff: str, diff_truncated: bool) -> str:
number = pr.get("number", "?")
title = pr.get("title", "(no title)")
body = (pr.get("body") or "").strip() or "(no description)"
html_url = pr.get("html_url", "")
author = (pr.get("user") or {}).get("login", "?")
base_branch = (pr.get("base") or {}).get("ref", "?")
head_branch = (pr.get("head") or {}).get("ref", "?")
labels = [lb["name"] for lb in (pr.get("labels") or [])]
label_str = ", ".join(labels) if labels else "(none)"
changed_files = pr.get("changed_files", "?")
additions = pr.get("additions", "?")
deletions = pr.get("deletions", "?")
tone = _TONE_INSTRUCTIONS.get(REVIEW_TONE, _TONE_INSTRUCTIONS["thorough"])
extra = (
f"\n\nAdditional style instructions:\n{REVIEW_STYLE_INSTRUCTIONS}"
if REVIEW_STYLE_INSTRUCTIONS.strip()
else ""
)
truncation_note = (
f"\n\n⚠️ The diff below has been truncated to the first {MAX_DIFF_LINES} lines. "
"Review what is available and note that the full diff is larger."
) if diff_truncated else ""
return (
f"You are an AI code reviewer. Review the following GitHub pull request "
f"and write a review comment.\n\n"
f"Repository : {REPO}\n"
f"PR #{number}: \"{title}\"\n"
f"Author : @{author}\n"
f"Base → Head: {base_branch} ← {head_branch}\n"
f"Labels : {label_str}\n"
f"Changes : +{additions} -{deletions} across {changed_files} file(s)\n"
f"URL : {html_url}\n"
f"\nPR Description:\n---\n{body}\n---\n"
f"{truncation_note}"
f"\nDiff:\n```diff\n{diff}\n```\n"
f"\nReview instructions:\n{tone}{extra}\n\n"
f"Output ONLY the review text — no preamble, no meta-commentary. "
f"This text will be posted verbatim as a comment on the pull request.\n"
f"End your review with a clear verdict on its own line: "
f"either `✅ APPROVED` or `🔄 CHANGES REQUESTED`."
)
# ── Core logic ─────────────────────────────────────────────────────────────────
def _process_new_pr(
github_token: str,
agent_url: str,
api_key: str,
openhands_url: str,
pr: dict,
conversations: dict,
) -> str | None:
"""Start a review conversation for a PR not yet seen. Returns the conversation ID."""
number = pr["number"]
title = pr.get("title", "(no title)")
html_url = pr.get("html_url", "")
print(f" New PR #{number}: \"{title}\"")
try:
diff = _get_pr_diff(github_token, REPO, number)
except Exception as exc:
print(f" Warning: could not fetch diff for PR #{number}: {exc}")
return None
diff_lines = diff.splitlines()
if len(diff_lines) > MAX_DIFF_LINES_SKIP:
print(f" Skipping PR #{number}: diff too large ({len(diff_lines)} lines)")
conversations[str(number)] = {
"pr_number": number,
"html_url": html_url,
"status": "skipped",
"reason": f"diff too large ({len(diff_lines)} lines)",
"last_activity": time.time(),
}
_post_github_comment(
github_token, REPO, number,
f"⚠️ **OpenHands PR Reviewer**: This PR's diff is too large to review "
f"automatically ({len(diff_lines):,} lines). Consider splitting it into "
f"smaller PRs.\n\n_This message was posted by an AI agent (OpenHands)._",
)
return None
diff_truncated = len(diff_lines) > MAX_DIFF_LINES
if diff_truncated:
diff = "\n".join(diff_lines[:MAX_DIFF_LINES])
prompt = _build_review_prompt(pr, diff, diff_truncated)
try:
conv_id = create_conversation(agent_url, api_key, prompt)
except Exception as exc:
print(f" Error creating conversation for PR #{number}: {exc}")
return None
conversations[str(number)] = {
"pr_number": number,
"html_url": html_url,
"status": "active",
"conversation_id": conv_id,
"last_activity": time.time(),
}
print(f" Created review conversation {conv_id}")
conv_url = f"{openhands_url}/conversations/{conv_id}"
_post_github_comment(
github_token, REPO, number,
f"🤖 **OpenHands is reviewing this PR.**\n\n"
f"View the conversation: {conv_url}\n\n"
f"_This comment was posted by an AI agent (OpenHands)._",
)
return conv_id
def _check_conversation_completion(
rec: dict,
github_token: str,
agent_url: str,
api_key: str,
) -> None:
"""Post the review result and close the conversation record once it finishes."""
if (time.time() - rec.get("last_activity", 0.0)) < DONE_DEBOUNCE:
return
conv_id = rec["conversation_id"]
pr_number = rec["pr_number"]
try:
status = conversation_status(agent_url, api_key, conv_id)
except Exception as exc:
print(f" Warning: could not get status for {conv_id}: {exc}")
return
print(f" PR #{pr_number} conversation {conv_id} → status={status}")
if status not in ("idle", "finished", "error", "stuck"):
return
try:
final = conversation_final_response(agent_url, api_key, conv_id)
except Exception:
final = ""
if status in ("error", "stuck"):
comment_body = (
f"⚠️ **OpenHands PR Reviewer encountered a problem** (status: `{status}`).\n\n"
+ (f"{final}\n\n" if final else "")
+ "_This message was posted by an AI agent (OpenHands)._"
)
else:
comment_body = (
final if final
else (
"✅ **OpenHands completed the review.** (No review text was produced.)\n\n"
"_This message was posted by an AI agent (OpenHands)._"
)
)
_post_github_comment(github_token, REPO, pr_number, comment_body)
rec["status"] = "closed"
print(f" Posted review for PR #{pr_number}")
# ── Main ───────────────────────────────────────────────────────────────────────
def main() -> str | None:
"""Run one polling cycle. Returns the last conversation ID created, if any."""
state_path = _state_file_path()
state = load_state(state_path)
agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
api_key = _get_env_key()
github_token = _resolve_github_token()
_verify_token_and_repo(github_token, REPO)
try:
openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
except Exception:
openhands_url = DEFAULT_OPENHANDS_URL
conversations: dict = state.get("conversations", {})
try:
open_prs = _list_open_prs(github_token, REPO)
except Exception as exc:
raise RuntimeError(f"Failed to list open PRs for {REPO}: {exc}") from exc
print(f"Found {len(open_prs)} open PR(s) in {REPO}")
open_pr_numbers = {str(pr["number"]) for pr in open_prs}
last_conversation_id: str | None = None
# Queue reviews for PRs not yet in state.
for pr in open_prs:
key = str(pr["number"])
if key in conversations:
continue
conv_id = _process_new_pr(
github_token, agent_url, api_key, openhands_url, pr, conversations,
)
if conv_id:
last_conversation_id = conv_id
# Check active conversations for completion.
for key, rec in list(conversations.items()):
if rec.get("status") != "active":
continue
if key not in open_pr_numbers:
# PR was closed/merged before review completed — mark closed silently.
rec["status"] = "closed"
print(f" PR #{rec.get('pr_number')} closed/merged — skipping result post")
continue
_check_conversation_completion(rec, github_token, agent_url, api_key)
state["conversations"] = conversations
save_state(state_path, state)
print(f"State saved → {state_path}")
return last_conversation_id
if __name__ == "__main__":
try:
conversation_id = main()
fire_callback("COMPLETED", conversation_id=conversation_id)
except Exception as exc:
import traceback
traceback.print_exc()
fire_callback("FAILED", str(exc))
sys.exit(1)
+1
-1
{
".": "0.4.0"
".": "0.4.1"
}
{
"name": "@openhands/extensions",
"version": "0.4.0",
"version": "0.4.1",
"description": "Public OpenHands extension catalogs for skills, plugins, integrations, and automation templates.",

@@ -5,0 +5,0 @@ "license": "MIT",

[project]
name = "extensions"
version = "0.4.0"
version = "0.4.1"
description = "OpenHands extensions, plugins, and skills"

@@ -5,0 +5,0 @@ requires-python = ">=3.12"

@@ -14,4 +14,9 @@ ---

Set up a cron or event-triggered automation that reviews GitHub pull requests.
Create a cron automation that polls a GitHub repository, reviews each open
pull request exactly once, and posts the AI review as a GitHub comment.
The automation script is fully deterministic: PR discovery, state tracking,
and deduplication are handled in Python. The LLM is only invoked to write
the review text for PRs not yet seen, never for orchestration.
---

@@ -21,15 +26,19 @@

### Required integration
### Required secret
- **GitHub MCP** must be installed in Settings → MCP.
Verify that the following secret is set in **OpenHands Settings → Secrets**:
### Information to collect
| Secret name | Token type | Minimum permissions |
|---|---|---|
| `GITHUB_PERSONAL_ACCESS_TOKEN` | Classic PAT | `repo` (private) or `public_repo` (public) |
| `GITHUB_PERSONAL_ACCESS_TOKEN` | Fine-grained PAT | Pull requests: Read and Write |
Ask the user for:
Check with:
```bash
curl -s https://api.github.com/user \
-H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('login') or d.get('message'))"
```
1. **Repositories** — which repos should be watched (e.g. `myorg/backend`, `myorg/frontend`)
2. **Trigger type** — event-based (reacts to `pull_request.opened` / `pull_request.synchronize`) or cron-based polling
3. **Review tone** — thorough, concise, or friendly
4. **Auto-post or draft** — should reviews be posted as GitHub comments automatically, or saved as draft artifacts for human approval first?
5. **Filters** — any label, branch, or author filters (e.g. skip drafts, only review PRs with `needs-review` label)
If the token is missing or invalid, inform the user and stop.

@@ -40,53 +49,187 @@ ---

### Step 1 — Verify GitHub MCP access
Follow these steps in order.
Confirm the GitHub MCP integration is working:
### Step 1 — Verify GITHUB_PERSONAL_ACCESS_TOKEN
Run the `curl` check above.
- If absent: *"GITHUB_PERSONAL_ACCESS_TOKEN is not set. Please add it in
OpenHands Settings → Secrets."* Stop.
- If the API returns `{"message": "Bad credentials"}`: tell the user the
token is invalid and ask them to update it. Stop.
### Step 2 — Collect repository
Ask: *"Which GitHub repository should be monitored?
(Format: `owner/repo`, e.g. `myorg/backend`)"*
Validate access:
```bash
curl -s "https://api.github.com/repos/{owner}/{repo}" \
-H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \
| python3 -c "
import json, sys
d = json.load(sys.stdin)
if 'message' in d:
print('ERROR:', d['message'])
else:
print(f\"Accessible. Private: {d.get('private')}. Permissions: {d.get('permissions')}\")
"
```
Use the GitHub MCP to list recent pull requests in one of the target repositories.
Record `REPO = "{owner}/{repo}"`.
### Step 3 — Collect review tone
Ask: *"What review tone should the reviewer use?
1. Thorough (default) — comprehensive coverage of correctness, security, tests, style
2. Concise — high-signal only, skips minor style feedback
3. Friendly — constructive and encouraging
(Press Enter for Thorough, or type your choice or any custom style description)"*
Map the choice to `REVIEW_TONE`:
| Answer | `REVIEW_TONE` | `REVIEW_STYLE_INSTRUCTIONS` |
|--------|--------------|------------------------------|
| 1 / Enter | `"thorough"` | `""` |
| 2 | `"concise"` | `""` |
| 3 | `"friendly"` | `""` |
| Custom text (e.g. "hostile pirate") | `"thorough"` | the custom text verbatim |
### Step 4 — Collect cron schedule
Ask: *"How often should the automation poll for new PRs?
(Press Enter for the default: every 5 minutes.
Use a cron expression for a different interval, e.g. `0 * * * *` = hourly)"*
Default: `*/5 * * * *`.
Record as `CRON_SCHEDULE`.
### Step 5 — Generate the automation script
Read `scripts/main.py` from this skill's directory. Apply exactly four
constant substitutions near the top of the file:
| Placeholder | Replace with |
|---|---|
| `REPO = "owner/repo"` | `REPO = "{owner_repo}"` |
| `REVIEW_TONE = "thorough"` | `REVIEW_TONE = "{review_tone}"` |
| `REVIEW_STYLE_INSTRUCTIONS = ""` | `REVIEW_STYLE_INSTRUCTIONS = "{style_instructions}"` |
| `DEFAULT_OPENHANDS_URL = "http://localhost:8000"` | leave unchanged unless the user has a preference |
Write the customised script to a temporary build directory:
```bash
mkdir -p /tmp/pr-reviewer-build
# write the customised main.py to /tmp/pr-reviewer-build/main.py
```
If it fails, tell the user to install the GitHub MCP integration first.
Validate syntax before packaging:
```bash
python3 -m py_compile /tmp/pr-reviewer-build/main.py && echo "Syntax OK"
```
### Step 2 — Determine trigger type
Fix any syntax errors before proceeding.
**Event-based (recommended if publicly reachable):**
Check `<RUNTIME_SERVICES>` for deployment reachability. If the deployment has a public URL, recommend an event trigger on `pull_request.opened` and `pull_request.synchronize`.
### Step 6 — Package and upload
**Cron-based (local/private deployments):**
If the deployment is local, set up a cron schedule (e.g. every 5 minutes) that polls for recently updated PRs.
Determine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`
block in your system context:
- **OPENHANDS_HOST**: the Automation backend `url_from_agent`
- **Auth**: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`
### Step 3 — Build the review prompt
```bash
tar -czf /tmp/pr-reviewer.tar.gz -C /tmp/pr-reviewer-build .
Construct a prompt for the automation that includes:
- The user's chosen repositories
- Review focus areas (correctness, security, tests, maintainability)
- Review tone preference
- Whether to auto-post or draft
TARBALL_PATH=$(curl -s -X POST \
"${OPENHANDS_HOST}/api/automation/v1/uploads?name=github-pr-reviewer" \
-H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
-H "Content-Type: application/gzip" \
--data-binary @/tmp/pr-reviewer.tar.gz \
| python3 -c "import json,sys; print(json.load(sys.stdin)['tarball_path'])")
### Step 4 — Create the automation
echo "Uploaded: $TARBALL_PATH"
```
Read the Automation backend URL and auth from `<RUNTIME_SERVICES>`:
- Use the **Automation backend** `url_from_agent` as `OPENHANDS_HOST`
- Auth: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`
### Step 7 — Register the automation
Use the **prompt preset** endpoint:
```bash
curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/preset/prompt" \
curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1" \
-H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "GitHub PR Reviewer",
"prompt": "<constructed review prompt>",
"trigger": <trigger config from step 2>
}'
-d "{
\"name\": \"GitHub PR Reviewer: {owner}/{repo}\",
\"trigger\": {\"type\": \"cron\", \"schedule\": \"{cron_schedule}\"},
\"tarball_path\": \"$TARBALL_PATH\",
\"entrypoint\": \"python3 main.py\",
\"timeout\": 300
}" | python3 -m json.tool
```
### Step 5 — Confirm
Record the returned `id`.
### Step 8 — Confirm
Tell the user:
> ✅ **GitHub PR Reviewer** is running!
>
> - Automation ID: `{id}`
> - Repositories: `{repo list}`
> - Trigger: `{trigger description}`
> - Review style: `{tone}`, `{auto-post or draft}`
> - Repository: `{owner}/{repo}`
> - Review tone: `{tone}`
> - Polling schedule: `{cron_schedule}`
> - State file: `~/.openhands/workspaces/automation-state/github_pr_reviewer_{id}.json`
>
> The next cron run will discover all currently open PRs and queue reviews.
> Each PR is reviewed exactly once; state is stored in the JSON file above.
> To force a re-review of all PRs, delete the state file.
---
## Runtime Behaviour (per poll)
Each cron run executes `main.py`, which:
1. **Loads state** from the JSON file (see `references/state-schema.md`).
2. **Resolves and validates `GITHUB_PERSONAL_ACCESS_TOKEN`** — aborts
immediately if absent or invalid.
3. **Lists all open PRs** in the configured repository.
4. **For each PR not yet in state**:
- Fetches the unified diff via the GitHub API.
- Skips PRs whose diff exceeds `MAX_DIFF_LINES_SKIP` (default 5000 lines)
and posts an explanatory comment.
- Truncates diffs larger than `MAX_DIFF_LINES` (default 500 lines) and
notes this in the prompt.
- Creates an OpenHands conversation with the PR metadata, diff, and
configured tone instructions as the initial message.
- Posts an acknowledgement comment on the PR with a link to the conversation.
- Records the PR in state with `status: "active"`.
5. **For each active conversation**:
- Skips PRs that have been closed or merged (marks them closed silently).
- Checks the conversation's `execution_status`.
- When it reaches `idle`, `finished`, `error`, or `stuck`:
posts the agent's final response as a GitHub comment and marks the
conversation `closed`.
6. **Saves updated state** and fires the completion callback.
---
## Additional Resources
- **`references/state-schema.md`** — State JSON schema, field definitions,
and conversation lifecycle diagram.
- **`scripts/main.py`** — The complete automation script. Customise the
four constants at the top before packaging.
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Bot never posts reviews | `GITHUB_PERSONAL_ACCESS_TOKEN` missing or wrong scopes | Verify token; check Step 1 |
| "Bad credentials" in run logs | Token expired | Rotate and update the secret |
| 404 on repo access | Repo name wrong or no access | Re-check `owner/repo` and token permissions |
| Same PR reviewed twice | State file deleted or corrupted | Check that the state file path is stable across runs |
| Review never posted | Conversation stuck in `running` | Open the conversation in the OpenHands UI |
| PR skipped silently | Diff too large | Raise `MAX_DIFF_LINES_SKIP` in the script or split the PR |

@@ -193,2 +193,3 @@ ---

- [`52_dynamic_workflow.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/52_dynamic_workflow.py)
- [`53_client_defined_tools.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/53_client_defined_tools.py)

@@ -210,2 +211,4 @@ ### [`02_remote_agent_server/`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server)

- [`13_workspace_get_llm.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/13_workspace_get_llm.py)
- [`14_client_defined_tools.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/14_client_defined_tools.py)
- [`15_openai_compatible_gateway.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/15_openai_compatible_gateway.py)
- [`hook_scripts`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server/hook_scripts)

@@ -212,0 +215,0 @@ - [`scripts`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server/scripts)

Sorry, the diff of this file is too big to display