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

@openhands/extensions

Package Overview
Dependencies
Maintainers
3
Versions
10
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.1
to
0.4.2
+1
-1
.release-please-manifest.json
{
".": "0.4.1"
".": "0.4.2"
}

@@ -5,3 +5,3 @@ {

"category": "Code review",
"description": "Watch pull requests, inspect the diff, and leave a concise review with risks and suggested follow-ups.",
"description": "Watch for a configurable label on GitHub pull requests, inspect full PR and repository context, and post an AI review comment once per label event.",
"requiredIntegrationIds": [

@@ -13,3 +13,3 @@ "github"

"prompt": "/pr-reviewer:setup",
"exampleImplementation": "Trigger: github.pull_request.opened and github.pull_request.synchronize\nRequired MCP: GitHub\n\n1. Read repository, pull_request, and sender from the event payload.\n2. Fetch the PR diff and related comments through the GitHub MCP.\n3. Run a review rubric focused on bugs, security, tests, and maintainability.\n4. TODO: decide whether to publish inline comments, a summary comment, or a draft artifact.\n5. Post or save the review with links back to the exact files and lines."
"exampleImplementation": "Trigger: cron polling for open GitHub PRs with a configured label such as openhands-review\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN\n\n1. Read repository, trigger label, review tone, and polling schedule from setup.\n2. List open PRs and find the latest matching GitHub labeled issue event for each labeled PR.\n3. Deduplicate on the label event ID so every label application queues exactly one review.\n4. Start an OpenHands conversation that clones the repo, checks out the exact PR head SHA, and inspects PR discussion, review comments, changed files, diff, and surrounding code.\n5. Post an acknowledgement with the conversation link, then post the final AI review comment only if the PR is still open and the head SHA has not changed."
}
{
"name": "@openhands/extensions",
"version": "0.4.1",
"version": "0.4.2",
"description": "Public OpenHands extension catalogs for skills, plugins, integrations, and automation templates.",

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

@@ -163,3 +163,3 @@ ---

timeout "${TIMEOUT_SECONDS}" \
uv run --no-project --with openhands-sdk --with openhands-tools --with lmnr \
uv run --no-project --with openhands-sdk --with openhands-tools --with 'lmnr<0.7.53' \
python ../extensions/plugins/qa-changes/scripts/agent_script.py

@@ -166,0 +166,0 @@

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

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

{
"name": "github-pr-reviewer",
"version": "1.0.0",
"description": "Create an automation that reviews GitHub pull requests when they are opened or updated. Inspects the diff, changed files, tests, and existing discussion via GitHub MCP, then posts a concise review highlighting risks, security issues, missing tests, and next steps.",
"description": "Create an automation that reviews GitHub pull requests when a configurable trigger label is applied. It starts one OpenHands review conversation per label event, inspects full PR and repository context, and posts the final review comment back to GitHub.",
"author": {

@@ -6,0 +6,0 @@ "name": "OpenHands",

---
# auto-generated by sync_extensions.py
description: Create an automation that reviews GitHub pull requests when they are opened or updated. Inspects the diff, changed files, tests, and existing discussion via GitHub MCP, then posts a concise review highlighting risks, security issues, missing tests, and next steps.
description: Create an automation that reviews GitHub pull requests when a configurable trigger label is applied. Polls GitHub deterministically, starts one OpenHands review conversation per label event, inspects full repository and PR context, and posts the final review comment back to GitHub.
---

@@ -5,0 +5,0 @@

# GitHub PR Reviewer
Create an automation that reviews GitHub pull requests on open or update.
Create an automation that reviews GitHub pull requests when a configurable
trigger label is applied.
## Triggers
## Trigger
This skill is activated by keywords:
This skill is activated by:
- `review pull requests`
- `PR review automation`
- `auto-review PRs`
- `/pr-reviewer:setup`
## Features
- **Inspects PR diff, changed files, and test coverage**
- **Posts review with correctness risks, security issues, missing tests**
- **Supports event-based (webhook) or cron-based (polling) triggers**
- **Configurable review tone (thorough, concise, friendly)**
- **Auto-post or draft mode for human approval**
- Reviews PRs on demand by watching for a GitHub label event
- Processes each label application exactly once, with persistent state
- Re-review support by removing and re-applying the label
- Suppresses stale reviews when the PR head commit changes mid-review
- Uses a real cloned checkout and full PR context instead of only a truncated diff
- Posts acknowledgement and final review comments with AI disclosure
- Configurable review tone and polling schedule
## Prerequisites
GitHub MCP installed in Settings → MCP
Set `GITHUB_PERSONAL_ACCESS_TOKEN` in OpenHands Settings -> Secrets. The token
must be able to read the repository, read pull requests, read issue events, and
write issue comments.

@@ -29,7 +32,10 @@ ## Quick Start

> "Set up a PR review automation for my myorg/backend repo that posts
> concise reviews when PRs are opened"
> "Set up a PR review automation for my `myorg/backend` repo using the
> `openhands-review` label and concise reviews."
After setup, apply the configured label to a pull request to queue a review. To
request another review later, remove and re-apply the label.
## See Also
- [SKILL.md](SKILL.md) — Full setup workflow reference
- [SKILL.md](SKILL.md) - Full setup workflow reference
# 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.
It is the source of truth for which trigger-label events have queued reviews
and which conversations are still active.

@@ -12,7 +12,7 @@ ---

```
{WORKSPACE_BASE_ROOT}/automation-state/github_pr_reviewer_{automation_id}.json
{WORKSPACE_BASE_ROOT}/automation-state/github_pr_reviewer_label_event_{automation_id}.json
```
`WORKSPACE_BASE_ROOT` is derived by going two levels up from the `WORKSPACE_BASE`
environment variable (stripping `automation-runs/{run_id}`).
environment variable, stripping `automation-runs/{run_id}`.

@@ -22,7 +22,7 @@ Example on a local install:

```
~/.openhands/workspaces/automation-state/github_pr_reviewer_abc12345-….json
~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_abc12345-....json
```
The `automation_id` is read from the `AUTOMATION_EVENT_PAYLOAD` environment
variable (field `automation_id`).
variable, field `automation_id`.

@@ -35,5 +35,8 @@ ---

{
"version": 1, // schema version (integer)
"repo": "owner/repo", // the monitored repository
"conversations": { } // see ConversationRecord below
"version": 2,
"repo": "owner/repo",
"trigger_label": "openhands-review",
"updated_at": 1717200000.0,
"reviews": {},
"prs": {}
}

@@ -44,48 +47,71 @@ ```

## `conversations` Map
## `reviews` Map
Key: `"{pr_number}"` (string) — uniquely identifies a PR in the repo.
Key: `"{pr_number}:label:{label_event_id}"`. This makes the latest GitHub
`labeled` event the idempotency key. Re-applying the trigger label creates a new
GitHub event and therefore a new review request.
Value: **ConversationRecord**
Value: **ReviewRecord**
```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"
"pr_number": 42,
"head_sha": "0123456789abcdef...",
"trigger_label_event_id": 123456789,
"trigger_label_event_created_at": "2026-06-12T00:00:00Z",
"html_url": "https://github.com/owner/repo/pull/42",
"status": "active",
"conversation_id": "550e8400-e29b-41d4-a716-446655440000",
// OpenHands conversation UUID
// Present when status is "skipped"
"reason": "diff too large (6000 lines)"
"last_activity": 1717200000.0
}
```
`status` values:
| Status | Meaning |
|---|---|
| `active` | Review conversation is running or waiting to be collected |
| `closed` | Final result was posted, or the PR closed before collection |
| `stale` | PR head SHA changed before the review completed, so the result was suppressed |
When a review becomes stale, `stale_reason` records the old and new head SHAs.
When a review closes after posting, `completed_at` records the completion time.
---
## Conversation Lifecycle
## `prs` Map
Key: `"{pr_number}"`.
Value: latest PR snapshot observed during polling:
```jsonc
{
"head_sha": "0123456789abcdef...",
"label_present": true,
"labels": ["openhands-review", "bug"],
"last_seen": 1717200000.0
}
```
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 ──
This snapshot is informational and helps diagnose whether a PR was skipped
because the trigger label was absent.
PR diff too large
[skipped] ── explanatory comment posted, PR never re-queued
---
## Review Lifecycle
```
Trigger label applied on GitHub
|
v
[active] - conversation created, acknowledgement comment posted
|
+-- PR closes/merges before collection --> [closed] without posting
|
+-- PR head SHA changes before collection --> [stale] without posting
|
v
[closed] - final review comment posted
```

@@ -96,8 +122,10 @@ ---

To force a re-review of all PRs (e.g. after changing the review tone):
To force the automation to reconsider previous label events, delete the state
file:
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.
```bash
rm ~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_<id>.json
```
Usually, prefer removing and re-applying the trigger label. That preserves
history while creating a new review request.
"""
GitHub PR Reviewer - OpenHands Automation Script
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)
Cron-polls a GitHub repository for open pull requests carrying the configured
trigger label. A review is queued only when the latest matching GitHub `labeled`
event has not already been processed by this automation.
"""

@@ -32,34 +15,20 @@

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
REPO = "owner/repo"
TRIGGER_LABEL = "openhands-review"
REVIEW_TONE = "thorough"
REVIEW_STYLE_INSTRUCTIONS = ""
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
TERMINAL_STATUSES = {"idle", "finished", "error", "stuck"}
# ── Stdlib helpers ─────────────────────────────────────────────────────────────
def _get_env_key() -> str:
return (
os.environ.get("SESSION_API_KEY")
or os.environ.get("OH_SESSION_API_KEYS_0")
or ""
)
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("/")

@@ -80,3 +49,2 @@ key = _get_env_key()

) -> None:
"""Signal run completion to the automation service."""
url = os.environ.get("AUTOMATION_CALLBACK_URL", "")

@@ -104,10 +72,3 @@ if not url:

# ── 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", "")

@@ -124,3 +85,3 @@ event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))

state_dir.mkdir(parents=True, exist_ok=True)
return str(state_dir / f"github_pr_reviewer_{automation_id}.json")
return str(state_dir / f"github_pr_reviewer_label_event_{automation_id}.json")

@@ -136,5 +97,7 @@

return {
"version": 1,
"version": 2,
"repo": REPO,
"conversations": {}, # pr_number (str) → ConversationRecord
"trigger_label": TRIGGER_LABEL,
"reviews": {},
"prs": {},
}

@@ -144,8 +107,8 @@

def save_state(path: str, state: dict) -> None:
with open(path, "w") as f:
json.dump(state, f, indent=2)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w") as f:
json.dump(state, f, indent=2, sort_keys=True)
os.replace(tmp_path, path)
# ── GitHub API helpers ─────────────────────────────────────────────────────────
def _github_request(

@@ -159,5 +122,3 @@ token: str,

) -> tuple:
"""Low-level GitHub API call. Returns (parsed_body, response_headers)."""
base = "https://api.github.com"
url = f"{base}{path}"
url = f"https://api.github.com{path}"
if params:

@@ -174,11 +135,7 @@ url = f"{url}?{urlencode(params)}"

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
return (json.loads(raw) if raw.strip() else {}), dict(r.headers)
def _github_paginate(token: str, path: str, params: dict | None = None) -> list:
"""Fetch all pages from a GitHub list endpoint."""
results = []

@@ -213,4 +170,3 @@ page = 1

def _verify_token_and_repo(token: str, repo: str) -> str:
"""Verify token validity and repo access. Returns the authenticated GitHub username."""
def _verify_token_and_repo(token: str, repo: str) -> None:
try:

@@ -220,10 +176,6 @@ user_data, _ = _github_request(token, "GET", "/user")

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}")
raise RuntimeError("GITHUB_PERSONAL_ACCESS_TOKEN is invalid or expired.") from exc
raise RuntimeError(f"GitHub /user check failed: {exc.code}") from exc
username: str = user_data.get("login", "?")
print(f"Authenticated as GitHub user: {username}")
print(f"Authenticated as GitHub user: {user_data.get('login', '?')}")

@@ -234,34 +186,41 @@ try:

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}")
raise RuntimeError(f"Repository '{repo}' is not accessible with the current token.") from exc
raise RuntimeError(f"GitHub /repos/{repo} check failed: {exc.code}") from exc
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"},
{"state": "open", "sort": "updated", "direction": "desc"},
)
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 _get_pr(token: str, repo: str, pr_number: int) -> dict:
pr, _ = _github_request(token, "GET", f"/repos/{repo}/pulls/{pr_number}")
return pr
def _get_issue_events(token: str, repo: str, pr_number: int) -> list[dict]:
return _github_paginate(token, f"/repos/{repo}/issues/{pr_number}/events")
def _latest_trigger_label_event(token: str, repo: str, pr_number: int) -> dict | None:
events = _get_issue_events(token, repo, pr_number)
matching = [
event for event in events
if event.get("event") == "labeled"
and (event.get("label") or {}).get("name", "").lower() == TRIGGER_LABEL.lower()
and event.get("id") is not None
]
if not matching:
return None
return max(matching, key=lambda event: (event.get("created_at") or "", int(event.get("id") or 0)))
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",
token,
"POST",
f"/repos/{repo}/issues/{pr_number}/comments",

@@ -274,7 +233,3 @@ body={"body": body},

# ── OpenHands conversation helpers ────────────────────────────────────────────
def _oh_request(
agent_url: str, api_key: str, method: str, path: str, body: dict | None = None
) -> dict:
def _oh_request(agent_url: str, api_key: str, method: str, path: str, body: dict | None = None) -> dict:
url = f"{agent_url}{path}"

@@ -294,5 +249,6 @@ headers = {"X-Session-API-Key": api_key, "Content-Type": "application/json"}

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)
req = urllib.request.Request(
f"{agent_url}/api/settings",
headers={"X-Session-API-Key": api_key, "X-Expose-Secrets": "plaintext"},
)
with urllib.request.urlopen(req) as r:

@@ -304,4 +260,3 @@ return json.loads(r.read())

data = _fetch_settings(agent_url, api_key)
agent_settings = data.get("agent_settings", {})
llm = agent_settings.get("llm", {})
llm = data.get("agent_settings", {}).get("llm", {})
return {

@@ -335,5 +290,4 @@ "kind": "Agent",

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:
secrets = {}
for secret in _list_secret_names(agent_url, api_key):
name = secret.get("name", "")

@@ -356,8 +310,6 @@ if not name:

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,
"agent": _get_agent_dict(agent_url, api_key),
"initial_message": {"content": [{"text": initial_message}]},

@@ -381,24 +333,19 @@ }

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",
)
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] = {
_TONE_INSTRUCTIONS = {
"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."
"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."
"Provide a brief, high-signal review. Focus only on important 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."
"raising concerns while still noting real issues."
),

@@ -408,3 +355,27 @@ }

def _build_review_prompt(pr: dict, diff: str, diff_truncated: bool) -> str:
def _labels(pr: dict) -> list[str]:
return [label.get("name", "") for label in pr.get("labels", [])]
def _has_trigger_label(pr: dict) -> bool:
return any(label.lower() == TRIGGER_LABEL.lower() for label in _labels(pr))
def _head_sha(pr: dict) -> str:
return ((pr.get("head") or {}).get("sha") or "").strip()
def _review_key(pr_number: int, label_event_id: int | str) -> str:
return f"{pr_number}:label:{label_event_id}"
def _with_ai_disclosure(body: str) -> str:
disclosure = "_This comment was posted by an AI agent (OpenHands)._"
body = (body or "").strip()
if disclosure.lower() in body.lower():
return body
return f"{body}\n\n{disclosure}" if body else disclosure
def _build_review_prompt(pr: dict, head_sha: str, label_event: dict) -> str:
number = pr.get("number", "?")

@@ -417,43 +388,46 @@ title = pr.get("title", "(no title)")

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)"
label_str = ", ".join(_labels(pr)) or "(none)"
label_event_id = label_event.get("id", "?")
label_event_created_at = label_event.get("created_at", "?")
changed_files = pr.get("changed_files", "?")
additions = pr.get("additions", "?")
deletions = pr.get("deletions", "?")
clone_url = f"https://github.com/{REPO}.git"
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 ""
extra = f"\n\nAdditional style instructions:\n{REVIEW_STYLE_INSTRUCTIONS}" if REVIEW_STYLE_INSTRUCTIONS.strip() else ""
return (
f"You are an AI code reviewer. Review the following GitHub pull request "
f"and write a review comment.\n\n"
"You are an AI code reviewer. Review the GitHub pull request below and write "
"a single review comment. Do not modify files, push commits, approve via the GitHub "
"API, or request changes via the review API; only produce the final comment text.\n\n"
f"Repository : {REPO}\n"
f"Clone URL : {clone_url}\n"
f"PR #{number}: \"{title}\"\n"
f"Author : @{author}\n"
f"Base → Head: {base_branch} ← {head_branch}\n"
f"Head SHA : {head_sha}\n"
f"Trigger : latest `{TRIGGER_LABEL}` labeled event {label_event_id} at {label_event_created_at}\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"\nPR Description:\n---\n{body}\n---\n\n"
"Required workflow:\n"
"1. Clone the repository into a fresh working directory inside the workspace.\n"
f" Example: `git clone {clone_url} pr-review-{number}`.\n"
"2. Check out the exact pull request branch by PR number, then verify HEAD matches the SHA above.\n"
f" Example: `git fetch origin pull/{number}/head:openhands-pr-{number}` followed by `git checkout openhands-pr-{number}`.\n"
"3. Inspect the existing PR context before reviewing, including PR description, issue comments, review comments, changed files, and the diff.\n"
" Prefer `gh pr view`, `gh pr diff`, `gh pr checkout`, or GitHub REST API calls with `GITHUB_PERSONAL_ACCESS_TOKEN`; do not print secret values.\n"
"4. Use the checked-out repository to inspect relevant files and surrounding code, not just the patch.\n"
"5. Before producing the final review text, delete only the cloned repository directory created in step 1.\n"
f" Example: `rm -rf pr-review-{number}`. Do not delete any other files or directories.\n"
"6. Write a high-signal review comment with specific findings. If there are no material issues, say so.\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`."
"Output ONLY the review text — no preamble, no meta-commentary. "
"This text will be posted verbatim as a comment on the pull request. "
"End your review with a clear verdict on its own line: either `✅ APPROVED` "
"or `🔄 CHANGES REQUESTED`."
)
# ── Core logic ─────────────────────────────────────────────────────────────────
def _process_new_pr(
def _process_review_request(
github_token: str,

@@ -464,41 +438,15 @@ agent_url: str,

pr: dict,
conversations: dict,
label_event: dict,
reviews: dict,
) -> str | None:
"""Start a review conversation for a PR not yet seen. Returns the conversation ID."""
number = pr["number"]
head_sha = _head_sha(pr)
label_event_id = label_event["id"]
key = _review_key(number, label_event_id)
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
print(f" Queuing review for PR #{number} from `{TRIGGER_LABEL}` event {label_event_id} at {head_sha[:12]}: {title}")
prompt = _build_review_prompt(pr, head_sha, label_event)
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:

@@ -510,4 +458,7 @@ conv_id = create_conversation(agent_url, api_key, prompt)

conversations[str(number)] = {
reviews[key] = {
"pr_number": number,
"head_sha": head_sha,
"trigger_label_event_id": label_event_id,
"trigger_label_event_created_at": label_event.get("created_at"),
"html_url": html_url,

@@ -522,12 +473,18 @@ "status": "active",

_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)._",
github_token,
REPO,
number,
_with_ai_disclosure(
"🤖 **OpenHands is reviewing this PR.**\n\n"
f"Trigger label: `{TRIGGER_LABEL}`\n"
f"Label event: `{label_event_id}` at `{label_event.get('created_at', '?')}`\n"
f"Head commit: `{head_sha}`\n"
f"View the conversation: {conv_url}"
),
)
return conv_id
def _check_conversation_completion(
rec: dict,
latest_open_prs: dict[int, dict],
github_token: str,

@@ -537,3 +494,2 @@ agent_url: 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:

@@ -544,3 +500,17 @@ return

pr_number = rec["pr_number"]
reviewed_sha = rec.get("head_sha", "")
current_pr = latest_open_prs.get(pr_number)
if not current_pr:
rec["status"] = "closed"
print(f" PR #{pr_number} closed/merged — skipping result post")
return
current_sha = _head_sha(current_pr)
if current_sha and reviewed_sha and current_sha != reviewed_sha:
rec["status"] = "stale"
rec["stale_reason"] = f"head changed from {reviewed_sha} to {current_sha}"
print(f" PR #{pr_number} advanced to {current_sha[:12]} — suppressing stale review {conv_id}")
return
try:

@@ -553,4 +523,3 @@ status = conversation_status(agent_url, api_key, conv_id)

print(f" PR #{pr_number} conversation {conv_id} → status={status}")
if status not in ("idle", "finished", "error", "stuck"):
if status not in TERMINAL_STATUSES:
return

@@ -563,15 +532,11 @@

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)._"
if status in {"error", "stuck"}:
comment_body = _with_ai_disclosure(
f"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` "
f"(status: `{status}`).\n\n{final}".strip()
)
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)._"
)
comment_body = _with_ai_disclosure(
final
or f"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced."
)

@@ -581,12 +546,9 @@

rec["status"] = "closed"
print(f" Posted review for PR #{pr_number}")
rec["completed_at"] = time.time()
print(f" Posted review for PR #{pr_number} at {reviewed_sha[:12]}")
# ── 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("/")

@@ -603,38 +565,58 @@ api_key = _get_env_key()

conversations: dict = state.get("conversations", {})
reviews: dict = state.setdefault("reviews", {})
prs_state: dict = state.setdefault("prs", {})
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
open_prs = _list_open_prs(github_token, REPO)
latest_open_prs = {pr["number"]: pr for pr in open_prs}
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 = None
last_conversation_id: str | None = None
for pr in open_prs:
number = pr["number"]
head_sha = _head_sha(pr)
label_present = _has_trigger_label(pr)
prs_state[str(number)] = {
"head_sha": head_sha,
"label_present": label_present,
"labels": _labels(pr),
"last_seen": time.time(),
}
# Queue reviews for PRs not yet in state.
for pr in open_prs:
key = str(pr["number"])
if key in conversations:
if not label_present:
continue
conv_id = _process_new_pr(
github_token, agent_url, api_key, openhands_url, pr, conversations,
)
if not head_sha:
print(f" PR #{number} has no head SHA; skipping")
continue
fresh_pr = _get_pr(github_token, REPO, number)
fresh_head_sha = _head_sha(fresh_pr)
if fresh_head_sha != head_sha:
print(f" PR #{number} head changed during poll ({head_sha[:12]} → {fresh_head_sha[:12]}); using latest PR metadata")
if not _has_trigger_label(fresh_pr):
print(f" PR #{number} lost `{TRIGGER_LABEL}` during poll; skipping")
continue
label_event = _latest_trigger_label_event(github_token, REPO, number)
if not label_event:
print(f" PR #{number} has `{TRIGGER_LABEL}` but no matching labeled event; skipping")
continue
key = _review_key(number, label_event["id"])
if key in reviews:
print(f" PR #{number} label event {label_event['id']} already tracked ({reviews[key].get('status')})")
continue
conv_id = _process_review_request(github_token, agent_url, api_key, openhands_url, fresh_pr, label_event, reviews)
if conv_id:
last_conversation_id = conv_id
# Check active conversations for completion.
for key, rec in list(conversations.items()):
for rec in list(reviews.values()):
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)
_check_conversation_completion(rec, latest_open_prs, github_token, agent_url, api_key)
state["conversations"] = conversations
state["repo"] = REPO
state["trigger_label"] = TRIGGER_LABEL
state["updated_at"] = time.time()
save_state(state_path, state)

@@ -651,4 +633,5 @@ print(f"State saved → {state_path}")

import traceback
traceback.print_exc()
fire_callback("FAILED", str(exc))
sys.exit(1)
---
name: github-pr-reviewer
description: >
Create an automation that reviews GitHub pull requests when they are opened
or updated. Inspects the diff, changed files, tests, and existing discussion
via GitHub MCP, then posts a concise review highlighting risks, security
issues, missing tests, and next steps.
Create an automation that reviews GitHub pull requests when a configurable
trigger label is applied. Polls GitHub deterministically, starts one
OpenHands review conversation per label event, inspects full repository and
PR context, and posts the final review comment back to GitHub.
triggers:

@@ -14,8 +14,9 @@ - /pr-reviewer:setup

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.
Create a cron automation that watches a GitHub repository for pull requests
with a review trigger label, starts an OpenHands review conversation once per
label event, 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.
The automation script is deterministic: PR discovery, label-event tracking,
state persistence, stale-result suppression, and GitHub comment posting are
handled in Python. The LLM is invoked only for the review itself.

@@ -28,8 +29,8 @@ ---

Verify that the following secret is set in **OpenHands Settings → Secrets**:
Verify that the following secret is set in **OpenHands Settings -> Secrets**:
| 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 |
| `GITHUB_PERSONAL_ACCESS_TOKEN` | Classic PAT | `repo` for private repos or `public_repo` for public repos |
| `GITHUB_PERSONAL_ACCESS_TOKEN` | Fine-grained PAT | Contents: Read, Metadata: Read, Pull requests: Read, Issues: Read and Write |

@@ -51,3 +52,3 @@ Check with:

### Step 1 — Verify GITHUB_PERSONAL_ACCESS_TOKEN
### Step 1 - Verify `GITHUB_PERSONAL_ACCESS_TOKEN`

@@ -57,7 +58,7 @@ Run the `curl` check above.

- If absent: *"GITHUB_PERSONAL_ACCESS_TOKEN is not set. Please add it in
OpenHands Settings → Secrets."* Stop.
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
### Step 2 - Collect repository

@@ -83,8 +84,20 @@ Ask: *"Which GitHub repository should be monitored?

### Step 3 — Collect review tone
### Step 3 - Collect trigger label
Ask: *"Which PR label should trigger a review?
(Press Enter for the default: `openhands-review`.)"*
Record the answer as `TRIGGER_LABEL`. If the label does not exist yet, tell the
user that GitHub will still record the event once the label is created and
applied to a PR.
The automation reviews a PR when it sees the latest matching `labeled` event for
that label. To request another review later, remove and re-apply the label.
### Step 4 - 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
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)"*

@@ -95,11 +108,11 @@

| Answer | `REVIEW_TONE` | `REVIEW_STYLE_INSTRUCTIONS` |
|--------|--------------|------------------------------|
|---|---|---|
| 1 / Enter | `"thorough"` | `""` |
| 2 | `"concise"` | `""` |
| 3 | `"friendly"` | `""` |
| Custom text (e.g. "hostile pirate") | `"thorough"` | the custom text verbatim |
| Custom text, e.g. `strict but kind` | `"thorough"` | the custom text verbatim |
### Step 4 — Collect cron schedule
### Step 5 - Collect cron schedule
Ask: *"How often should the automation poll for new PRs?
Ask: *"How often should the automation poll for labeled PRs?
(Press Enter for the default: every 5 minutes.

@@ -112,6 +125,6 @@ Use a cron expression for a different interval, e.g. `0 * * * *` = hourly)"*

### Step 5 — Generate the automation script
### Step 6 - Generate the automation script
Read `scripts/main.py` from this skill's directory. Apply exactly four
constant substitutions near the top of the file:
Read `scripts/main.py` from this skill's directory. Apply exactly five constant
substitutions near the top of the file:

@@ -121,2 +134,3 @@ | Placeholder | Replace with |

| `REPO = "owner/repo"` | `REPO = "{owner_repo}"` |
| `TRIGGER_LABEL = "openhands-review"` | `TRIGGER_LABEL = "{trigger_label}"` |
| `REVIEW_TONE = "thorough"` | `REVIEW_TONE = "{review_tone}"` |

@@ -126,6 +140,9 @@ | `REVIEW_STYLE_INSTRUCTIONS = ""` | `REVIEW_STYLE_INSTRUCTIONS = "{style_instructions}"` |

Write the customised script to a temporary build directory:
Use a safe string writer such as `json.dumps(value)` when inserting user-provided
repository names, labels, or style instructions into Python string literals.
Write the customized 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
# write the customized main.py to /tmp/pr-reviewer-build/main.py
```

@@ -140,3 +157,3 @@

### Step 6 — Package and upload
### Step 7 - Package and upload

@@ -161,3 +178,3 @@ Determine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`

### Step 7 — Register the automation
### Step 8 - Register the automation

@@ -169,3 +186,3 @@ ```bash

-d "{
\"name\": \"GitHub PR Reviewer: {owner}/{repo}\",
\"name\": \"GitHub PR Reviewer: {owner}/{repo} label {trigger_label}\",
\"trigger\": {\"type\": \"cron\", \"schedule\": \"{cron_schedule}\"},

@@ -180,3 +197,3 @@ \"tarball_path\": \"$TARBALL_PATH\",

### Step 8 — Confirm
### Step 9 - Confirm

@@ -189,9 +206,10 @@ Tell the user:

> - Repository: `{owner}/{repo}`
> - Trigger label: `{trigger_label}`
> - Review tone: `{tone}`
> - Polling schedule: `{cron_schedule}`
> - State file: `~/.openhands/workspaces/automation-state/github_pr_reviewer_{id}.json`
> - State file: `~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_{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.
> Apply the `{trigger_label}` label to a pull request to queue a review. Each
> label event is processed once. To request another review, remove and re-apply
> the label.

@@ -204,23 +222,24 @@ ---

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.
1. Loads state from the JSON file (see `references/state-schema.md`).
2. Resolves and validates `GITHUB_PERSONAL_ACCESS_TOKEN` and repository access.
3. Lists open PRs, newest-updated first.
4. For each open PR carrying `TRIGGER_LABEL`:
- Refetches current PR metadata to avoid acting on stale list data.
- Finds the latest matching GitHub `labeled` issue event.
- Skips the event if it has already been tracked.
- Starts an OpenHands conversation with a review prompt that includes PR
metadata, the exact head SHA, label event details, and instructions to
clone the repo, inspect PR discussion, review comments, changed files,
diff, and surrounding code.
- Posts an acknowledgement comment with the label event, head SHA, and
conversation link.
- Records the label-event review in state with `status: "active"`.
5. For each active review conversation:
- Marks it closed without posting if the PR has closed or merged.
- Suppresses stale results if the PR head SHA changed after the review was
queued.
- When the conversation reaches `idle`, `finished`, `error`, or `stuck`,
posts the agent's final response as a GitHub comment and marks the review
closed.
6. Saves state atomically and fires the completion callback.

@@ -231,6 +250,6 @@ ---

- **`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.
- **`references/state-schema.md`** - State JSON schema, field definitions, and
review lifecycle diagram.
- **`scripts/main.py`** - The complete automation script. Customize the five
constants at the top before packaging.

@@ -243,7 +262,7 @@ ---

|---|---|---|
| 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 |
| Bot never queues reviews | Trigger label not present or no matching `labeled` event | Apply the configured label to the PR |
| "Bad credentials" in run logs | Token expired | Rotate and update `GITHUB_PERSONAL_ACCESS_TOKEN` |
| 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 |
| Same PR not reviewed after new commits | Label event was already processed | Remove and re-apply the trigger label |
| Review result never posts | Conversation still running or stuck | Open the conversation link from the acknowledgement comment |
| Stale review suppressed | PR head SHA changed while the agent was reviewing | Re-apply the trigger label after the latest commit |

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