From 3f4d1393cb0482d9046ea5ec6f93012bf061f1f7 Mon Sep 17 00:00:00 2001 From: tofarr Date: Fri, 22 May 2026 13:18:49 -0600 Subject: [PATCH 01/11] feat: add github-repo-monitor skill Adds a new skill that creates a cron automation to monitor a GitHub repository for comments on issues and PRs. When a comment containing a configurable trigger phrase (@OpenHands by default) is detected the skill: - Verifies GITHUB_TOKEN is set and has comment-posting permissions - Fetches the full issue/PR context (title, body, labels, last 10 comments) - Creates an OpenHands conversation pre-loaded with that context - Posts a GitHub acknowledgement comment with a conversation link - Forwards follow-up trigger comments to the running conversation - Re-opens closed conversations on new triggers (creates fresh if deleted) - Posts a summary GitHub comment when the conversation finishes Mirrors the structure and patterns of slack-channel-monitor, adapted for the GitHub API. Includes SKILL.md setup workflow, main.py template, state-schema.md, github-api.md reference, and plugin metadata files. Co-authored-by: openhands --- skills/github-repo-monitor/.claude-plugin | 1 + skills/github-repo-monitor/.codex-plugin | 1 + .../github-repo-monitor/.plugin/plugin.json | 22 + skills/github-repo-monitor/README.md | 69 ++ skills/github-repo-monitor/SKILL.md | 272 +++++++ .../references/github-api.md | 241 ++++++ .../references/state-schema.md | 160 ++++ skills/github-repo-monitor/scripts/main.py | 716 ++++++++++++++++++ 8 files changed, 1482 insertions(+) create mode 100644 skills/github-repo-monitor/.claude-plugin create mode 100644 skills/github-repo-monitor/.codex-plugin create mode 100644 skills/github-repo-monitor/.plugin/plugin.json create mode 100644 skills/github-repo-monitor/README.md create mode 100644 skills/github-repo-monitor/SKILL.md create mode 100644 skills/github-repo-monitor/references/github-api.md create mode 100644 skills/github-repo-monitor/references/state-schema.md create mode 100644 skills/github-repo-monitor/scripts/main.py diff --git a/skills/github-repo-monitor/.claude-plugin b/skills/github-repo-monitor/.claude-plugin new file mode 100644 index 00000000..665797f0 --- /dev/null +++ b/skills/github-repo-monitor/.claude-plugin @@ -0,0 +1 @@ +.plugin \ No newline at end of file diff --git a/skills/github-repo-monitor/.codex-plugin b/skills/github-repo-monitor/.codex-plugin new file mode 100644 index 00000000..665797f0 --- /dev/null +++ b/skills/github-repo-monitor/.codex-plugin @@ -0,0 +1 @@ +.plugin \ No newline at end of file diff --git a/skills/github-repo-monitor/.plugin/plugin.json b/skills/github-repo-monitor/.plugin/plugin.json new file mode 100644 index 00000000..63d325c8 --- /dev/null +++ b/skills/github-repo-monitor/.plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "github-repo-monitor", + "version": "1.0.0", + "description": "Create a cron automation that polls a GitHub repository for issue and PR comments containing a configurable trigger phrase (@OpenHands by default). Starts an OpenHands conversation with full issue/PR context, posts acknowledgement comments with a conversation link, and summarises results back to the issue/PR when the agent finishes.", + "author": { + "name": "OpenHands", + "email": "contact@all-hands.dev" + }, + "homepage": "https://github.com/OpenHands/extensions", + "repository": "https://github.com/OpenHands/extensions", + "license": "MIT", + "keywords": [ + "github", + "monitor", + "issues", + "pull-requests", + "trigger", + "cron", + "automation", + "integration" + ] +} diff --git a/skills/github-repo-monitor/README.md b/skills/github-repo-monitor/README.md new file mode 100644 index 00000000..fce46feb --- /dev/null +++ b/skills/github-repo-monitor/README.md @@ -0,0 +1,69 @@ +# GitHub Repository Monitor Skill + +An OpenHands skill that creates a cron automation to monitor a GitHub +repository for issue and PR comments, routing them to OpenHands conversations +and posting results back as GitHub comments. + +## What it does + +1. **Polls** a GitHub repository for new comments on issues and PRs. +2. **Triggers** when a comment contains `@OpenHands` (configurable). +3. **Creates** an OpenHands conversation pre-loaded with the full issue/PR + context — title, description, labels, and the last 10 comments. +4. **Posts** a GitHub comment with a link to the conversation and an + AI-disclosure notice. +5. **Forwards** follow-up trigger comments to the running conversation, + or re-opens it if it was previously closed. +6. **Summarises** by posting the agent's final response back to the + issue/PR once the conversation finishes. + +## Files + +``` +github-repo-monitor/ +├── SKILL.md ← agent instructions (loaded automatically) +├── README.md ← this file +├── scripts/ +│ └── main.py ← automation script template +└── references/ + ├── state-schema.md ← JSON state file documentation + └── github-api.md ← GitHub API endpoints and rate-limit notes +``` + +## Quick start + +Just tell OpenHands: + +> *"Set up a GitHub repository monitor for `owner/repo`"* + +The skill will walk through token verification, event-type selection, cron +schedule, and automation creation automatically. + +## Requirements + +- `GITHUB_TOKEN` secret set in OpenHands Settings → Secrets + - Classic PAT: `repo` (private repos) or `public_repo` (public repos) + - Fine-grained PAT: Issues — Read and Write +- The monitored repository must be accessible with that token + +## Configuration options + +| Option | Default | Description | +|--------|---------|-------------| +| Repository | (required) | `owner/repo` format | +| Trigger phrase | `@OpenHands` | Case-insensitive string to watch for in comments | +| Event types | `issue_comment` | `issue_comment`, `pr_review_comment`, or both | +| Cron schedule | `* * * * *` | Every minute; any valid 5-field cron expression | + +## State file + +The automation maintains a JSON state file at: +``` +~/.openhands/workspaces/automation-state/github_poller_{automation_id}.json +``` + +See `references/state-schema.md` for the full schema. + +## Similar skills + +- `slack-channel-monitor` — same pattern applied to Slack channels diff --git a/skills/github-repo-monitor/SKILL.md b/skills/github-repo-monitor/SKILL.md new file mode 100644 index 00000000..f7554a40 --- /dev/null +++ b/skills/github-repo-monitor/SKILL.md @@ -0,0 +1,272 @@ +# GitHub Repository Monitor + +Create a cron automation that polls a single GitHub repository on a +configurable schedule (default: every minute). + +When a comment on an issue or PR contains the **trigger phrase** +(default: `@OpenHands`) it: + +1. Posts a GitHub comment acknowledging the request with a conversation link. +2. Creates an OpenHands conversation pre-loaded with the issue/PR title, body, + labels, and recent comment history for full context. +3. Posts a summary GitHub comment when the conversation finishes. + +On every subsequent run: +- New trigger comments on an already-tracked issue/PR are forwarded to the + running conversation (or re-open a previously closed one). +- When a conversation goes idle/finished/error the agent's final response + is posted back as a GitHub comment. + +> **Local mode only.** This automation targets the local OpenHands setup +> (`dev:automation` stack). A cloud/webhook variant is out of scope here. + +--- + +## Prerequisites + +### Required secret + +Verify that the following secret is set in **OpenHands Settings → Secrets** +before proceeding: + +| Secret name | Token type | Minimum permissions | +|---|---|---| +| `GITHUB_TOKEN` | Classic PAT | `repo` (private repos) or `public_repo` (public repos) | +| `GITHUB_TOKEN` | Fine-grained PAT | Issues: Read and Write | + +Check with: +```bash +curl -s https://api.github.com/user \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('login') or d.get('message'))" +``` + +If the token is missing, inform the user and stop — the automation cannot +function without GitHub credentials. + +### Optional secret + +| Secret name | Default | Purpose | +|---|---|---| +| `OPENHANDS_URL` | `http://localhost:8000` | Base URL used to build conversation links in GitHub comments | + +--- + +## Setup Workflow + +Follow these steps in order. + +### Step 1 - Verify GITHUB_TOKEN + +Fetch the secret and run the `curl` check above. + +- If the secret is absent: tell the user + *"GITHUB_TOKEN is not set. Please add it in OpenHands Settings → Secrets + (classic PAT with `repo` or `public_repo` scope, or a fine-grained PAT + with Issues: Read and Write)."* Then stop. + +- If the API returns a non-200 or `{"message": "Bad credentials"}`: + tell the user the token is invalid and ask them to update it. + +### Step 2 - Collect repository + +Ask the user: *"Which GitHub repository should be monitored? +(Format: `owner/repo`, e.g. `microsoft/vscode`)"* + +Validate access and write permissions: + +```bash +curl -s "https://api.github.com/repos/{owner}/{repo}" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + | python3 -c " +import json, sys +d = json.load(sys.stdin) +if 'message' in d: + print('ERROR:', d['message']) +else: + perms = d.get('permissions', {}) + print(f\"Accessible. Private: {d.get('private')}. Permissions: {perms}\") +" +``` + +- If `message: Not Found` or `message: Bad credentials` → + inform the user and ask them to check the repo name and token. +- If the repo is private and `permissions.push` is `false` → + inform the user the token does not have write access and comments will fail. +- If the check passes, record `REPO = "{owner}/{repo}"`. + +### Step 3 - Collect trigger phrase + +Ask the user: *"What trigger phrase should OpenHands respond to? +(Press Enter to use the default: `@OpenHands`)"* + +Accepted values: any non-empty string unlikely to appear by accident. + +Record as `TRIGGER_PHRASE`. Default: `"@openhands"`. + +### Step 4 - Collect event types + +Ask the user: *"Which event types should be monitored? +Choose one or more:* + *1. Issue and PR comments (default)* + *2. PR inline review comments* + *3. Both* +*(Press Enter to accept the default: issue and PR comments.)"* + +Map the choice to the `EVENT_TYPES` list: + +| Choice | `EVENT_TYPES` value | +|---|---| +| 1 (default) | `["issue_comment"]` | +| 2 | `["pr_review_comment"]` | +| 3 | `["issue_comment", "pr_review_comment"]` | + +### Step 5 - Collect cron schedule + +Ask the user: *"How often should the automation poll GitHub? +(Press Enter for the default: every minute. +Use a cron expression for a different interval, e.g.: +`*/5 * * * *` = every 5 minutes, +`0 * * * *` = every hour)"* + +Default: `* * * * *` (every minute). + +Record as `CRON_SCHEDULE`. + +### 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: + +| Placeholder | Replace with | +|---|---| +| `REPO = "owner/repo"` | `REPO = "{owner_repo}"` | +| `TRIGGER_PHRASE = "@openhands"` | `TRIGGER_PHRASE = "{trigger_phrase_lower}"` | +| `EVENT_TYPES = ["issue_comment"]` | `EVENT_TYPES = {event_types_list}` | +| `DEFAULT_OPENHANDS_URL = "http://localhost:8000"` | `DEFAULT_OPENHANDS_URL = "{url}"` (keep default if the user has no preference) | + +Write the customised script to a temporary build directory: +```bash +mkdir -p /tmp/github-monitor-build +# (write the customised main.py to /tmp/github-monitor-build/main.py) +``` + +Validate syntax before packaging: +```bash +python3 -m py_compile /tmp/github-monitor-build/main.py && echo "Syntax OK" +``` + +Fix any syntax errors before proceeding. + +### Step 7 - Package and upload + +```bash +tar -czf /tmp/github-monitor.tar.gz -C /tmp/github-monitor-build . + +OPENHANDS_HOST="http://localhost:8000" + +TARBALL_PATH=$(curl -s -X POST \ + "${OPENHANDS_HOST}/api/automation/v1/uploads?name=github-repo-monitor" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + -H "Content-Type: application/gzip" \ + --data-binary @/tmp/github-monitor.tar.gz \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['tarball_path'])") + +echo "Uploaded: $TARBALL_PATH" +``` + +### Step 8 - Create the automation + +```bash +curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"GitHub Monitor: {owner}/{repo}\", + \"trigger\": {\"type\": \"cron\", \"schedule\": \"{cron_schedule}\"}, + \"tarball_path\": \"$TARBALL_PATH\", + \"entrypoint\": \"python3 main.py\", + \"timeout\": 55 + }" | python3 -m json.tool +``` + +Record the returned `id`. + +### Step 9 - Confirm + +Tell the user: + +> ✅ **GitHub Repository Monitor** is running! +> +> - Automation ID: `{id}` +> - Repository: `{owner}/{repo}` +> - Trigger phrase: `{phrase}` +> - Event types: `{event_types}` +> - Polling schedule: `{cron_schedule}` +> - State file: `~/.openhands/workspaces/automation-state/github_poller_{id}.json` +> +> Post a comment containing `{phrase}` on any issue or PR in `{owner}/{repo}` +> to test it. OpenHands will acknowledge with a comment and a link to the +> new conversation. + +--- + +## 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_TOKEN** — aborts immediately if absent or invalid. +3. **Polls for new events** since the previous `last_poll` timestamp: + - `GET /repos/{owner}/{repo}/issues/comments?since=…` for `issue_comment` + - `GET /repos/{owner}/{repo}/pulls/comments?since=…` for `pr_review_comment` +4. **Processes matching comments** in chronological order: + - Skips bot accounts (login ending in `[bot]`) to avoid feedback loops. + - Skips already-processed comment IDs. + - Checks body for the trigger phrase (case-insensitive). + - Extracts the issue/PR number from the comment URL. +5. **For each trigger comment**, per issue/PR: + - **Active conversation** → forwards the new comment directly. + - **Closed conversation** → tries to re-open it; falls back to creating + a new conversation if the old one is unreachable. + - **No conversation** → fetches full context (title, body, labels, last + 10 comments) and creates a new conversation with a detailed prompt. + - Posts a GitHub comment: *"🤖 OpenHands is on it! View progress: {url}"* +6. **Checks active conversations** for completion: + - If `status ∈ {idle, finished, error, stuck}` and enough time has passed + since creation (debounce), fetches the agent's final response and posts + it as a GitHub comment. Marks the conversation `closed`. +7. **Saves state** and fires the completion callback. + +--- + +## Additional Resources + +### Reference Files + +- **`references/state-schema.md`** - State JSON schema, field definitions, + and conversation lifecycle diagram. +- **`references/github-api.md`** - GitHub API endpoint reference, token + scopes, rate limits, and common error codes. + +### Script Template + +- **`scripts/main.py`** - The complete automation script. Customise the four + constants at the top (`REPO`, `TRIGGER_PHRASE`, `EVENT_TYPES`, + `DEFAULT_OPENHANDS_URL`) before packaging. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| Bot doesn't respond to comments | `GITHUB_TOKEN` missing or wrong scopes | Verify token with `curl /user`; check scopes in Step 1 | +| "Bad credentials" in run logs | Token expired | Rotate token and update the secret in Settings | +| 404 on repo access | Repo name wrong or token has no access | Re-check `owner/repo` spelling; add token as collaborator | +| Comments posted but no conversation created | Agent server URL wrong | Check `OPENHANDS_URL` secret and `AGENT_SERVER_URL` env var | +| Same comment processed twice | `processed_comment_ids` cleared | State file was deleted; harmless but duplicate comment may appear | +| Summary never posted | Conversation stuck in `running` | Open the conversation in the OpenHands UI; agent may need input | +| No events detected after first run | `last_poll` in the future | Delete the state file to reset; it will be recreated on next run | diff --git a/skills/github-repo-monitor/references/github-api.md b/skills/github-repo-monitor/references/github-api.md new file mode 100644 index 00000000..05e761f4 --- /dev/null +++ b/skills/github-repo-monitor/references/github-api.md @@ -0,0 +1,241 @@ +# GitHub API Reference + +Quick reference for the endpoints, authentication, and rate limits used by +the GitHub Repository Monitor automation. + +--- + +## Authentication + +All requests use Bearer authentication: + +``` +Authorization: Bearer {GITHUB_TOKEN} +Accept: application/vnd.github+json +X-GitHub-Api-Version: 2022-11-28 +``` + +--- + +## Token Types and Required Scopes + +### Classic Personal Access Token + +| Scope | Grants | +|-------|--------| +| `repo` | Full access to private and public repos (read + write issues/PRs) | +| `public_repo` | Write access to public repos only (sufficient for public repos) | + +### Fine-Grained Personal Access Token + +| Permission | Level | +|------------|-------| +| Issues | Read and Write | +| Pull requests | Read (optional, for fetching PR metadata) | + +### Checking Token Scopes + +```bash +curl -I https://api.github.com/user \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + | grep -i x-oauth-scopes +# X-OAuth-Scopes: repo, public_repo +``` + +Fine-grained PATs do not return `X-OAuth-Scopes`; the script relies on the +`permissions` field in the repo response instead. + +--- + +## Endpoints Used + +### Verify token identity + +``` +GET /user +``` + +Returns the authenticated user. Use to verify the token is valid. + +--- + +### Verify repo access and permissions + +``` +GET /repos/{owner}/{repo} +``` + +Key fields in the response: + +| Field | Type | Description | +|-------|------|-------------| +| `private` | bool | Whether the repo is private | +| `permissions.push` | bool | Token has write access (required for private repos) | +| `permissions.pull` | bool | Token has read access | +| `full_name` | string | `owner/repo` | + +Error codes: +- `404` — Repo not found or token has no read access. +- `403` — Token exists but is blocked from this resource. + +--- + +### Poll issue and PR comments + +``` +GET /repos/{owner}/{repo}/issues/comments + ?since={ISO 8601 UTC} + &sort=created + &direction=asc + &per_page=100 + &page={n} +``` + +Returns all comments on issues **and** PRs (PRs are a superset of issues in +GitHub's API). The `since` parameter filters to comments created **at or +after** the given timestamp. + +Key fields per comment: + +| Field | Description | +|-------|-------------| +| `id` | Integer comment ID (used for deduplication) | +| `body` | Comment text | +| `user.login` | Author username | +| `user.type` | `"User"` or `"Bot"` | +| `created_at` | ISO 8601 UTC creation timestamp | +| `html_url` | Direct link to the comment | +| `issue_url` | API URL of the parent issue/PR (extract number from last path segment) | + +--- + +### Poll PR inline review comments + +``` +GET /repos/{owner}/{repo}/pulls/comments + ?since={ISO 8601 UTC} + &sort=created + &direction=asc + &per_page=100 + &page={n} +``` + +Returns inline review comments on PR diffs. + +Key fields per comment: + +| Field | Description | +|-------|-------------| +| `id` | Integer comment ID | +| `body` | Comment text | +| `user.login` | Author username | +| `pull_request_url` | API URL of the parent PR (extract number from last path segment) | +| `path` | File path the comment was left on | +| `line` | Line number in the file (nullable) | +| `created_at` | ISO 8601 UTC creation timestamp | + +--- + +### Fetch issue/PR metadata + +``` +GET /repos/{owner}/{repo}/issues/{issue_number} +``` + +Works for both issues and pull requests. To distinguish: +- Response contains `pull_request` key → it's a PR. +- No `pull_request` key → it's a regular issue. + +Key fields: + +| Field | Description | +|-------|-------------| +| `number` | Issue/PR number | +| `title` | Title string | +| `body` | Description text (can be null) | +| `state` | `"open"` or `"closed"` | +| `html_url` | Browser URL | +| `labels[].name` | Label names | +| `pull_request.url` | Present only if this is a PR | + +--- + +### Fetch recent comments for context + +``` +GET /repos/{owner}/{repo}/issues/{issue_number}/comments + ?per_page=100 +``` + +Returns all issue/PR comments in chronological order. The script fetches +all pages and takes the last `CONTEXT_COMMENT_LIMIT` (default 10) entries. + +--- + +### Post a comment + +``` +POST /repos/{owner}/{repo}/issues/{issue_number}/comments + +Body: { "body": "comment text (Markdown supported)" } +``` + +Works for both issues and PRs. Returns the created comment object including +its `id` and `html_url`. + +Error codes: +- `403` — Token lacks write permission. +- `404` — Repo or issue not found. +- `410` — Issue is locked; comments are disabled. + +--- + +## Rate Limits + +| Tier | Limit | Notes | +|------|-------|-------| +| Authenticated requests | 5,000 / hour | Per token | +| Search API | 30 / minute | Not used by this script | +| Secondary rate limit | Varies | Triggered by rapid POST bursts; unlikely at 1-min polling | + +At one poll per minute on a moderately active repo: +- ~2 GET calls per run baseline (user + repo) +- ~1–3 additional GETs per trigger event (issue context + comment history) +- ~1–2 POSTs per trigger event (acknowledgement comment + optional summary) + +Typical usage: **< 20 requests/hour** for a quiet repo, +**< 300 requests/hour** for a very active repo (still well within the 5,000 limit). + +Check remaining quota with: +```bash +curl -s https://api.github.com/rate_limit \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + | python3 -c " +import json, sys +d = json.load(sys.stdin) +core = d['resources']['core'] +print(f\"Remaining: {core['remaining']}/{core['limit']} Reset: {core['reset']}\") +" +``` + +--- + +## Common Error Codes + +| Code | Meaning | Typical fix | +|------|---------|-------------| +| 401 | Bad credentials / token expired | Rotate token; update the secret | +| 403 | Forbidden (scope missing or repo blocked) | Add required scope; check org SSO | +| 404 | Resource not found or no read access | Check repo name; ensure token has access | +| 410 | Gone (issue locked or deleted) | Harmless — skip this issue | +| 422 | Unprocessable entity (e.g., body too long) | Truncate comment body | +| 429 | Rate limit hit (secondary) | Slow down; add sleep between requests | + +--- + +## Bot Detection + +GitHub sets `user.type = "Bot"` for GitHub Apps and some bots. Classic bot +accounts (created as regular users) may instead have logins ending in +`[bot]` (e.g. `dependabot[bot]`). The script skips both patterns to avoid +feedback loops with other automation. diff --git a/skills/github-repo-monitor/references/state-schema.md b/skills/github-repo-monitor/references/state-schema.md new file mode 100644 index 00000000..456e64d5 --- /dev/null +++ b/skills/github-repo-monitor/references/state-schema.md @@ -0,0 +1,160 @@ +# State File Schema + +The automation maintains a JSON state file that persists across polling runs. +This file is the source of truth for which conversations are active, which +comment IDs have already been processed, and the timestamp of the last poll. + +--- + +## File Location + +``` +{WORKSPACE_BASE_ROOT}/automation-state/github_poller_{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_poller_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 + "last_poll": "2024-06-01T12:00:00Z", // ISO 8601 UTC — used as ?since= on the + // next GitHub API call; advanced to the + // START of each run before processing + "conversations": { ... }, // see ConversationRecord below + "processed_comment_ids": [ // rolling list (max 5000) of GitHub + 12345678, // comment IDs already handled; prevents + 98765432 // duplicate processing if the run overlaps + ] +} +``` + +--- + +## `conversations` Map + +Key: `"{issue_number}"` (string) — uniquely identifies an issue or PR in the repo. + +Value: **ConversationRecord** + +```jsonc +{ + // Required fields + "conversation_id": "550e8400-e29b-41d4-a716-446655440000", + // OpenHands conversation UUID + "issue_number": 42, // GitHub issue or PR number (integer) + "issue_type": "issue", // "issue" | "pr" + "html_url": "https://github.com/owner/repo/issues/42", + // direct link to the issue/PR + "status": "active", // "active" | "closed" (see below) + "last_activity": 1716576060.0 // float Unix timestamp — last time a message + // was sent to or created for this conversation +} +``` + +### `status` Values + +| Value | Meaning | +|-------|---------| +| `active` | Conversation is running or awaiting more input; new trigger comments on the same issue/PR will be forwarded | +| `closed` | Summary has been posted to GitHub; a new trigger comment will attempt to re-open this conversation (or create a fresh one if it is unreachable) | + +--- + +## `processed_comment_ids` List + +A rolling list (max `MAX_PROCESSED_IDS = 5000` entries) of integer GitHub +comment IDs that have already been processed. This prevents: + +- Duplicate processing caused by cron boundary overlap (the `last_poll` + timestamp is advanced to the start of the current run, so the next run + re-scans a small window of time). +- Accidental re-triggers if the state file is partially reset. + +IDs are stored as integers and kept sorted; the oldest entries are pruned +when the list exceeds the maximum. + +--- + +## Conversation Lifecycle + +``` +[trigger phrase detected in a new comment] + │ + ▼ + ┌──────────────────────────────────────────────────┐ + │ status = "active" │ + │ last_activity = now │ + │ POST GitHub comment: "🤖 OpenHands is on it!" │ + └──────────────────────────────────────────────────┘ + │ + (subsequent runs) + │ + ┌─────┴────────────────────────────────────────────┐ + │ New trigger on same issue/PR │ + │ → send_to_conversation() │ + │ → last_activity = now │ + └──────────────────────────────────────────────────┘ + │ + (when time.time() - last_activity > DONE_DEBOUNCE + AND conversation_status ∈ {idle, finished, error, stuck}) + │ + ▼ + POST GitHub comment: summary or error message + status = "closed" + │ + (if a NEW trigger comment arrives later) + │ + ▼ + Try send_to_conversation() on closed conv_id + ├── succeeds → status = "active" (re-opened) + └── fails (conv deleted) → create_conversation() → status = "active" +``` + +--- + +## Example State File + +```json +{ + "version": 1, + "repo": "acme-corp/backend", + "last_poll": "2024-06-01T12:05:00Z", + "conversations": { + "42": { + "conversation_id": "550e8400-e29b-41d4-a716-446655440000", + "issue_number": 42, + "issue_type": "issue", + "html_url": "https://github.com/acme-corp/backend/issues/42", + "status": "active", + "last_activity": 1717243502.3 + }, + "15": { + "conversation_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "issue_number": 15, + "issue_type": "pr", + "html_url": "https://github.com/acme-corp/backend/pull/15", + "status": "closed", + "last_activity": 1717240800.0 + } + }, + "processed_comment_ids": [ + 1234567890, + 9876543210 + ] +} +``` diff --git a/skills/github-repo-monitor/scripts/main.py b/skills/github-repo-monitor/scripts/main.py new file mode 100644 index 00000000..ad98d87e --- /dev/null +++ b/skills/github-repo-monitor/scripts/main.py @@ -0,0 +1,716 @@ +""" +GitHub Repository Monitor - OpenHands Automation Script + +Polls a GitHub repository on a cron schedule. When an event matching the +configured trigger phrase and event-type filter is detected it: + 1. Posts a GitHub comment acknowledging the request with a conversation link. + 2. Creates (or resumes) an OpenHands conversation pre-loaded with full + issue/PR context and recent comment history. + 3. When the conversation reaches a terminal/idle state the agent's final + response is posted back to the issue/PR as a GitHub comment. + +On subsequent runs: + - New trigger comments on a tracked issue/PR are forwarded to the running + conversation. + - If the previous conversation was closed/deleted a new one is created. + +Configuration constants are embedded at automation-creation time by the skill. +See SKILL.md for the full setup workflow. + +Required secrets (set in OpenHands Settings → Secrets): + GITHUB_TOKEN - Personal Access Token + Classic PAT: 'repo' scope (private) or 'public_repo' (public) + Fine-grained PAT: Issues: 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, timedelta, timezone +from urllib.parse import urlencode + +# ── Embedded configuration (filled in by the skill at creation time) ────────── +REPO = "owner/repo" # e.g. "microsoft/vscode" +TRIGGER_PHRASE = "@openhands" # case-insensitive +EVENT_TYPES = ["issue_comment"] # e.g. ["issue_comment", "pr_review_comment"] +DEFAULT_OPENHANDS_URL = "http://localhost:8000" + +# Context: number of recent issue/PR comments to include in the initial prompt. +CONTEXT_COMMENT_LIMIT = 10 + +# Lookback slightly over 60 s on the first run to avoid boundary gaps. +INITIAL_LOOKBACK_SECONDS = 70 + +# Prevent posting summaries in the same run that created the conversation. +DONE_DEBOUNCE = 15 + +# Rolling window for processed event IDs — sized for ~1 week at high volume. +MAX_PROCESSED_IDS = 5000 + + +# ── 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) -> 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 + 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 = os.path.dirname(os.path.dirname(os.path.abspath(workspace_base))) + else: + root = os.path.expanduser("~/.openhands/workspaces") + + state_dir = os.path.join(root, "automation-state") + os.makedirs(state_dir, exist_ok=True) + return os.path.join(state_dir, f"github_poller_{automation_id}.json") + + +def _default_since() -> str: + """ISO 8601 UTC timestamp for the initial lookback window.""" + return ( + datetime.now(timezone.utc) - timedelta(seconds=INITIAL_LOOKBACK_SECONDS) + ).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def load_state(path: str) -> dict: + if os.path.exists(path): + with open(path) as f: + return json.load(f) + return { + "version": 1, + "repo": REPO, + "last_poll": _default_since(), + "conversations": {}, # issue_number (str) → ConversationRecord + "processed_comment_ids": [], # list of int comment IDs already handled + } + + +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, +) -> tuple[dict | list, dict]: + """Low-level GitHub API call. Returns (parsed_body, response_headers). + Raises urllib.error.HTTPError on non-2xx responses. + """ + base = "https://api.github.com" + url = f"{base}{path}" + if params: + url = f"{url}?{urlencode(params)}" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "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() + 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: + """Fetch GITHUB_TOKEN from secrets. Raises RuntimeError if absent.""" + try: + token = get_secret("GITHUB_TOKEN") + if token: + return token + except Exception: + pass + raise RuntimeError( + "GITHUB_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 the token is valid, the repo is accessible, and the token can + post comments. Returns the authenticated GitHub username. + Raises RuntimeError with a user-friendly message on any failure. + """ + # 1. Verify token validity and get scopes. + try: + user_data, user_headers = _github_request(token, "GET", "/user") + except urllib.error.HTTPError as exc: + if exc.code == 401: + raise RuntimeError( + "GITHUB_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", "?") + scopes_header: str = user_headers.get("X-OAuth-Scopes", "") or "" + scopes = {s.strip() for s in scopes_header.split(",") if s.strip()} + print(f"Authenticated as GitHub user: {username} scopes: {scopes or '(fine-grained PAT)'}") + + # 2. Verify repo access. + try: + repo_data, _ = _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 GITHUB_TOKEN. " + "Check the repo name (format: owner/repo) and token permissions." + ) + if exc.code == 403: + raise RuntimeError( + f"Access denied to repository '{repo}'. " + "Ensure GITHUB_TOKEN has the required permissions." + ) + raise RuntimeError(f"GitHub /repos/{repo} check failed: {exc.code}") + + # 3. Verify comment-posting permission. + is_private: bool = repo_data.get("private", False) + permissions: dict = repo_data.get("permissions", {}) + can_push: bool = permissions.get("push", False) + has_repo_scope: bool = "repo" in scopes + has_public_repo_scope: bool = "public_repo" in scopes + + if is_private: + # Private repo: must have push access or the 'repo' classic-PAT scope. + if not can_push and not has_repo_scope and scopes: + raise RuntimeError( + f"GITHUB_TOKEN cannot post comments to private repository '{repo}'. " + "A classic PAT needs the 'repo' scope; " + "a fine-grained PAT needs 'Issues: Read and Write' permission." + ) + else: + # Public repo: need at minimum 'public_repo' scope or push access. + if scopes and not (can_push or has_public_repo_scope or has_repo_scope): + raise RuntimeError( + f"GITHUB_TOKEN cannot post comments to public repository '{repo}'. " + "A classic PAT needs the 'public_repo' scope; " + "a fine-grained PAT needs 'Issues: Read and Write' permission." + ) + + print(f"Repository '{repo}' accessible. Private: {is_private}. Can push: {can_push}") + return username + + +def _poll_issue_comments(token: str, repo: str, since: str) -> list[dict]: + """Fetch all issue/PR comments created after `since` (ISO 8601 UTC).""" + return _github_paginate( + token, + f"/repos/{repo}/issues/comments", + {"since": since, "sort": "created", "direction": "asc"}, + ) + + +def _poll_pr_review_comments(token: str, repo: str, since: str) -> list[dict]: + """Fetch all PR inline review comments created after `since`.""" + return _github_paginate( + token, + f"/repos/{repo}/pulls/comments", + {"since": since, "sort": "created", "direction": "asc"}, + ) + + +def _extract_issue_number(comment: dict, event_type: str) -> int | None: + """Extract the issue/PR number from a comment object.""" + try: + if event_type == "issue_comment": + # issue_url: .../repos/owner/repo/issues/42 + return int(comment["issue_url"].rstrip("/").rsplit("/", 1)[-1]) + if event_type == "pr_review_comment": + # pull_request_url: .../repos/owner/repo/pulls/15 + return int(comment["pull_request_url"].rstrip("/").rsplit("/", 1)[-1]) + except (KeyError, ValueError, AttributeError): + pass + return None + + +def _get_issue_context(token: str, repo: str, issue_number: int) -> dict: + """Fetch issue/PR metadata and up to CONTEXT_COMMENT_LIMIT recent comments.""" + issue_data, _ = _github_request(token, "GET", f"/repos/{repo}/issues/{issue_number}") + + # Fetch last CONTEXT_COMMENT_LIMIT comments (GitHub returns oldest-first by default). + # We request a larger page and take the tail to get the most recent ones. + all_comments = _github_paginate( + token, + f"/repos/{repo}/issues/{issue_number}/comments", + {"per_page": 100}, + ) + recent_comments = all_comments[-CONTEXT_COMMENT_LIMIT:] + + return { + "issue": issue_data, + "recent_comments": recent_comments, + "is_pr": "pull_request" in issue_data, + } + + +def _post_github_comment(token: str, repo: str, issue_number: int, body: str) -> int | None: + """Post a comment on an issue/PR and return the comment ID.""" + try: + result, _ = _github_request( + token, + "POST", + f"/repos/{repo}/issues/{issue_number}/comments", + body={"body": body}, + ) + return result.get("id") + except Exception as exc: + print(f" Warning: failed to post GitHub comment on #{issue_number}: {exc}") + return None + + +# ── 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 _get_agent_dict(agent_url: str, api_key: str) -> dict: + """Fetch configured agent settings for conversation creation.""" + url = f"{agent_url}/api/settings" + headers = {"X-Session-API-Key": api_key, "X-Expose-Secrets": "plaintext"} + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req) as r: + data = json.loads(r.read()) + except urllib.error.HTTPError as exc: + raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc + llm = data.get("agent_settings", {}).get("llm", {}) + return {"kind": "Agent", "llm": llm} + + +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) + result = _oh_request(agent_url, api_key, "POST", "/api/conversations", { + "workspace": {"working_dir": workspace_dir}, + "agent": agent, + "initial_message": {"content": [{"text": initial_message}]}, + }) + return result["id"] + + +def send_to_conversation(agent_url: str, api_key: str, conv_id: str, text: str) -> None: + """Send a user message to an existing conversation and resume the agent.""" + _oh_request(agent_url, api_key, "POST", f"/api/conversations/{conv_id}/events", { + "role": "user", + "content": [{"text": text}], + "run": True, + }) + + +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 ──────────────────────────────────────────────────────────── + +def _build_initial_prompt(ctx: dict, trigger_comment: dict, event_type: str) -> str: + """Build the initial prompt for a new OpenHands conversation.""" + issue = ctx["issue"] + is_pr = ctx["is_pr"] + item_type = "Pull Request" if is_pr else "Issue" + number = issue.get("number", "?") + title = issue.get("title", "(no title)") + body = (issue.get("body") or "").strip() or "(no description)" + state = issue.get("state", "?") + html_url = issue.get("html_url", "") + labels = [lb["name"] for lb in issue.get("labels", [])] + label_str = ", ".join(labels) if labels else "(none)" + + comment_lines: list[str] = [] + for c in ctx["recent_comments"]: + author = c.get("user", {}).get("login", "?") + c_body = (c.get("body") or "").strip() + comment_lines.append(f"[{author}]: {c_body}") + context_block = "\n".join(comment_lines) if comment_lines else "(no prior comments)" + + trigger_author = trigger_comment.get("user", {}).get("login", "?") + trigger_body = (trigger_comment.get("body") or "").strip() + + path_info = "" + if event_type == "pr_review_comment": + path = trigger_comment.get("path", "") + line = trigger_comment.get("line") or trigger_comment.get("original_line") + if path: + path_info = f"\nTriggering comment location: {path}" + (f" line {line}" if line else "") + + return ( + f"You are an AI assistant responding to a request on a GitHub {item_type}.\n\n" + f"Repository : {REPO}\n" + f"{item_type} #{number}: \"{title}\"\n" + f"State : {state}\n" + f"Labels : {label_str}\n" + f"URL : {html_url}\n" + f"\nDescription:\n---\n{body}\n---\n" + f"\nRecent comments (oldest → newest, up to {CONTEXT_COMMENT_LIMIT}):\n" + f"---\n{context_block}\n---\n" + f"\nTriggering comment by @{trigger_author}:{path_info}\n" + f"---\n{trigger_body}\n---\n" + f"\nPlease analyse the request and take the appropriate action.\n" + f"The GITHUB_TOKEN secret is available if you need to interact with the " + f"GitHub API (fetch the PR diff, create commits, update labels, etc.).\n" + f"When you are finished, summarise what you did clearly — that summary " + f"will be posted back to the GitHub {item_type} as a comment." + ) + + +# ── Core event processing ────────────────────────────────────────────────────── + +def _process_trigger_comment( + github_token: str, + agent_url: str, + api_key: str, + openhands_url: str, + repo: str, + issue_number: int, + comment: dict, + event_type: str, + conversations: dict[str, dict], +) -> None: + """Handle a new trigger comment: create or resume a conversation.""" + conv_key = str(issue_number) + print(f" Trigger detected on #{issue_number} (comment {comment.get('id')})") + + # Fetch full issue/PR context. + try: + ctx = _get_issue_context(github_token, repo, issue_number) + except Exception as exc: + print(f" Error fetching context for #{issue_number}: {exc}") + return + + is_pr = ctx["is_pr"] + item_type = "pull request" if is_pr else "issue" + html_url = ctx["issue"].get("html_url", f"https://github.com/{repo}/issues/{issue_number}") + + existing = conversations.get(conv_key) + + # ── Case A: active conversation — forward the new comment ───────────────── + if existing and existing.get("status") == "active": + conv_id = existing["conversation_id"] + print(f" Forwarding to active conversation {conv_id}") + author = comment.get("user", {}).get("login", "?") + body = (comment.get("body") or "").strip() + try: + send_to_conversation( + agent_url, api_key, conv_id, + f"New comment on GitHub {item_type} #{issue_number} by @{author}:\n\n{body}", + ) + existing["last_activity"] = time.time() + return + except Exception as exc: + print(f" Warning: could not forward to conversation {conv_id}: {exc} — creating new") + # Fall through to create a new conversation. + + # ── Case B: closed or missing conversation — create / re-open ───────────── + prompt = _build_initial_prompt(ctx, comment, event_type) + + # If there's a closed conversation, try to re-open it by sending a message. + if existing and existing.get("status") == "closed": + conv_id = existing["conversation_id"] + author = comment.get("user", {}).get("login", "?") + body_text = (comment.get("body") or "").strip() + try: + send_to_conversation( + agent_url, api_key, conv_id, + f"New request on GitHub {item_type} #{issue_number} by @{author}:\n\n{body_text}", + ) + existing["status"] = "active" + existing["last_activity"] = time.time() + conv_id_used = conv_id + print(f" Re-opened closed conversation {conv_id}") + except Exception as exc: + print(f" Closed conversation {conv_id} unreachable ({exc}) — creating new") + existing = None # fall through to create fresh + + if existing is None or existing.get("status") not in ("active", "closed"): + # Create a brand-new conversation. + try: + conv_id_used = create_conversation(agent_url, api_key, prompt) + conversations[conv_key] = { + "conversation_id": conv_id_used, + "issue_number": issue_number, + "issue_type": "pr" if is_pr else "issue", + "html_url": html_url, + "status": "active", + "last_activity": time.time(), + } + print(f" Created conversation {conv_id_used}") + except Exception as exc: + print(f" Error creating conversation for #{issue_number}: {exc}") + return + + conv_url = f"{openhands_url}/conversations/{conv_id_used}" + + # Post acknowledgement comment on GitHub. + ack_body = ( + f"🤖 **OpenHands is on it!**\n\n" + f"I've started working on this {item_type}. " + f"View the conversation here: {conv_url}\n\n" + f"_This comment was posted by an AI agent (OpenHands) " + f"in response to an @openhands mention._" + ) + _post_github_comment(github_token, repo, issue_number, ack_body) + + +def _check_conversation_completion( + conv_key: str, + rec: dict, + github_token: str, + repo: str, + agent_url: str, + api_key: str, +) -> None: + """Post a summary GitHub comment when a conversation reaches a terminal state.""" + if (time.time() - rec.get("last_activity", 0.0)) < DONE_DEBOUNCE: + return + + conv_id = rec["conversation_id"] + issue_number = rec["issue_number"] + item_type = rec.get("issue_type", "issue") + item_label = "pull request" if item_type == "pr" else "issue" + + 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" #{issue_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 encountered a problem** (status: `{status}`).\n\n" + + (f"{final}\n\n" if final else "") + + f"_This message was posted by an AI agent (OpenHands)._" + ) + else: + comment_body = ( + (f"✅ **OpenHands completed the task:**\n\n{final}\n\n" if final + else f"✅ **OpenHands completed the task.** (No summary available.)\n\n") + + f"_This summary was generated by an AI agent (OpenHands) " + f"working on {item_label} #{issue_number}._" + ) + + _post_github_comment(github_token, repo, issue_number, comment_body) + rec["status"] = "closed" + print(f" Posted summary for #{issue_number}") + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main() -> None: + 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 + + since = state.get("last_poll") or _default_since() + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + state["last_poll"] = now_iso # advance before processing so next run doesn't miss events + + conversations: dict[str, dict] = state.get("conversations", {}) + processed_ids: list[int] = state.get("processed_comment_ids", []) + processed_set: set[int] = set(processed_ids) + + print(f"Polling {REPO} since {since} (trigger: '{TRIGGER_PHRASE}' types: {EVENT_TYPES})") + + # ── Collect new events ───────────────────────────────────────────────────── + all_events: list[tuple[str, dict]] = [] # (event_type, comment) + + if "issue_comment" in EVENT_TYPES: + try: + comments = _poll_issue_comments(github_token, REPO, since) + print(f" issue_comment: {len(comments)} new comment(s)") + for c in comments: + all_events.append(("issue_comment", c)) + except Exception as exc: + print(f" Warning: could not poll issue comments: {exc}") + + if "pr_review_comment" in EVENT_TYPES: + try: + review_comments = _poll_pr_review_comments(github_token, REPO, since) + print(f" pr_review_comment: {len(review_comments)} new comment(s)") + for c in review_comments: + all_events.append(("pr_review_comment", c)) + except Exception as exc: + print(f" Warning: could not poll PR review comments: {exc}") + + # Sort all events by creation time so they are processed chronologically. + all_events.sort(key=lambda x: x[1].get("created_at", "")) + + # ── Process trigger events ───────────────────────────────────────────────── + for event_type, comment in all_events: + comment_id: int = comment.get("id", 0) + if comment_id in processed_set: + continue + + author_login = (comment.get("user") or {}).get("login", "") + # Skip comments made by bots to avoid feedback loops. + if author_login.endswith("[bot]") or (comment.get("user") or {}).get("type") == "Bot": + processed_set.add(comment_id) + continue + + body_text: str = (comment.get("body") or "").strip() + if TRIGGER_PHRASE.lower() not in body_text.lower(): + # Not a trigger comment — mark as seen but don't process. + processed_set.add(comment_id) + continue + + issue_number = _extract_issue_number(comment, event_type) + if issue_number is None: + print(f" Could not extract issue number from comment {comment_id} — skipping") + processed_set.add(comment_id) + continue + + _process_trigger_comment( + github_token, agent_url, api_key, openhands_url, + REPO, issue_number, comment, event_type, conversations, + ) + processed_set.add(comment_id) + + # ── Check active conversations for completion ────────────────────────────── + for conv_key, rec in list(conversations.items()): + if rec.get("status") != "active": + continue + _check_conversation_completion( + conv_key, rec, github_token, REPO, agent_url, api_key, + ) + + # Trim processed_ids rolling window. + trimmed = sorted(processed_set) + if len(trimmed) > MAX_PROCESSED_IDS: + trimmed = trimmed[-MAX_PROCESSED_IDS:] + state["processed_comment_ids"] = trimmed + state["conversations"] = conversations + + save_state(state_path, state) + print(f"State saved → {state_path}") + + +try: + main() + fire_callback("COMPLETED") +except Exception as exc: + import traceback + traceback.print_exc() + fire_callback("FAILED", str(exc)) + sys.exit(1) From 1e6d6d195768efef4cc0cec5043c66b2f1d205e6 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 22 May 2026 19:23:38 +0000 Subject: [PATCH 02/11] fix: use TRIGGER_PHRASE variable in acknowledgement comment instead of hardcoded string Co-authored-by: openhands --- skills/github-repo-monitor/scripts/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/github-repo-monitor/scripts/main.py b/skills/github-repo-monitor/scripts/main.py index ad98d87e..0446821c 100644 --- a/skills/github-repo-monitor/scripts/main.py +++ b/skills/github-repo-monitor/scripts/main.py @@ -549,7 +549,7 @@ def _process_trigger_comment( f"I've started working on this {item_type}. " f"View the conversation here: {conv_url}\n\n" f"_This comment was posted by an AI agent (OpenHands) " - f"in response to an @openhands mention._" + f"in response to a '{TRIGGER_PHRASE}' mention._" ) _post_github_comment(github_token, repo, issue_number, ack_body) From 0c628fb9a824a55db5e216c960cfb658a4935b62 Mon Sep 17 00:00:00 2001 From: tofarr Date: Fri, 22 May 2026 13:24:10 -0600 Subject: [PATCH 03/11] fix: resolve CI failures for github-repo-monitor skill Three issues flagged by the test suite: 1. Add YAML frontmatter to SKILL.md Skill.load() requires a 'description' field in the frontmatter block at the top of SKILL.md. Added 'name' and 'description' matching the pattern of all other skills in the repo. 2. Convert .claude-plugin and .codex-plugin to symlinks The test suite enforces that these must be symlinks pointing to .plugin, not regular files. Replaced both plain files with proper symlinks (ln -s .plugin .claude-plugin / .codex-plugin). 3. Add skill to openhands-extensions.json marketplace Every skill directory must be listed in at least one marketplace JSON file. Added the github-repo-monitor entry after github-pr-review. Co-authored-by: openhands --- marketplaces/openhands-extensions.json | 16 ++++++++++++++++ skills/github-repo-monitor/.claude-plugin | 0 skills/github-repo-monitor/.codex-plugin | 0 skills/github-repo-monitor/SKILL.md | 12 ++++++++++++ 4 files changed, 28 insertions(+) mode change 100644 => 120000 skills/github-repo-monitor/.claude-plugin mode change 100644 => 120000 skills/github-repo-monitor/.codex-plugin diff --git a/marketplaces/openhands-extensions.json b/marketplaces/openhands-extensions.json index 92a4b844..550a86b9 100644 --- a/marketplaces/openhands-extensions.json +++ b/marketplaces/openhands-extensions.json @@ -250,6 +250,22 @@ "code-review" ] }, + { + "name": "github-repo-monitor", + "source": "./skills/github-repo-monitor", + "description": "Create a cron automation that polls a GitHub repository for issue and PR comments containing a configurable trigger phrase (@OpenHands by default). Starts an OpenHands conversation with full issue/PR context, posts acknowledgement comments with a conversation link, and summarises results back to the issue/PR when the agent finishes.", + "category": "productivity", + "keywords": [ + "github", + "monitor", + "issues", + "pull-requests", + "trigger", + "cron", + "automation", + "integration" + ] + }, { "name": "gitlab", "source": "./skills/gitlab", diff --git a/skills/github-repo-monitor/.claude-plugin b/skills/github-repo-monitor/.claude-plugin deleted file mode 100644 index 665797f0..00000000 --- a/skills/github-repo-monitor/.claude-plugin +++ /dev/null @@ -1 +0,0 @@ -.plugin \ No newline at end of file diff --git a/skills/github-repo-monitor/.claude-plugin b/skills/github-repo-monitor/.claude-plugin new file mode 120000 index 00000000..665797f0 --- /dev/null +++ b/skills/github-repo-monitor/.claude-plugin @@ -0,0 +1 @@ +.plugin \ No newline at end of file diff --git a/skills/github-repo-monitor/.codex-plugin b/skills/github-repo-monitor/.codex-plugin deleted file mode 100644 index 665797f0..00000000 --- a/skills/github-repo-monitor/.codex-plugin +++ /dev/null @@ -1 +0,0 @@ -.plugin \ No newline at end of file diff --git a/skills/github-repo-monitor/.codex-plugin b/skills/github-repo-monitor/.codex-plugin new file mode 120000 index 00000000..665797f0 --- /dev/null +++ b/skills/github-repo-monitor/.codex-plugin @@ -0,0 +1 @@ +.plugin \ No newline at end of file diff --git a/skills/github-repo-monitor/SKILL.md b/skills/github-repo-monitor/SKILL.md index f7554a40..9ee83c6b 100644 --- a/skills/github-repo-monitor/SKILL.md +++ b/skills/github-repo-monitor/SKILL.md @@ -1,3 +1,15 @@ +--- +name: github-repo-monitor +description: > + This skill should be used when the user asks to "monitor a GitHub repository", + "watch GitHub for issues or PRs", "respond to @OpenHands mentions on GitHub", + "set up an OpenHands GitHub integration", "trigger OpenHands from a GitHub + comment", or "poll a GitHub repo for a trigger phrase". Guides the user + through creating a cron automation that polls a single repository and starts + an OpenHands conversation whenever a configurable trigger phrase is detected + in an issue or PR comment. +--- + # GitHub Repository Monitor Create a cron automation that polls a single GitHub repository on a From 674fccb80f2f13d84d2c64dc91e589383afaf941 Mon Sep 17 00:00:00 2001 From: tofarr Date: Fri, 22 May 2026 13:25:12 -0600 Subject: [PATCH 04/11] chore: regenerate README catalog with github-repo-monitor entry Auto-generated by scripts/sync_extensions.py catalog Co-authored-by: openhands --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5bf3bbc5..759b132a 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ See [`mcps/README.md`](mcps/README.md) and [`automations/README.md`](automations ## Extensions Catalog -This repository contains **2 marketplace(s)** with **49 extensions** (39 skills, 10 plugins). +This repository contains **2 marketplace(s)** with **50 extensions** (40 skills, 10 plugins). ### large-codebase @@ -69,7 +69,7 @@ OpenHands skills for interacting, improving, and refactoring large codebases Official skills and plugins for OpenHands — the open-source AI software engineer. -**45 extensions** (37 skills, 8 plugins) +**46 extensions** (38 skills, 8 plugins) | Name | Type | Description | Commands | |------|------|-------------|----------| @@ -90,6 +90,7 @@ Official skills and plugins for OpenHands — the open-source AI software engine | frontend-design | skill | Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks ... | — | | github | skill | Interact with GitHub repositories, pull requests, issues, and workflows using the GITHUB_TOKEN environment variable a... | — | | github-pr-review | skill | Post structured PR reviews to GitHub with inline comments/suggestions in a single API call. | `/github-pr-review` | +| github-repo-monitor | skill | Create a cron automation that polls a GitHub repository for issue and PR comments containing a configurable trigger p... | — | | gitlab | skill | Interact with GitLab repositories, merge requests, and APIs using the GITLAB_TOKEN environment variable. Use when wor... | — | | iterate | skill | Iterate on a GitHub pull request — drive it through CI, code review, and QA until merge-ready. Monitors state, fixes ... | `/iterate`, `/verify`, `/babysit` | | jupyter | skill | Read, modify, execute, and convert Jupyter notebooks programmatically. Use when working with .ipynb files for data sc... | — | From 0ccc7355cc860e3192647d684bf5da21b61c174b Mon Sep 17 00:00:00 2001 From: tofarr Date: Fri, 22 May 2026 13:52:58 -0600 Subject: [PATCH 05/11] fix: use configured agent type instead of hardcoded 'Agent' kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both github-repo-monitor and slack-channel-monitor were calling _get_agent_dict() which read the user's LLM config from /api/settings but discarded the configured agent type, hardcoding kind='Agent' instead. 'Agent' is the abstract base class — it only exposes think and finish. 'CodeActAgent' is the concrete implementation with bash, file_editor, browser, and the full action tool suite. Any automation that needs to actually *do* things (run tests, call APIs, edit files) was silently getting a neutered agent. Fix: read agent_settings.agent from /api/settings and use it as the kind, defaulting to 'CodeActAgent' if not set. Co-authored-by: openhands --- skills/github-repo-monitor/scripts/main.py | 6 ++++-- skills/slack-channel-monitor/scripts/main.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/skills/github-repo-monitor/scripts/main.py b/skills/github-repo-monitor/scripts/main.py index 0446821c..c6ec9bb5 100644 --- a/skills/github-repo-monitor/scripts/main.py +++ b/skills/github-repo-monitor/scripts/main.py @@ -367,8 +367,10 @@ def _get_agent_dict(agent_url: str, api_key: str) -> dict: data = json.loads(r.read()) except urllib.error.HTTPError as exc: raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc - llm = data.get("agent_settings", {}).get("llm", {}) - return {"kind": "Agent", "llm": llm} + agent_settings = data.get("agent_settings", {}) + llm = agent_settings.get("llm", {}) + agent_name = agent_settings.get("agent", "CodeActAgent") + return {"kind": agent_name, "llm": llm} def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str: diff --git a/skills/slack-channel-monitor/scripts/main.py b/skills/slack-channel-monitor/scripts/main.py index b0603a47..a3cbf98f 100644 --- a/skills/slack-channel-monitor/scripts/main.py +++ b/skills/slack-channel-monitor/scripts/main.py @@ -309,8 +309,10 @@ def _get_agent_dict(agent_url: str, api_key: str) -> dict: data = json.loads(r.read()) except urllib.error.HTTPError as exc: raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc - llm = data.get("agent_settings", {}).get("llm", {}) - return {"kind": "Agent", "llm": llm} + agent_settings = data.get("agent_settings", {}) + llm = agent_settings.get("llm", {}) + agent_name = agent_settings.get("agent", "CodeActAgent") + return {"kind": agent_name, "llm": llm} def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str: From 0f1d1956f6a840e61091a5eb0d9d258aafc5e450 Mon Sep 17 00:00:00 2001 From: tofarr Date: Fri, 22 May 2026 13:58:54 -0600 Subject: [PATCH 06/11] fix: address review bot suggestions Two suggestions from all-hands-bot code review: 1. pathlib for state file path (PRRT_kwDOQcEru86ENp2h) Replace os.path.dirname/abspath/join/makedirs chain with pathlib.Path operations. Clearer intent and more Pythonic. 2. Differentiated acknowledgement messages (PRRT_kwDOQcEru86ENp2l) Track whether a conversation was freshly created or resumed from a closed state. New conversations get 'OpenHands is on it!'; resumed conversations get 'Resuming work on this {item_type}.' so users are not confused seeing a 'started' message for work that was already in progress. Co-authored-by: openhands --- skills/github-repo-monitor/scripts/main.py | 35 ++++++++++++++-------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/skills/github-repo-monitor/scripts/main.py b/skills/github-repo-monitor/scripts/main.py index c6ec9bb5..4354bb1d 100644 --- a/skills/github-repo-monitor/scripts/main.py +++ b/skills/github-repo-monitor/scripts/main.py @@ -29,6 +29,7 @@ import json import os import sys +from pathlib import Path import time import urllib.error import urllib.request @@ -111,13 +112,13 @@ def _state_file_path() -> str: automation_id = event_payload.get("automation_id", "default") if workspace_base: - root = os.path.dirname(os.path.dirname(os.path.abspath(workspace_base))) + root = Path(workspace_base).resolve().parent.parent else: - root = os.path.expanduser("~/.openhands/workspaces") + root = Path.home() / ".openhands" / "workspaces" - state_dir = os.path.join(root, "automation-state") - os.makedirs(state_dir, exist_ok=True) - return os.path.join(state_dir, f"github_poller_{automation_id}.json") + state_dir = root / "automation-state" + state_dir.mkdir(parents=True, exist_ok=True) + return str(state_dir / f"github_poller_{automation_id}.json") def _default_since() -> str: @@ -507,6 +508,7 @@ def _process_trigger_comment( # ── Case B: closed or missing conversation — create / re-open ───────────── prompt = _build_initial_prompt(ctx, comment, event_type) + resumed = False # If there's a closed conversation, try to re-open it by sending a message. if existing and existing.get("status") == "closed": @@ -521,6 +523,7 @@ def _process_trigger_comment( existing["status"] = "active" existing["last_activity"] = time.time() conv_id_used = conv_id + resumed = True print(f" Re-opened closed conversation {conv_id}") except Exception as exc: print(f" Closed conversation {conv_id} unreachable ({exc}) — creating new") @@ -546,13 +549,21 @@ def _process_trigger_comment( conv_url = f"{openhands_url}/conversations/{conv_id_used}" # Post acknowledgement comment on GitHub. - ack_body = ( - f"🤖 **OpenHands is on it!**\n\n" - f"I've started working on this {item_type}. " - f"View the conversation here: {conv_url}\n\n" - f"_This comment was posted by an AI agent (OpenHands) " - f"in response to a '{TRIGGER_PHRASE}' mention._" - ) + if resumed: + ack_body = ( + f"🤖 **OpenHands is resuming work on this {item_type}.**\n\n" + f"Picking up the existing conversation: {conv_url}\n\n" + f"_This comment was posted by an AI agent (OpenHands) " + f"in response to a '{TRIGGER_PHRASE}' mention._" + ) + else: + ack_body = ( + f"🤖 **OpenHands is on it!**\n\n" + f"I've started working on this {item_type}. " + f"View the conversation here: {conv_url}\n\n" + f"_This comment was posted by an AI agent (OpenHands) " + f"in response to a '{TRIGGER_PHRASE}' mention._" + ) _post_github_comment(github_token, repo, issue_number, ack_body) From 0828d831a3278e1a2d2a2ceecdb93aecd1ed42d0 Mon Sep 17 00:00:00 2001 From: tofarr Date: Fri, 22 May 2026 14:11:06 -0600 Subject: [PATCH 07/11] refactor + test: address second-round review comments Three improvements from all-hands-bot review (PRRT_kwDOQcEru86EOJad, PRRT_kwDOQcEru86EOJaj): 1. Refactor _process_trigger_comment (PRRT_kwDOQcEru86EOJad) Extract two focused helpers: - _ensure_conversation() handles all state transitions: re-opens a closed conversation if reachable, falls back to creating a new one, returns (conv_id, resumed) so the caller knows which path was taken. - _post_acknowledgement() owns the GitHub comment wording. Also extracted _is_bot_comment() and _has_trigger() from the inline poll-loop logic so they are independently testable. Guarded the entry point with if __name__ == '__main__' so the module can be imported by tests without executing main(). 2. Fix load_state for corrupted JSON Wraps json.load() in try/except (json.JSONDecodeError, OSError) and falls back to the default state rather than crashing the run. 3. Add 29-test unit suite (PRRT_kwDOQcEru86EOJaj) tests/test_main.py covers: - load_state: missing file, valid JSON, corrupted JSON, empty file - save_state/load_state round-trip - _is_bot_comment: [bot] suffix, Bot type, human, null/missing user - _has_trigger: exact match, case-insensitive, absent, empty/None body - processed-comment-id persistence across save/load - _ensure_conversation state transitions: new, reopen, fallback-to-new, unknown status, create failure - _post_acknowledgement: new vs resumed wording, trigger phrase footer Co-authored-by: openhands --- skills/github-repo-monitor/scripts/main.py | 191 ++++++----- skills/github-repo-monitor/tests/test_main.py | 303 ++++++++++++++++++ 2 files changed, 421 insertions(+), 73 deletions(-) create mode 100644 skills/github-repo-monitor/tests/test_main.py diff --git a/skills/github-repo-monitor/scripts/main.py b/skills/github-repo-monitor/scripts/main.py index 4354bb1d..cef9fc18 100644 --- a/skills/github-repo-monitor/scripts/main.py +++ b/skills/github-repo-monitor/scripts/main.py @@ -130,8 +130,11 @@ def _default_since() -> str: def load_state(path: str) -> dict: if os.path.exists(path): - with open(path) as f: - return json.load(f) + 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, @@ -407,6 +410,21 @@ def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> s return result.get("response", "") +# ── Comment filtering helpers ───────────────────────────────────────────────── + +def _is_bot_comment(comment: dict) -> bool: + """Return True if the comment was posted by a bot account.""" + user = comment.get("user") or {} + login = user.get("login", "") + return login.endswith("[bot]") or user.get("type") == "Bot" + + +def _has_trigger(comment: dict, phrase: str) -> bool: + """Return True if the comment body contains *phrase* (case-insensitive).""" + body = (comment.get("body") or "").strip() + return phrase.lower() in body.lower() + + # ── Prompt building ──────────────────────────────────────────────────────────── def _build_initial_prompt(ctx: dict, trigger_comment: dict, event_type: str) -> str: @@ -461,6 +479,82 @@ def _build_initial_prompt(ctx: dict, trigger_comment: dict, event_type: str) -> # ── Core event processing ────────────────────────────────────────────────────── +def _ensure_conversation( + agent_url: str, + api_key: str, + conversations: dict[str, dict], + conv_key: str, + issue_number: int, + is_pr: bool, + html_url: str, + prompt: str, + comment: dict, + item_type: str, +) -> tuple[str, bool]: + """Create a new conversation or re-open a closed one. + + Returns ``(conv_id, resumed)`` where *resumed* is True when an existing + closed conversation was successfully re-activated. + Raises on unrecoverable errors so the caller can log and skip. + """ + existing = conversations.get(conv_key) + + if existing and existing.get("status") == "closed": + conv_id = existing["conversation_id"] + author = (comment.get("user") or {}).get("login", "?") + body_text = (comment.get("body") or "").strip() + try: + send_to_conversation( + agent_url, api_key, conv_id, + f"New request on GitHub {item_type} #{issue_number} by @{author}:\n\n{body_text}", + ) + existing["status"] = "active" + existing["last_activity"] = time.time() + print(f" Re-opened closed conversation {conv_id}") + return conv_id, True + except Exception as exc: + print(f" Closed conversation {conv_id} unreachable ({exc}) — creating new") + + conv_id = create_conversation(agent_url, api_key, prompt) + conversations[conv_key] = { + "conversation_id": conv_id, + "issue_number": issue_number, + "issue_type": "pr" if is_pr else "issue", + "html_url": html_url, + "status": "active", + "last_activity": time.time(), + } + print(f" Created conversation {conv_id}") + return conv_id, False + + +def _post_acknowledgement( + github_token: str, + repo: str, + issue_number: int, + item_type: str, + conv_url: str, + resumed: bool, +) -> None: + """Post an acknowledgement comment on the GitHub issue or PR.""" + if resumed: + body = ( + f"🤖 **OpenHands is resuming work on this {item_type}.**\n\n" + f"Picking up the existing conversation: {conv_url}\n\n" + f"_This comment was posted by an AI agent (OpenHands) " + f"in response to a '{TRIGGER_PHRASE}' mention._" + ) + else: + body = ( + f"🤖 **OpenHands is on it!**\n\n" + f"I've started working on this {item_type}. " + f"View the conversation here: {conv_url}\n\n" + f"_This comment was posted by an AI agent (OpenHands) " + f"in response to a '{TRIGGER_PHRASE}' mention._" + ) + _post_github_comment(github_token, repo, issue_number, body) + + def _process_trigger_comment( github_token: str, agent_url: str, @@ -506,65 +600,19 @@ def _process_trigger_comment( print(f" Warning: could not forward to conversation {conv_id}: {exc} — creating new") # Fall through to create a new conversation. - # ── Case B: closed or missing conversation — create / re-open ───────────── + # ── Case B: closed or missing — create / re-open via helper ────────────── prompt = _build_initial_prompt(ctx, comment, event_type) - resumed = False - - # If there's a closed conversation, try to re-open it by sending a message. - if existing and existing.get("status") == "closed": - conv_id = existing["conversation_id"] - author = comment.get("user", {}).get("login", "?") - body_text = (comment.get("body") or "").strip() - try: - send_to_conversation( - agent_url, api_key, conv_id, - f"New request on GitHub {item_type} #{issue_number} by @{author}:\n\n{body_text}", - ) - existing["status"] = "active" - existing["last_activity"] = time.time() - conv_id_used = conv_id - resumed = True - print(f" Re-opened closed conversation {conv_id}") - except Exception as exc: - print(f" Closed conversation {conv_id} unreachable ({exc}) — creating new") - existing = None # fall through to create fresh - - if existing is None or existing.get("status") not in ("active", "closed"): - # Create a brand-new conversation. - try: - conv_id_used = create_conversation(agent_url, api_key, prompt) - conversations[conv_key] = { - "conversation_id": conv_id_used, - "issue_number": issue_number, - "issue_type": "pr" if is_pr else "issue", - "html_url": html_url, - "status": "active", - "last_activity": time.time(), - } - print(f" Created conversation {conv_id_used}") - except Exception as exc: - print(f" Error creating conversation for #{issue_number}: {exc}") - return - - conv_url = f"{openhands_url}/conversations/{conv_id_used}" - - # Post acknowledgement comment on GitHub. - if resumed: - ack_body = ( - f"🤖 **OpenHands is resuming work on this {item_type}.**\n\n" - f"Picking up the existing conversation: {conv_url}\n\n" - f"_This comment was posted by an AI agent (OpenHands) " - f"in response to a '{TRIGGER_PHRASE}' mention._" - ) - else: - ack_body = ( - f"🤖 **OpenHands is on it!**\n\n" - f"I've started working on this {item_type}. " - f"View the conversation here: {conv_url}\n\n" - f"_This comment was posted by an AI agent (OpenHands) " - f"in response to a '{TRIGGER_PHRASE}' mention._" + try: + conv_id, resumed = _ensure_conversation( + agent_url, api_key, conversations, conv_key, + issue_number, is_pr, html_url, prompt, comment, item_type, ) - _post_github_comment(github_token, repo, issue_number, ack_body) + except Exception as exc: + print(f" Error creating conversation for #{issue_number}: {exc}") + return + + conv_url = f"{openhands_url}/conversations/{conv_id}" + _post_acknowledgement(github_token, repo, issue_number, item_type, conv_url, resumed) def _check_conversation_completion( @@ -676,15 +724,11 @@ def main() -> None: if comment_id in processed_set: continue - author_login = (comment.get("user") or {}).get("login", "") - # Skip comments made by bots to avoid feedback loops. - if author_login.endswith("[bot]") or (comment.get("user") or {}).get("type") == "Bot": + if _is_bot_comment(comment): processed_set.add(comment_id) continue - body_text: str = (comment.get("body") or "").strip() - if TRIGGER_PHRASE.lower() not in body_text.lower(): - # Not a trigger comment — mark as seen but don't process. + if not _has_trigger(comment, TRIGGER_PHRASE): processed_set.add(comment_id) continue @@ -719,11 +763,12 @@ def main() -> None: print(f"State saved → {state_path}") -try: - main() - fire_callback("COMPLETED") -except Exception as exc: - import traceback - traceback.print_exc() - fire_callback("FAILED", str(exc)) - sys.exit(1) +if __name__ == "__main__": + try: + main() + fire_callback("COMPLETED") + except Exception as exc: + import traceback + traceback.print_exc() + fire_callback("FAILED", str(exc)) + sys.exit(1) diff --git a/skills/github-repo-monitor/tests/test_main.py b/skills/github-repo-monitor/tests/test_main.py new file mode 100644 index 00000000..6c00b85c --- /dev/null +++ b/skills/github-repo-monitor/tests/test_main.py @@ -0,0 +1,303 @@ +"""Unit tests for github-repo-monitor main.py. + +Run from the skill root: + python -m pytest tests/ +or with the standard library runner: + python -m unittest discover tests +""" + +import json +import os +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch, call + +# Allow importing main.py from the sibling scripts/ directory. +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) +import main # noqa: E402 + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def _make_comment(body="hello @openhands", login="octocat", user_type="User"): + return {"id": 1, "body": body, "user": {"login": login, "type": user_type}, + "issue_url": "https://api.github.com/repos/owner/repo/issues/7"} + + +# ── State file tests ─────────────────────────────────────────────────────────── + +class TestLoadState(unittest.TestCase): + + def test_missing_file_returns_default(self): + state = main.load_state("/nonexistent/path/state.json") + self.assertIn("conversations", state) + self.assertIn("processed_comment_ids", state) + self.assertEqual(state["version"], 1) + + def test_valid_json_is_loaded(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"version": 1, "custom": "value", "conversations": {}}, f) + path = f.name + try: + state = main.load_state(path) + self.assertEqual(state["custom"], "value") + finally: + os.unlink(path) + + def test_corrupted_json_returns_default(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("{this is not valid json!!!}") + path = f.name + try: + state = main.load_state(path) + # Should return the default state rather than raising. + self.assertIn("conversations", state) + self.assertEqual(state["version"], 1) + finally: + os.unlink(path) + + def test_empty_file_returns_default(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("") + path = f.name + try: + state = main.load_state(path) + self.assertIn("conversations", state) + finally: + os.unlink(path) + + +class TestSaveAndLoadRoundtrip(unittest.TestCase): + + def test_roundtrip(self): + data = { + "version": 1, + "conversations": {"42": {"conversation_id": "abc", "status": "active"}}, + "processed_comment_ids": [1, 2, 3], + } + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: + path = f.name + try: + main.save_state(path, data) + loaded = main.load_state(path) + self.assertEqual(loaded["conversations"]["42"]["conversation_id"], "abc") + self.assertEqual(loaded["processed_comment_ids"], [1, 2, 3]) + finally: + os.unlink(path) + + +# ── Bot detection tests ──────────────────────────────────────────────────────── + +class TestIsBotComment(unittest.TestCase): + + def test_login_ends_with_bot_suffix(self): + self.assertTrue(main._is_bot_comment( + {"user": {"login": "dependabot[bot]", "type": "Bot"}} + )) + + def test_login_ends_with_bot_suffix_human_type(self): + # Login suffix alone is sufficient. + self.assertTrue(main._is_bot_comment( + {"user": {"login": "mybot[bot]", "type": "User"}} + )) + + def test_user_type_bot_without_suffix(self): + self.assertTrue(main._is_bot_comment( + {"user": {"login": "AutomationService", "type": "Bot"}} + )) + + def test_human_user_returns_false(self): + self.assertFalse(main._is_bot_comment( + {"user": {"login": "octocat", "type": "User"}} + )) + + def test_missing_user_returns_false(self): + self.assertFalse(main._is_bot_comment({})) + + def test_null_user_returns_false(self): + self.assertFalse(main._is_bot_comment({"user": None})) + + def test_login_containing_but_not_ending_with_bot(self): + # "botuser" does not end with "[bot]" — should be treated as human. + self.assertFalse(main._is_bot_comment( + {"user": {"login": "botuser", "type": "User"}} + )) + + +# ── Trigger phrase tests ─────────────────────────────────────────────────────── + +class TestHasTrigger(unittest.TestCase): + + def test_exact_match(self): + c = _make_comment(body="Please fix this @openhands") + self.assertTrue(main._has_trigger(c, "@openhands")) + + def test_case_insensitive_upper(self): + c = _make_comment(body="Hey @OpenHands can you help?") + self.assertTrue(main._has_trigger(c, "@openhands")) + + def test_case_insensitive_phrase_uppercase(self): + c = _make_comment(body="@openhands please look at this") + self.assertTrue(main._has_trigger(c, "@OPENHANDS")) + + def test_custom_trigger_phrase(self): + c = _make_comment(body="yeehaw! this needs fixing") + self.assertTrue(main._has_trigger(c, "yeehaw!")) + + def test_absent_phrase_returns_false(self): + c = _make_comment(body="Just a regular comment, nothing special") + self.assertFalse(main._has_trigger(c, "@openhands")) + + def test_empty_body_returns_false(self): + c = _make_comment(body="") + self.assertFalse(main._has_trigger(c, "@openhands")) + + def test_none_body_returns_false(self): + c = {"id": 1, "body": None, "user": {"login": "u", "type": "User"}} + self.assertFalse(main._has_trigger(c, "@openhands")) + + def test_missing_body_returns_false(self): + c = {"id": 1, "user": {"login": "u", "type": "User"}} + self.assertFalse(main._has_trigger(c, "@openhands")) + + +# ── Processed-ID deduplication tests ────────────────────────────────────────── + +class TestProcessedIdDeduplication(unittest.TestCase): + """ + The dedup logic lives in main() but the set membership check is trivial. + These tests verify the state schema: processed_comment_ids is persisted + and re-hydrated correctly across simulated runs. + """ + + def test_ids_survive_save_and_load(self): + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: + path = f.name + try: + state = {"version": 1, "conversations": {}, + "processed_comment_ids": [101, 202, 303]} + main.save_state(path, state) + loaded = main.load_state(path) + self.assertIn(101, loaded["processed_comment_ids"]) + self.assertIn(303, loaded["processed_comment_ids"]) + self.assertNotIn(404, loaded["processed_comment_ids"]) + finally: + os.unlink(path) + + +# ── Conversation state transition tests ─────────────────────────────────────── + +class TestEnsureConversation(unittest.TestCase): + """Tests for _ensure_conversation using mocked API calls.""" + + BASE_ARGS = dict( + agent_url="http://agent", + api_key="key", + conv_key="7", + issue_number=7, + is_pr=False, + html_url="https://github.com/owner/repo/issues/7", + prompt="Do something", + comment=_make_comment(), + item_type="issue", + ) + + @patch("main.create_conversation", return_value="new-conv-id") + def test_creates_new_when_no_existing(self, mock_create): + conversations = {} + conv_id, resumed = main._ensure_conversation(conversations=conversations, + **self.BASE_ARGS) + self.assertEqual(conv_id, "new-conv-id") + self.assertFalse(resumed) + mock_create.assert_called_once() + self.assertEqual(conversations["7"]["status"], "active") + + @patch("main.send_to_conversation") + def test_reopens_closed_conversation(self, mock_send): + conversations = { + "7": {"conversation_id": "old-conv-id", "status": "closed", + "issue_number": 7, "last_activity": 0.0} + } + conv_id, resumed = main._ensure_conversation(conversations=conversations, + **self.BASE_ARGS) + self.assertEqual(conv_id, "old-conv-id") + self.assertTrue(resumed) + mock_send.assert_called_once() + self.assertEqual(conversations["7"]["status"], "active") + + @patch("main.create_conversation", return_value="fallback-conv-id") + @patch("main.send_to_conversation", side_effect=RuntimeError("gone")) + def test_fallback_to_new_when_closed_unreachable(self, mock_send, mock_create): + conversations = { + "7": {"conversation_id": "stale-conv-id", "status": "closed", + "issue_number": 7, "last_activity": 0.0} + } + conv_id, resumed = main._ensure_conversation(conversations=conversations, + **self.BASE_ARGS) + self.assertEqual(conv_id, "fallback-conv-id") + self.assertFalse(resumed) + mock_create.assert_called_once() + self.assertEqual(conversations["7"]["status"], "active") + + @patch("main.create_conversation", return_value="brand-new-id") + def test_creates_new_when_status_unknown(self, mock_create): + # An entry with an unrecognised status should be treated as missing. + conversations = { + "7": {"conversation_id": "weird-id", "status": "unknown"} + } + conv_id, resumed = main._ensure_conversation(conversations=conversations, + **self.BASE_ARGS) + self.assertEqual(conv_id, "brand-new-id") + self.assertFalse(resumed) + + @patch("main.create_conversation", side_effect=RuntimeError("API down")) + def test_raises_when_create_fails(self, _mock_create): + conversations = {} + with self.assertRaises(RuntimeError): + main._ensure_conversation(conversations=conversations, **self.BASE_ARGS) + + +# ── Acknowledgement message tests ───────────────────────────────────────────── + +class TestPostAcknowledgement(unittest.TestCase): + + @patch("main._post_github_comment") + def test_new_conversation_message(self, mock_post): + main._post_acknowledgement( + github_token="tok", repo="o/r", issue_number=5, + item_type="issue", conv_url="http://app/conv/1", resumed=False, + ) + body = mock_post.call_args[0][3] + self.assertIn("OpenHands is on it!", body) + self.assertNotIn("resuming", body.lower()) + + @patch("main._post_github_comment") + def test_resumed_conversation_message(self, mock_post): + main._post_acknowledgement( + github_token="tok", repo="o/r", issue_number=5, + item_type="pull request", conv_url="http://app/conv/2", resumed=True, + ) + body = mock_post.call_args[0][3] + self.assertIn("resuming", body.lower()) + self.assertNotIn("OpenHands is on it!", body) + + @patch("main._post_github_comment") + def test_trigger_phrase_in_footer(self, mock_post): + original = main.TRIGGER_PHRASE + main.TRIGGER_PHRASE = "yeehaw!" + try: + main._post_acknowledgement( + github_token="tok", repo="o/r", issue_number=1, + item_type="issue", conv_url="http://x", resumed=False, + ) + body = mock_post.call_args[0][3] + self.assertIn("yeehaw!", body) + finally: + main.TRIGGER_PHRASE = original + + +if __name__ == "__main__": + unittest.main() From bfbe9e0c3439ab2fd01c8d410f9d44ae20c69e3f Mon Sep 17 00:00:00 2001 From: tofarr Date: Fri, 22 May 2026 14:38:18 -0600 Subject: [PATCH 08/11] fix: use 'or' fallback so agent=null in settings defaults to CodeActAgent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dict.get(key, default) only uses the default when the key is ABSENT. /api/settings returns agent_settings.agent=null for most users, so .get('agent', 'CodeActAgent') returns None — producing {kind: null} in the conversation creation payload and a 500 from the server. Fixed in both skills with: .get('agent') or 'CodeActAgent' Added regression tests: TestGetAgentDict (3 cases). Co-authored-by: openhands --- skills/github-repo-monitor/scripts/main.py | 2 +- skills/github-repo-monitor/tests/test_main.py | 41 +++++++++++++++++++ skills/slack-channel-monitor/scripts/main.py | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/skills/github-repo-monitor/scripts/main.py b/skills/github-repo-monitor/scripts/main.py index cef9fc18..ec9027c1 100644 --- a/skills/github-repo-monitor/scripts/main.py +++ b/skills/github-repo-monitor/scripts/main.py @@ -373,7 +373,7 @@ def _get_agent_dict(agent_url: str, api_key: str) -> dict: raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc agent_settings = data.get("agent_settings", {}) llm = agent_settings.get("llm", {}) - agent_name = agent_settings.get("agent", "CodeActAgent") + agent_name = agent_settings.get("agent") or "CodeActAgent" return {"kind": agent_name, "llm": llm} diff --git a/skills/github-repo-monitor/tests/test_main.py b/skills/github-repo-monitor/tests/test_main.py index 6c00b85c..90a135f5 100644 --- a/skills/github-repo-monitor/tests/test_main.py +++ b/skills/github-repo-monitor/tests/test_main.py @@ -299,5 +299,46 @@ def test_trigger_phrase_in_footer(self, mock_post): main.TRIGGER_PHRASE = original +# ── _get_agent_dict tests ────────────────────────────────────────────────────── + +class TestGetAgentDict(unittest.TestCase): + """Regression tests for agent-name resolution from /api/settings.""" + + def _mock_settings(self, agent_value, llm_value=None): + """Return a mock urlopen context manager that yields the given settings.""" + payload = json.dumps({ + "agent_settings": {"agent": agent_value, "llm": llm_value or {}} + }).encode() + mock_resp = MagicMock() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_resp.read = MagicMock(return_value=payload) + return mock_resp + + @patch("urllib.request.urlopen") + def test_null_agent_falls_back_to_codeactagent(self, mock_urlopen): + """agent=null in settings must not propagate as kind=null (regression for 500 error).""" + mock_urlopen.return_value = self._mock_settings(agent_value=None) + result = main._get_agent_dict("http://agent", "key") + self.assertEqual(result["kind"], "CodeActAgent") + + @patch("urllib.request.urlopen") + def test_explicit_agent_name_is_used(self, mock_urlopen): + mock_urlopen.return_value = self._mock_settings(agent_value="BrowsingAgent") + result = main._get_agent_dict("http://agent", "key") + self.assertEqual(result["kind"], "BrowsingAgent") + + @patch("urllib.request.urlopen") + def test_missing_agent_key_falls_back_to_codeactagent(self, mock_urlopen): + payload = json.dumps({"agent_settings": {"llm": {}}}).encode() + mock_resp = MagicMock() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_resp.read = MagicMock(return_value=payload) + mock_urlopen.return_value = mock_resp + result = main._get_agent_dict("http://agent", "key") + self.assertEqual(result["kind"], "CodeActAgent") + + if __name__ == "__main__": unittest.main() diff --git a/skills/slack-channel-monitor/scripts/main.py b/skills/slack-channel-monitor/scripts/main.py index a3cbf98f..b7d1bea7 100644 --- a/skills/slack-channel-monitor/scripts/main.py +++ b/skills/slack-channel-monitor/scripts/main.py @@ -311,7 +311,7 @@ def _get_agent_dict(agent_url: str, api_key: str) -> dict: raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc agent_settings = data.get("agent_settings", {}) llm = agent_settings.get("llm", {}) - agent_name = agent_settings.get("agent", "CodeActAgent") + agent_name = agent_settings.get("agent") or "CodeActAgent" return {"kind": agent_name, "llm": llm} From 21ab2cde80852b26739fa6678f7c3ef191d8fcf7 Mon Sep 17 00:00:00 2001 From: tofarr Date: Fri, 22 May 2026 14:56:14 -0600 Subject: [PATCH 09/11] fix: use kind='Agent' + explicit tools to fix CodeActAgent error and think/finish limitation Two related bugs fixed in both github-repo-monitor and slack-channel-monitor: 1. Wrong default agent kind 'CodeActAgent' is a full-app class not registered in the SDK agent registry. The only valid SDK kinds are 'Agent' and 'ACPAgent'. Change fallback: 'CodeActAgent' -> 'Agent'. 2. No tools in conversation agent dict The SDK's Agent has ONLY FinishTool and ThinkTool by default (see openhands/sdk/tool/builtins/__init__.py: BUILT_IN_TOOLS = [FinishTool, ThinkTool]). Bash and file editing come from openhands-tools and must be explicitly listed in the 'tools' field. Without this, conversations report 'I only have think and finish tools'. Add: tools=[{name:TerminalTool},{name:FileEditorTool}] to agent dict. Updated tests: renamed fallback tests to expect 'Agent' (not 'CodeActAgent'), added test_tools_always_included to guard against regressions on the tools omission. Co-authored-by: openhands --- skills/github-repo-monitor/scripts/main.py | 10 ++++++++-- skills/github-repo-monitor/tests/test_main.py | 19 ++++++++++++++----- skills/slack-channel-monitor/scripts/main.py | 10 ++++++++-- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/skills/github-repo-monitor/scripts/main.py b/skills/github-repo-monitor/scripts/main.py index ec9027c1..35791124 100644 --- a/skills/github-repo-monitor/scripts/main.py +++ b/skills/github-repo-monitor/scripts/main.py @@ -373,8 +373,14 @@ def _get_agent_dict(agent_url: str, api_key: str) -> dict: raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc agent_settings = data.get("agent_settings", {}) llm = agent_settings.get("llm", {}) - agent_name = agent_settings.get("agent") or "CodeActAgent" - return {"kind": agent_name, "llm": llm} + agent_name = agent_settings.get("agent") or "Agent" + return { + "kind": agent_name, + "llm": llm, + # TerminalTool (bash) and FileEditorTool are provided by openhands-tools. + # Without an explicit tools list the SDK Agent defaults to think+finish only. + "tools": [{"name": "TerminalTool"}, {"name": "FileEditorTool"}], + } def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str: diff --git a/skills/github-repo-monitor/tests/test_main.py b/skills/github-repo-monitor/tests/test_main.py index 90a135f5..49f144e1 100644 --- a/skills/github-repo-monitor/tests/test_main.py +++ b/skills/github-repo-monitor/tests/test_main.py @@ -316,11 +316,20 @@ def _mock_settings(self, agent_value, llm_value=None): return mock_resp @patch("urllib.request.urlopen") - def test_null_agent_falls_back_to_codeactagent(self, mock_urlopen): - """agent=null in settings must not propagate as kind=null (regression for 500 error).""" + def test_null_agent_falls_back_to_agent(self, mock_urlopen): + """agent=null in settings must fall back to 'Agent', not propagate as null.""" mock_urlopen.return_value = self._mock_settings(agent_value=None) result = main._get_agent_dict("http://agent", "key") - self.assertEqual(result["kind"], "CodeActAgent") + self.assertEqual(result["kind"], "Agent") + + @patch("urllib.request.urlopen") + def test_tools_always_included(self, mock_urlopen): + """TerminalTool and FileEditorTool must always be present so the agent has bash.""" + mock_urlopen.return_value = self._mock_settings(agent_value=None) + result = main._get_agent_dict("http://agent", "key") + tool_names = [t["name"] for t in result.get("tools", [])] + self.assertIn("TerminalTool", tool_names) + self.assertIn("FileEditorTool", tool_names) @patch("urllib.request.urlopen") def test_explicit_agent_name_is_used(self, mock_urlopen): @@ -329,7 +338,7 @@ def test_explicit_agent_name_is_used(self, mock_urlopen): self.assertEqual(result["kind"], "BrowsingAgent") @patch("urllib.request.urlopen") - def test_missing_agent_key_falls_back_to_codeactagent(self, mock_urlopen): + def test_missing_agent_key_falls_back_to_agent(self, mock_urlopen): payload = json.dumps({"agent_settings": {"llm": {}}}).encode() mock_resp = MagicMock() mock_resp.__enter__ = MagicMock(return_value=mock_resp) @@ -337,7 +346,7 @@ def test_missing_agent_key_falls_back_to_codeactagent(self, mock_urlopen): mock_resp.read = MagicMock(return_value=payload) mock_urlopen.return_value = mock_resp result = main._get_agent_dict("http://agent", "key") - self.assertEqual(result["kind"], "CodeActAgent") + self.assertEqual(result["kind"], "Agent") if __name__ == "__main__": diff --git a/skills/slack-channel-monitor/scripts/main.py b/skills/slack-channel-monitor/scripts/main.py index b7d1bea7..cb73b4bd 100644 --- a/skills/slack-channel-monitor/scripts/main.py +++ b/skills/slack-channel-monitor/scripts/main.py @@ -311,8 +311,14 @@ def _get_agent_dict(agent_url: str, api_key: str) -> dict: raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc agent_settings = data.get("agent_settings", {}) llm = agent_settings.get("llm", {}) - agent_name = agent_settings.get("agent") or "CodeActAgent" - return {"kind": agent_name, "llm": llm} + agent_name = agent_settings.get("agent") or "Agent" + return { + "kind": agent_name, + "llm": llm, + # TerminalTool (bash) and FileEditorTool are provided by openhands-tools. + # Without an explicit tools list the SDK Agent defaults to think+finish only. + "tools": [{"name": "TerminalTool"}, {"name": "FileEditorTool"}], + } def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str: From 7fc0ca64976bdb790766bd57b2c08b1abe6c3741 Mon Sep 17 00:00:00 2001 From: tofarr Date: Fri, 22 May 2026 15:31:12 -0600 Subject: [PATCH 10/11] fix: use registered tool names from OpenAPI spec example ('terminal', 'file_editor') The POST /api/conversations OpenAPI spec has two conflicting naming conventions for tools: - The Tool schema examples use class names: 'TerminalTool', 'FileEditorTool' - The request worked example uses registered names: 'terminal', 'file_editor' We used the class names and got the same '500 / Object of type ValueError is not JSON serializable' crash, which is the server's error-handler bug (it tries json.dumps(caught_ValueError_object) instead of str(e)) making the real cause invisible. Switch to the worked-example names ('terminal', 'file_editor') which match what the running server actually accepts. Co-authored-by: openhands --- skills/github-repo-monitor/scripts/main.py | 4 ++-- skills/github-repo-monitor/tests/test_main.py | 10 +++++++--- skills/slack-channel-monitor/scripts/main.py | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/skills/github-repo-monitor/scripts/main.py b/skills/github-repo-monitor/scripts/main.py index 35791124..29e9e164 100644 --- a/skills/github-repo-monitor/scripts/main.py +++ b/skills/github-repo-monitor/scripts/main.py @@ -377,9 +377,9 @@ def _get_agent_dict(agent_url: str, api_key: str) -> dict: return { "kind": agent_name, "llm": llm, - # TerminalTool (bash) and FileEditorTool are provided by openhands-tools. + # "terminal" and "file_editor" are the runtime-registered tool names. # Without an explicit tools list the SDK Agent defaults to think+finish only. - "tools": [{"name": "TerminalTool"}, {"name": "FileEditorTool"}], + "tools": [{"name": "terminal"}, {"name": "file_editor"}], } diff --git a/skills/github-repo-monitor/tests/test_main.py b/skills/github-repo-monitor/tests/test_main.py index 49f144e1..d76489d1 100644 --- a/skills/github-repo-monitor/tests/test_main.py +++ b/skills/github-repo-monitor/tests/test_main.py @@ -324,12 +324,16 @@ def test_null_agent_falls_back_to_agent(self, mock_urlopen): @patch("urllib.request.urlopen") def test_tools_always_included(self, mock_urlopen): - """TerminalTool and FileEditorTool must always be present so the agent has bash.""" + """terminal and file_editor must always be present so the agent has bash. + + The runtime-registered names ('terminal', 'file_editor') must be used, + not the Python class names ('TerminalTool', 'FileEditorTool'). + """ mock_urlopen.return_value = self._mock_settings(agent_value=None) result = main._get_agent_dict("http://agent", "key") tool_names = [t["name"] for t in result.get("tools", [])] - self.assertIn("TerminalTool", tool_names) - self.assertIn("FileEditorTool", tool_names) + self.assertIn("terminal", tool_names) + self.assertIn("file_editor", tool_names) @patch("urllib.request.urlopen") def test_explicit_agent_name_is_used(self, mock_urlopen): diff --git a/skills/slack-channel-monitor/scripts/main.py b/skills/slack-channel-monitor/scripts/main.py index cb73b4bd..c32df4f6 100644 --- a/skills/slack-channel-monitor/scripts/main.py +++ b/skills/slack-channel-monitor/scripts/main.py @@ -315,9 +315,9 @@ def _get_agent_dict(agent_url: str, api_key: str) -> dict: return { "kind": agent_name, "llm": llm, - # TerminalTool (bash) and FileEditorTool are provided by openhands-tools. + # "terminal" and "file_editor" are the runtime-registered tool names. # Without an explicit tools list the SDK Agent defaults to think+finish only. - "tools": [{"name": "TerminalTool"}, {"name": "FileEditorTool"}], + "tools": [{"name": "terminal"}, {"name": "file_editor"}], } From 067e6f77657b8bd5b0ee7c17bd5e22eb2b857bf3 Mon Sep 17 00:00:00 2001 From: tofarr Date: Fri, 22 May 2026 15:56:36 -0600 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20never=20forward=20settings=20agent?= =?UTF-8?q?=20name=20to=20SDK=20=E2=80=94=20hardcode=20kind=3D'Agent'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: /api/settings returns the full-app agent registry name (e.g. 'CodeActAgent', 'BrowsingAgent') in agent_settings.agent. The automation SDK is an entirely separate runtime that only accepts ['ACPAgent', 'Agent'] as valid kinds. The previous 'or "Agent"' fallback only guarded against null/missing values — a truthy 'CodeActAgent' sailed straight through, producing: 'Unknown kind CodeActAgent for AgentBase; Expected one of: [...]' Fix: remove the settings agent name lookup entirely. Hardcode 'Agent'. Update test_explicit_agent_name_is_used → test_full_app_agent_name_not_forwarded to assert that all app-registry names (CodeActAgent, BrowsingAgent, …) are rejected rather than forwarded. Co-authored-by: openhands --- skills/github-repo-monitor/scripts/main.py | 6 ++++-- skills/github-repo-monitor/tests/test_main.py | 16 ++++++++++++---- skills/slack-channel-monitor/scripts/main.py | 6 ++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/skills/github-repo-monitor/scripts/main.py b/skills/github-repo-monitor/scripts/main.py index 29e9e164..c5125e03 100644 --- a/skills/github-repo-monitor/scripts/main.py +++ b/skills/github-repo-monitor/scripts/main.py @@ -373,9 +373,11 @@ def _get_agent_dict(agent_url: str, api_key: str) -> dict: raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc agent_settings = data.get("agent_settings", {}) llm = agent_settings.get("llm", {}) - agent_name = agent_settings.get("agent") or "Agent" + # settings["agent_settings"]["agent"] reflects the full-app agent registry + # (e.g. "CodeActAgent", "BrowsingAgent"). The automation SDK is a separate + # runtime whose only valid kind is "Agent" — never forward that value. return { - "kind": agent_name, + "kind": "Agent", "llm": llm, # "terminal" and "file_editor" are the runtime-registered tool names. # Without an explicit tools list the SDK Agent defaults to think+finish only. diff --git a/skills/github-repo-monitor/tests/test_main.py b/skills/github-repo-monitor/tests/test_main.py index d76489d1..f5b4c208 100644 --- a/skills/github-repo-monitor/tests/test_main.py +++ b/skills/github-repo-monitor/tests/test_main.py @@ -336,10 +336,18 @@ def test_tools_always_included(self, mock_urlopen): self.assertIn("file_editor", tool_names) @patch("urllib.request.urlopen") - def test_explicit_agent_name_is_used(self, mock_urlopen): - mock_urlopen.return_value = self._mock_settings(agent_value="BrowsingAgent") - result = main._get_agent_dict("http://agent", "key") - self.assertEqual(result["kind"], "BrowsingAgent") + def test_full_app_agent_name_not_forwarded(self, mock_urlopen): + """Full-app agent names (CodeActAgent, BrowsingAgent, …) must not be forwarded. + + settings["agent_settings"]["agent"] belongs to the full OpenHands app + registry. The automation SDK only accepts 'Agent' / 'ACPAgent'. + Forwarding 'CodeActAgent' causes a 500 with 'Unknown kind' in production. + """ + for app_agent in ("CodeActAgent", "BrowsingAgent", "SomeFutureAgent"): + with self.subTest(app_agent=app_agent): + mock_urlopen.return_value = self._mock_settings(agent_value=app_agent) + result = main._get_agent_dict("http://agent", "key") + self.assertEqual(result["kind"], "Agent") @patch("urllib.request.urlopen") def test_missing_agent_key_falls_back_to_agent(self, mock_urlopen): diff --git a/skills/slack-channel-monitor/scripts/main.py b/skills/slack-channel-monitor/scripts/main.py index c32df4f6..97fe5641 100644 --- a/skills/slack-channel-monitor/scripts/main.py +++ b/skills/slack-channel-monitor/scripts/main.py @@ -311,9 +311,11 @@ def _get_agent_dict(agent_url: str, api_key: str) -> dict: raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc agent_settings = data.get("agent_settings", {}) llm = agent_settings.get("llm", {}) - agent_name = agent_settings.get("agent") or "Agent" + # settings["agent_settings"]["agent"] reflects the full-app agent registry + # (e.g. "CodeActAgent", "BrowsingAgent"). The automation SDK is a separate + # runtime whose only valid kind is "Agent" — never forward that value. return { - "kind": agent_name, + "kind": "Agent", "llm": llm, # "terminal" and "file_editor" are the runtime-registered tool names. # Without an explicit tools list the SDK Agent defaults to think+finish only.