@openhands/extensions
Advanced tools
| module.exports = async ({ github, context, core }) => { | ||
| const issueNumber = Number(process.env.ISSUE_NUMBER); | ||
| const summary = (process.env.SUMMARY || "").trim(); | ||
| const classification = process.env.CLASSIFICATION || "no-match"; | ||
| const autoClose = process.env.AUTO_CLOSE_CANDIDATE === "true"; | ||
| const closeAfterDays = process.env.CLOSE_AFTER_DAYS || "3"; | ||
| let candidates = []; | ||
| try { | ||
| candidates = JSON.parse(process.env.CANDIDATE_ISSUES_JSON || "[]"); | ||
| } catch (error) { | ||
| core.setFailed(`Invalid candidate JSON: ${error.message}`); | ||
| return; | ||
| } | ||
| if (!Array.isArray(candidates)) { | ||
| core.setFailed("CANDIDATE_ISSUES_JSON is not an array"); | ||
| return; | ||
| } | ||
| if (candidates.length === 0) { | ||
| core.warning(`No candidate issues were returned for issue #${issueNumber}; skipping.`); | ||
| return; | ||
| } | ||
| const canonicalIssueRaw = process.env.CANONICAL_ISSUE_NUMBER || candidates[0].number; | ||
| const canonicalIssueNumber = canonicalIssueRaw ? Number(canonicalIssueRaw) : Number.NaN; | ||
| const candidateLabel = "duplicate-candidate"; | ||
| function parseDuplicateCheckMarker(body) { | ||
| if (!body) return null; | ||
| const match = body.match(/<!-- openhands-duplicate-check canonical=(\d+) auto-close=(true|false) -->/); | ||
| if (!match) return null; | ||
| return { | ||
| canonicalIssueNumber: Number(match[1]), | ||
| autoClose: match[2] === "true", | ||
| }; | ||
| } | ||
| async function ensureCanonicalIssueIsOpenIssue() { | ||
| let canonicalIssue; | ||
| try { | ||
| ({ data: canonicalIssue } = await github.rest.issues.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: canonicalIssueNumber, | ||
| })); | ||
| } catch (error) { | ||
| if (error.status === 404) { | ||
| core.setFailed(`Canonical issue #${canonicalIssueNumber} does not exist.`); | ||
| return false; | ||
| } | ||
| throw error; | ||
| } | ||
| if (canonicalIssue.pull_request) { | ||
| core.setFailed(`Canonical issue #${canonicalIssueNumber} is a pull request, not an issue.`); | ||
| return false; | ||
| } | ||
| if (canonicalIssue.state !== "open" || canonicalIssue.locked) { | ||
| core.setFailed(`Canonical issue #${canonicalIssueNumber} must be an open, unlocked issue.`); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| async function ensureCandidateLabelOnIssue() { | ||
| try { | ||
| await github.rest.issues.getLabel({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| name: candidateLabel, | ||
| }); | ||
| } catch (error) { | ||
| if (error.status !== 404) throw error; | ||
| await github.rest.issues.createLabel({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| name: candidateLabel, | ||
| color: "f97316", | ||
| description: "Potential duplicate awaiting auto-close or maintainer review", | ||
| }); | ||
| } | ||
| const { data: issue } = await github.rest.issues.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| }); | ||
| const labelNames = (issue.labels || []).map((label) => | ||
| typeof label === "string" ? label : label.name, | ||
| ); | ||
| if (!labelNames.includes(candidateLabel)) { | ||
| await github.rest.issues.addLabels({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| labels: [candidateLabel], | ||
| }); | ||
| } | ||
| } | ||
| async function removeCandidateLabelFromIssue() { | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| name: candidateLabel, | ||
| }); | ||
| } catch (error) { | ||
| if (error.status !== 404) throw error; | ||
| } | ||
| } | ||
| if (!Number.isInteger(canonicalIssueNumber) || canonicalIssueNumber <= 0) { | ||
| core.setFailed(`No canonical issue number was returned for issue #${issueNumber}.`); | ||
| return; | ||
| } | ||
| if (canonicalIssueNumber === issueNumber) { | ||
| core.setFailed(`Duplicate check cannot mark issue #${issueNumber} as a duplicate of itself.`); | ||
| return; | ||
| } | ||
| if (!(await ensureCanonicalIssueIsOpenIssue())) return; | ||
| const marker = `<!-- openhands-duplicate-check canonical=${canonicalIssueNumber} auto-close=${autoClose ? "true" : "false"} -->`; | ||
| const header = candidates.length === 1 | ||
| ? "Found 1 possible duplicate issue:" | ||
| : `Found ${candidates.length} possible duplicate issues:`; | ||
| const candidateLines = candidates.map((candidate, index) => | ||
| `${index + 1}. [#${candidate.number}](${candidate.url}) — ${candidate.title}`, | ||
| ); | ||
| const sections = []; | ||
| if (summary) sections.push(summary, ""); | ||
| sections.push(header, "", ...candidateLines); | ||
| if (classification === "overlapping-scope") { | ||
| sections.push( | ||
| "", | ||
| "These may not be exact duplicates, but the scope appears to overlap enough that keeping discussion in one place may be more useful.", | ||
| ); | ||
| } | ||
| if (autoClose) { | ||
| sections.push( | ||
| "", | ||
| `This issue will be automatically closed as a duplicate in ${closeAfterDays} days.`, | ||
| "", | ||
| "- If your issue is a duplicate, please close it and 👍 the existing issue instead", | ||
| "- To prevent auto-closure, add a comment or 👎 this comment", | ||
| ); | ||
| } | ||
| sections.push( | ||
| "", | ||
| marker, | ||
| "_This comment was created by an AI assistant (OpenHands) on behalf of the repository maintainer._", | ||
| ); | ||
| const body = sections.join("\n").trim(); | ||
| const maxCommentPages = 50; | ||
| let allComments = []; | ||
| let page = 1; | ||
| while (page <= maxCommentPages) { | ||
| const { data: comments } = await github.rest.issues.listComments({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| per_page: 100, | ||
| page, | ||
| }); | ||
| if (!comments || comments.length === 0) break; | ||
| allComments = allComments.concat(comments); | ||
| if (comments.length < 100) break; | ||
| page += 1; | ||
| } | ||
| if (page > maxCommentPages) { | ||
| core.setFailed(`Stopped loading comments for issue #${issueNumber} after ${maxCommentPages} pages.`); | ||
| return; | ||
| } | ||
| const existing = allComments.find((comment) => | ||
| comment.body && comment.body.includes("<!-- openhands-duplicate-check "), | ||
| ); | ||
| if (existing) { | ||
| const existingMarker = parseDuplicateCheckMarker(existing.body); | ||
| if (existingMarker) { | ||
| if ( | ||
| existingMarker.canonicalIssueNumber !== canonicalIssueNumber || | ||
| existingMarker.autoClose !== autoClose | ||
| ) { | ||
| await github.rest.issues.updateComment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| comment_id: existing.id, | ||
| body, | ||
| }); | ||
| if (autoClose) await ensureCandidateLabelOnIssue(); | ||
| else await removeCandidateLabelFromIssue(); | ||
| core.info(`Updated existing duplicate check comment ${existing.id} on issue #${issueNumber}.`); | ||
| return; | ||
| } | ||
| if (autoClose) await ensureCandidateLabelOnIssue(); | ||
| else await removeCandidateLabelFromIssue(); | ||
| } else { | ||
| core.warning( | ||
| `Duplicate check comment already exists on issue #${issueNumber} but its marker could not be parsed; leaving label state unchanged.`, | ||
| ); | ||
| } | ||
| core.info(`Duplicate check comment already exists on issue #${issueNumber}; skipping.`); | ||
| return; | ||
| } | ||
| await github.rest.issues.createComment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| body, | ||
| }); | ||
| if (autoClose) await ensureCandidateLabelOnIssue(); | ||
| }; |
| module.exports = async ({ github, context, core }) => { | ||
| const issueNumber = context.issue.number; | ||
| const commenter = context.payload.comment?.user?.login ?? ""; | ||
| const normalizedCommenter = commenter.toLowerCase(); | ||
| if (normalizedCommenter.endsWith("[bot]") || normalizedCommenter === "all-hands-bot") { | ||
| core.info(`Skipping duplicate-candidate label removal for bot comment from ${commenter || "unknown"}`); | ||
| return; | ||
| } | ||
| core.info(`Removing duplicate-candidate label from issue #${issueNumber} after comment from ${commenter}`); | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| name: "duplicate-candidate", | ||
| }); | ||
| } catch (error) { | ||
| if (error.status === 404) { | ||
| core.info(`duplicate-candidate label was already removed from issue #${issueNumber}`); | ||
| return; | ||
| } | ||
| throw error; | ||
| } | ||
| }; |
| { | ||
| "name": "github-actions", | ||
| "version": "1.0.0", | ||
| "description": "Create, debug, and test GitHub Actions workflows and custom actions. Use when building CI/CD pipelines, automating workflows, or troubleshooting GitHub Actions.", | ||
| "author": { | ||
| "name": "OpenHands", | ||
| "email": "contact@all-hands.dev" | ||
| }, | ||
| "homepage": "https://github.com/OpenHands/extensions", | ||
| "repository": "https://github.com/OpenHands/extensions", | ||
| "license": "MIT", | ||
| "keywords": [ | ||
| "github-actions", | ||
| "workflows", | ||
| "ci-cd", | ||
| "automation", | ||
| "actions" | ||
| ] | ||
| } |
| # GitHub Actions Skill | ||
| A skill focused on **effectiveness** when working with GitHub Actions — monitoring runs, reading logs, and developing actions with confidence rather than guessing. | ||
| ## Overview | ||
| This skill provides guidance for: | ||
| - Creating and structuring workflows | ||
| - Building custom composite and reusable actions | ||
| - Testing actions locally (before burning CI minutes) and in CI | ||
| - Debugging failed workflows from real evidence (logs), not assumptions | ||
| - Avoiding common pitfalls around permissions, secrets, and fork PRs | ||
| ## When to Use This Skill | ||
| - Creating new GitHub Actions workflows | ||
| - Building custom composite or reusable actions | ||
| - Debugging workflow failures | ||
| - Setting up CI/CD pipelines | ||
| - Troubleshooting permission or secret issues | ||
| - Testing actions before merging to main | ||
| ## Cost Awareness | ||
| Every workflow run consumes CI minutes (billed minutes on private repos, shared capacity on public ones). | ||
| Plan before you push: | ||
| - Prefer one targeted commit that exercises the change over multiple speculative pushes | ||
| - Use `workflow_dispatch` with inputs to manually trigger only what you need | ||
| - Use `paths:` filters and `if:` conditions so jobs only run when relevant | ||
| - Run with `act` locally before opening a PR when feasible | ||
| - Cancel obsolete runs with `concurrency:` groups (don't pay for runs you've already invalidated) | ||
| - Use the smallest matrix that proves the change works; expand only if needed | ||
| ## Develop With Confidence: The Loop | ||
| Don't change → push → hope. Use this loop: | ||
| 1. **Read** the existing workflow / action carefully. Note triggers, permissions, inputs, env, and secrets. | ||
| 2. **Reproduce locally** with `act` when possible (see below). | ||
| 3. **Add visibility** — debug steps that print non-secret inputs, refs, and intermediate state. | ||
| 4. **Push a single focused commit**, then watch the run live. | ||
| 5. **Read the full failed-job log** (not just the summary) before editing. | ||
| 6. **Form a hypothesis from the log**, change the smallest thing that tests it, push, watch again. | ||
| ## Monitoring Runs With `gh` | ||
| ```bash | ||
| # Watch the most recent run from the current branch in real time | ||
| gh run watch | ||
| # Watch a specific run | ||
| gh run watch <run-id> | ||
| # Auto-refresh PR checks until they finish (every 10s) | ||
| gh pr checks <pr-number> --watch --interval 10 | ||
| # List recent runs for the repo / branch | ||
| gh run list --branch <branch> --limit 10 | ||
| # Get the run ID for the latest run on a branch | ||
| gh run list --branch <branch> --limit 1 --json databaseId --jq '.[0].databaseId' | ||
| ``` | ||
| ## Reading Logs (Don't Guess) | ||
| ```bash | ||
| # Full log for a run | ||
| gh run view <run-id> --log | ||
| # Just the failed steps (much shorter) | ||
| gh run view <run-id> --log-failed | ||
| # Log for a specific job (useful for matrix builds) | ||
| gh run view <run-id> --log --job <job-id> | ||
| # List jobs for a run to find the job ID | ||
| gh run view <run-id> --json jobs --jq '.jobs[] | {id, name, conclusion}' | ||
| # Rerun only the failed jobs (avoid paying to rerun green ones) | ||
| gh run rerun <run-id> --failed | ||
| ``` | ||
| When a step fails, look for: | ||
| - The exact command that exited non-zero (above the `##[error]` line) | ||
| - The shell — bash on Linux, `pwsh` on Windows runners — error messages differ | ||
| - Values printed by your debug step; compare against what the workflow *thought* it was passing in | ||
| ## Adding Visibility to Actions | ||
| When creating a new action or hitting a tricky bug, add debug steps. Don't leave them in indefinitely — remove or guard them with `if: runner.debug == '1'` once the issue is understood. | ||
| ```yaml | ||
| steps: | ||
| # Print non-secret inputs and context at the start | ||
| - name: Debug - inputs and context | ||
| if: runner.debug == '1' || inputs.debug == 'true' | ||
| run: | | ||
| echo "Event: ${{ github.event_name }}" | ||
| echo "Ref: ${{ github.ref }}" | ||
| echo "SHA: ${{ github.sha }}" | ||
| echo "Actor: ${{ github.actor }}" | ||
| echo "PWD: $(pwd)" | ||
| echo "Input.my-param: ${{ inputs.my-param }}" | ||
| echo "Files:" | ||
| ls -la | ||
| # ... your real steps ... | ||
| # Verify outcome before the job ends | ||
| - name: Debug - verify outputs | ||
| if: always() && (runner.debug == '1' || inputs.debug == 'true') | ||
| run: | | ||
| echo "Generated files:" | ||
| ls -la dist/ || echo "dist/ not found" | ||
| ``` | ||
| To enable `runner.debug == '1'` for a single run, re-run the workflow with **"Enable debug logging"** checked, or set the repo secret `ACTIONS_RUNNER_DEBUG=true`. | ||
| **Never** echo `${{ secrets.* }}` — GitHub masks them, but encoded forms (base64, hex, JSON-wrapped) can leak through. | ||
| ## Testing Locally With `act` | ||
| `act` ([nektos/act](https://github.com/nektos/act)) runs workflows in Docker locally (Docker must be installed and running). Use it to iterate without burning CI minutes. | ||
| ```bash | ||
| # Run the default push event | ||
| act | ||
| # Simulate a pull_request event | ||
| act pull_request | ||
| # Run a specific job | ||
| act -j build | ||
| # Pass secrets | ||
| act -s GITHUB_TOKEN="$GITHUB_TOKEN" | ||
| # Use a larger image closer to GitHub's runner | ||
| act -P ubuntu-latest=catthehacker/ubuntu:full-latest | ||
| ``` | ||
| Caveats: `act` does not perfectly emulate GitHub's runners. Things that may differ: pre-installed tools, `GITHUB_TOKEN` permissions, OIDC, the `secrets.GITHUB_TOKEN` value, and macOS/Windows runners. Treat green-on-`act` as a strong signal, not a guarantee. | ||
| ## Testing Custom Actions Requires Merge to Main | ||
| A repository-local custom action referenced as `uses: ./.github/actions/my-action` works from any branch, but: | ||
| - A custom action published as `uses: owner/repo/action@ref` must have the action's files **on `ref`** to be resolvable | ||
| - `@main` or `@v1` must already exist when the workflow that consumes it runs | ||
| - This is why a brand-new action typically must land on `main` first, then be consumed from feature branches that reference `@main` (or a tag) | ||
| If you need to iterate on an action and its consumer together, point `uses:` at a SHA on your branch, or develop the action via the local `./.github/actions/…` path until it's stable. | ||
| ## Common Pitfalls | ||
| 1. **Custom action not found from a PR** — the action's ref doesn't exist yet. Merge to `main` (or push a tag), then consume. | ||
| 2. **`GITHUB_TOKEN` permissions** — defaults can be read-only. Set `permissions:` explicitly at the workflow or job level. | ||
| 3. **`pull_request` vs `pull_request_target`** — `pull_request` runs in the fork's context (no secrets); `pull_request_target` runs with repo secrets against the base — **dangerous** if you check out PR code and execute it. Don't. | ||
| 4. **Secrets in fork PRs** — even non-`*_target` workflows hide secrets from forks. Don't design workflows that require secrets to run on community PRs. | ||
| 5. **`schedule:` triggers only fire from the default branch** — a `schedule:` trigger added in a PR only fires after it lands on the default branch; `workflow_dispatch` similarly won't appear in the Actions tab UI until merged to default (though it can be triggered via API on any branch); `pull_request`-triggered workflows do run on the PR that introduces them. | ||
| 6. **Path filters are OR, not AND** — any matching path triggers; you can't require all paths. | ||
| 7. **Matrix `fail-fast: true`** (default) cancels other matrix legs on first failure — turn off when you need full coverage. | ||
| 8. **Artifacts** — files don't persist between jobs unless uploaded via `actions/upload-artifact` and downloaded in the consuming job. | ||
| 9. **`env:` scope** — env at workflow, job, and step levels all exist. Step-level shadows job-level shadows workflow-level. Surprises follow if you forget. | ||
| 10. **Pin versions** — use `@v4` (or better, a SHA) rather than `@main`. Floating refs break builds when upstream changes. | ||
| ## Best Practices | ||
| 1. **Pin action versions** — `@v4` or SHA, not `@main` | ||
| 2. **Least privilege** — set `permissions:` to the minimum needed | ||
| 3. **Debug steps when introducing or debugging** — remove or gate them with `runner.debug` afterward | ||
| 4. **Cache dependencies** — `actions/cache` saves real minutes | ||
| 5. **Concurrency** — cancel obsolete runs: | ||
| ```yaml | ||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: true | ||
| ``` | ||
| 6. **Timeouts** — set `timeout-minutes:` on jobs that can hang | ||
| 7. **Fail loudly** — `set -euo pipefail` in non-trivial bash steps | ||
| 8. **Document non-obvious triggers/inputs** at the top of the workflow file | ||
| ## Resources | ||
| - [GitHub Actions documentation](https://docs.github.com/en/actions) | ||
| - [`act` — run actions locally](https://github.com/nektos/act) | ||
| - [`gh run` CLI reference](https://cli.github.com/manual/gh_run) | ||
| - [github skill](https://github.com/OpenHands/extensions/tree/main/skills/github) — GitHub API, PRs, issues, and repos | ||
| - [github-pr-review skill](https://github.com/OpenHands/extensions/tree/main/skills/github-pr-review) — post structured code reviews via the GitHub API |
| --- | ||
| name: github-actions | ||
| description: Create, debug, and test GitHub Actions workflows and custom actions. Use when building CI/CD pipelines, automating workflows, or troubleshooting GitHub Actions. | ||
| triggers: | ||
| - github actions | ||
| - github workflow | ||
| - actions workflow | ||
| - gh actions | ||
| - .github/workflows | ||
| --- | ||
| # GitHub Actions Guide | ||
| ## Critical Rules | ||
| **Custom Action Deployment:** | ||
| - New custom actions MUST be merged to the main branch before they can be used | ||
| - After the initial merge, they should be tested from feature branches | ||
| **Debug Steps:** | ||
| Add debug steps that print non-secret parameters when: | ||
| - Creating a new action, OR | ||
| - Troubleshooting a particularly tricky issue | ||
| (Not required for every workflow - use when needed) | ||
| ## Effectiveness Principles | ||
| Actions cost CI minutes. Be deliberate, not iterative: | ||
| 1. **Monitor, don't poll** - use `gh run watch` / `gh pr checks --watch` to follow runs live | ||
| 2. **Read logs, don't guess** - fetch the failed job's log before changing code | ||
| 3. **Print actual values** - debug steps reveal the real `inputs`/`github` context, not your assumptions | ||
| 4. **Test locally first** - `act` runs workflows on your machine and avoids burning CI minutes | ||
| 5. **Plan the smallest reproduction** - one job, minimal matrix, narrow trigger before scaling up | ||
| See [README.md](README.md) for the full debugging workflow, `gh` commands, and YAML debug-step examples. | ||
| ## Key Gotchas | ||
| 1. **Secrets unavailable in fork PRs** - `pull_request` has no secrets for forks; `pull_request_target` does but **never check out or execute fork PR code inside it** (RCE with write permissions) | ||
| 2. **Pin action versions** - Use `@v4` or SHA, not `@main` (prevents breaking changes) | ||
| 3. **Explicit permissions** - Set `permissions:` block for GITHUB_TOKEN operations | ||
| 4. **Artifacts for job-to-job data** - Files don't persist between jobs without `upload-artifact`/`download-artifact` |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: Create an automation that reviews GitHub pull requests when they are opened or updated. Inspects the diff, changed files, tests, and existing discussion via GitHub MCP, then posts a concise review highlighting risks, security issues, missing tests, and next steps. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| 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. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: Create an automation that drafts incident retrospectives. Gathers incident-channel messages from Slack, collects linked tickets and follow-ups from Linear, and publishes a retrospective draft to Notion with a timeline, impact summary, root-cause hypotheses, and action items. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: Create an automation that triages new Linear issues. Inspects the issue title, description, team, customer, priority, and recent related issues via Linear MCP. Suggests labels, priority, likely owner, duplicates, and posts a clarifying comment. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: This skill should be used when the user asks to "create an automation", "schedule a task", "set up a cron job", "webhook integration", "event-triggered automation", or mentions automations, scheduled tasks, cron scheduling, or webhook events in OpenHands Cloud. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: Create an automation that writes a recurring research brief. Uses Tavily MCP for web research and Notion MCP to publish the final brief with executive summary, implications, and source citations. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: This skill should be used when the user asks to "monitor a Slack channel", "watch Slack for messages", "create a Slack bot that responds to mentions", "set up an OpenHands Slack integration", "trigger OpenHands from Slack", "respond to @openhands in Slack", or "poll Slack channels for a trigger phrase". Guides the user through creating a cron automation that watches up to 10 Slack channels and starts an OpenHands conversation whenever a configurable trigger phrase is detected. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: Create an automation that generates an async standup digest from Slack. Searches selected channels for messages since the previous workday, groups updates by project, highlights blockers and decisions, and posts a summary to a target channel. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| { | ||
| ".": "0.3.0" | ||
| ".": "0.4.0" | ||
| } |
| { | ||
| "id": "github-pr-reviewer", | ||
| "name": "GitHub PR review copilot", | ||
| "name": "GitHub Code Review Agent", | ||
| "category": "Code review", | ||
@@ -5,0 +5,0 @@ "description": "Watch pull requests, inspect the diff, and leave a concise review with risks and suggested follow-ups.", |
@@ -10,3 +10,3 @@ { | ||
| "prompt": "/github-monitor:poll", | ||
| "exampleImplementation": "Trigger: cron, every minute (configurable)\nRequired secret: GITHUB_TOKEN\n\n1. Poll GitHub for new issue and PR comments since the last run.\n2. Match comments containing the trigger phrase (case-insensitive, default: @OpenHands).\n3. Post an acknowledgment comment with a link to the new OpenHands conversation.\n4. Forward follow-up replies in the same thread to the running conversation.\n5. Post the agent's final response back to GitHub when the conversation completes." | ||
| "exampleImplementation": "Trigger: cron, every minute (configurable)\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN\n\n1. Poll GitHub for new issue and PR comments since the last run.\n2. Match comments containing the trigger phrase (case-insensitive, default: @OpenHands).\n3. Post an acknowledgment comment with a link to the new OpenHands conversation.\n4. Forward follow-up replies in the same thread to the running conversation.\n5. Post the agent's final response back to GitHub when the conversation completes." | ||
| } |
@@ -242,2 +242,15 @@ { | ||
| { | ||
| "name": "github-actions", | ||
| "source": "./skills/github-actions", | ||
| "description": "Create, debug, and test GitHub Actions workflows and custom actions. Use when building CI/CD pipelines, automating workflows, or troubleshooting GitHub Actions.", | ||
| "category": "integration", | ||
| "keywords": [ | ||
| "github-actions", | ||
| "workflows", | ||
| "ci-cd", | ||
| "automation", | ||
| "actions" | ||
| ] | ||
| }, | ||
| { | ||
| "name": "github-pr-review", | ||
@@ -244,0 +257,0 @@ "source": "./skills/github-pr-review", |
+1
-1
| { | ||
| "name": "@openhands/extensions", | ||
| "version": "0.3.0", | ||
| "version": "0.4.0", | ||
| "description": "Public OpenHands extension catalogs for skills, plugins, integrations, and automation templates.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
@@ -261,3 +261,3 @@ --- | ||
| env: | ||
| ACTION_SCRIPT: ${{ github.action_path }}/scripts/post_duplicate_notice.js | ||
| ACTION_SCRIPT: ${{ github.action_path }}/scripts/post_duplicate_notice.cjs | ||
| ISSUE_NUMBER: ${{ inputs.issue-number }} | ||
@@ -345,3 +345,3 @@ SUMMARY: ${{ steps.parsed_result.outputs.summary }} | ||
| env: | ||
| ACTION_SCRIPT: ${{ github.action_path }}/scripts/remove_duplicate_candidate_label.js | ||
| ACTION_SCRIPT: ${{ github.action_path }}/scripts/remove_duplicate_candidate_label.cjs | ||
| with: | ||
@@ -348,0 +348,0 @@ github-token: ${{ inputs.github-token }} |
@@ -72,3 +72,3 @@ # PR Review Plugin | ||
| **Note**: For repositories that need to post review comments from a bot account, use `ALLHANDS_BOT_GITHUB_PAT` instead of `GITHUB_TOKEN`. | ||
| **Note**: To post review comments from a bot account instead of `GITHUB_TOKEN`, use a bot token scoped to the repository's trust boundary. On **public** repositories, use `OPENHANDS_BOT_GITHUB_PAT_PUBLIC` — a fine-grained token limited to public repositories. On **private** repositories, use a bot token scoped to that repository. Never give a public repository a token that can reach private repositories. | ||
@@ -75,0 +75,0 @@ ### 3. Customize the Workflow (Optional) |
@@ -122,2 +122,4 @@ --- | ||
| GITHUB_TOKEN: ${{ inputs.github-token }} | ||
| GITHUB_EVENT_PULL_REQUEST_TITLE: ${{ github.event.pull_request.title }} | ||
| INPUTS_LLM_MODEL: ${{ inputs.llm-model }} | ||
| run: | | ||
@@ -134,5 +136,5 @@ if [ -z "$LLM_API_KEY" ]; then | ||
| echo "PR Number: ${{ github.event.pull_request.number }}" | ||
| echo "PR Title: ${{ github.event.pull_request.title }}" | ||
| echo "PR Title: $GITHUB_EVENT_PULL_REQUEST_TITLE" | ||
| echo "Repository: ${{ github.repository }}" | ||
| echo "LLM model: ${{ inputs.llm-model }}" | ||
| echo "LLM model: $INPUTS_LLM_MODEL" | ||
@@ -139,0 +141,0 @@ - name: Run QA validation |
@@ -112,7 +112,8 @@ --- | ||
| GITHUB_TOKEN: ${{ inputs.github-token }} | ||
| INPUTS_TAG: ${{ inputs.tag }} | ||
| run: | | ||
| if [ -f release_notes.md ]; then | ||
| echo "Updating release notes for tag ${{ inputs.tag }}" | ||
| gh release edit "${{ inputs.tag }}" --notes-file release_notes.md || \ | ||
| echo "Updating release notes for tag $INPUTS_TAG" | ||
| gh release edit "$INPUTS_TAG" --notes-file release_notes.md || \ | ||
| echo "Note: Could not update release. Release may not exist yet." | ||
| fi |
@@ -41,7 +41,10 @@ --- | ||
| id: get-tag | ||
| env: | ||
| GITHUB_EVENT_INPUTS_TAG: ${{ github.event.inputs.tag }} | ||
| GITHUB_REF_NAME: ${{ github.ref_name }} | ||
| run: | | ||
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | ||
| echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT | ||
| echo "tag=$GITHUB_EVENT_INPUTS_TAG" >> $GITHUB_OUTPUT | ||
| else | ||
| echo "tag=${{ github.ref_name }}" >> $GITHUB_OUTPUT | ||
| echo "tag=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT | ||
| fi | ||
@@ -61,6 +64,9 @@ | ||
| - name: Display generated notes | ||
| env: | ||
| TAG: ${{ steps.get-tag.outputs.tag }} | ||
| PREVIOUS_TAG: ${{ steps.release-notes.outputs.previous-tag }} | ||
| run: | | ||
| echo "## Release Notes for ${{ steps.get-tag.outputs.tag }}" | ||
| echo "## Release Notes for $TAG" | ||
| echo "" | ||
| echo "Previous tag: ${{ steps.release-notes.outputs.previous-tag }}" | ||
| echo "Previous tag: $PREVIOUS_TAG" | ||
| echo "Commits: ${{ steps.release-notes.outputs.commit-count }}" | ||
@@ -67,0 +73,0 @@ echo "Contributors: ${{ steps.release-notes.outputs.contributor-count }}" |
@@ -99,2 +99,6 @@ --- | ||
| GITHUB_TOKEN: ${{ inputs.github-token }} | ||
| INPUTS_EXTENSIONS_VERSION: ${{ inputs.extensions-version }} | ||
| INPUTS_LLM_MODEL: ${{ inputs.llm-model }} | ||
| INPUTS_SEVERITY_THRESHOLD: ${{ inputs.severity-threshold }} | ||
| INPUTS_MAX_VULNERABILITIES: ${{ inputs.max-vulnerabilities }} | ||
| run: | | ||
@@ -112,6 +116,6 @@ if [ -z "$LLM_API_KEY" ]; then | ||
| echo "Repository: ${{ github.repository }}" | ||
| echo "Extensions Version: ${{ inputs.extensions-version }}" | ||
| echo "LLM Model: ${{ inputs.llm-model }}" | ||
| echo "Severity Threshold: ${{ inputs.severity-threshold }}" | ||
| echo "Max Vulnerabilities: ${{ inputs.max-vulnerabilities }}" | ||
| echo "Extensions Version: $INPUTS_EXTENSIONS_VERSION" | ||
| echo "LLM Model: $INPUTS_LLM_MODEL" | ||
| echo "Severity Threshold: $INPUTS_SEVERITY_THRESHOLD" | ||
| echo "Max Vulnerabilities: $INPUTS_MAX_VULNERABILITIES" | ||
@@ -118,0 +122,0 @@ - name: Run vulnerability scan |
@@ -93,8 +93,10 @@ # Vulnerability Remediation Plugin | ||
| For better PR attribution, use a bot PAT: | ||
| For better PR attribution, use a bot PAT scoped to the repository's trust boundary. On **public** repositories, use the public-scoped token: | ||
| ```yaml | ||
| github-token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT || secrets.GITHUB_TOKEN }} | ||
| github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || secrets.GITHUB_TOKEN }} | ||
| ``` | ||
| On **private** repositories, use a bot token scoped to that repository instead. Never give a public repository a token that can reach private repositories. | ||
| ## Usage | ||
@@ -101,0 +103,0 @@ |
+1
-1
| [project] | ||
| name = "extensions" | ||
| version = "0.3.0" | ||
| version = "0.4.0" | ||
| description = "OpenHands extensions, plugins, and skills" | ||
@@ -5,0 +5,0 @@ requires-python = ">=3.12" |
+3
-2
@@ -63,3 +63,3 @@ # OpenHands Extensions | ||
| <!-- BEGIN AUTO-GENERATED CATALOG --> | ||
| This repository contains **2 marketplace(s)** with **56 extensions** (46 skills, 10 plugins). | ||
| This repository contains **2 marketplace(s)** with **57 extensions** (47 skills, 10 plugins). | ||
@@ -83,3 +83,3 @@ ### large-codebase | ||
| **52 extensions** (44 skills, 8 plugins) | ||
| **53 extensions** (45 skills, 8 plugins) | ||
@@ -105,2 +105,3 @@ | Name | Type | Description | Commands | | ||
| | github | skill | Interact with GitHub repositories, pull requests, issues, and workflows using the GITHUB_TOKEN environment variable a... | — | | ||
| | github-actions | skill | Create, debug, and test GitHub Actions workflows and custom actions. Use when building CI/CD pipelines, automating wo... | — | | ||
| | github-pr-review | skill | Post structured PR reviews to GitHub with inline comments/suggestions in a single API call. | `/github-pr-review` | | ||
@@ -107,0 +108,0 @@ | github-pr-reviewer | skill | Create an automation that reviews GitHub pull requests when they are opened or updated. Inspects the diff, changed fi... | `/pr-reviewer:setup` | |
@@ -146,3 +146,4 @@ #!/usr/bin/env python3 | ||
| for trigger in slash_triggers(meta): | ||
| cmd_name = trigger.lstrip("/") | ||
| # Replace colons with dashes for cross-platform filename compatibility | ||
| cmd_name = trigger.lstrip("/").replace(":", "-") | ||
| cmd_path = skill_dir / "commands" / f"{cmd_name}.md" | ||
@@ -149,0 +150,0 @@ needed.append(CommandSpec(path=cmd_path, trigger=trigger, description=desc)) |
@@ -44,3 +44,3 @@ # GitHub Repository Monitor Skill | ||
| - `GITHUB_TOKEN` secret set in OpenHands Settings → Secrets | ||
| - `GITHUB_PERSONAL_ACCESS_TOKEN` secret set in OpenHands Settings → Secrets | ||
| - Classic PAT: `repo` (private repos) or `public_repo` (public repos) | ||
@@ -47,0 +47,0 @@ - Fine-grained PAT: Issues — Read and Write |
@@ -13,3 +13,3 @@ # GitHub API Reference | ||
| ``` | ||
| Authorization: Bearer {GITHUB_TOKEN} | ||
| Authorization: Bearer {GITHUB_PERSONAL_ACCESS_TOKEN} | ||
| Accept: application/vnd.github+json | ||
@@ -41,3 +41,3 @@ X-GitHub-Api-Version: 2022-11-28 | ||
| curl -I https://api.github.com/user \ | ||
| -H "Authorization: Bearer $GITHUB_TOKEN" \ | ||
| -H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \ | ||
| | grep -i x-oauth-scopes | ||
@@ -214,3 +214,3 @@ # X-OAuth-Scopes: repo, public_repo | ||
| curl -s https://api.github.com/rate_limit \ | ||
| -H "Authorization: Bearer $GITHUB_TOKEN" \ | ||
| -H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \ | ||
| | python3 -c " | ||
@@ -217,0 +217,0 @@ import json, sys |
@@ -21,3 +21,3 @@ """ | ||
| Required secrets (set in OpenHands Settings → Secrets): | ||
| GITHUB_TOKEN - Personal Access Token | ||
| GITHUB_PERSONAL_ACCESS_TOKEN - Personal Access Token | ||
| Classic PAT: 'repo' scope (private) or 'public_repo' (public) | ||
@@ -44,3 +44,3 @@ Fine-grained PAT: Issues: Read and Write | ||
| EVENT_TYPES = ["issue_comment"] # e.g. ["issue_comment", "pr_review_comment"] | ||
| # Who may trigger conversations. Default is the authenticated GITHUB_TOKEN owner. | ||
| # Who may trigger conversations. Default is the authenticated GITHUB_PERSONAL_ACCESS_TOKEN owner. | ||
| # Use ["*"] to allow any non-bot commenter, or explicit logins like ["octocat"]. | ||
@@ -212,5 +212,5 @@ ALLOWED_GITHUB_LOGINS = ["<TOKEN_OWNER>"] | ||
| def _resolve_github_token() -> str: | ||
| """Fetch GITHUB_TOKEN from secrets. Raises RuntimeError if absent.""" | ||
| """Fetch GITHUB_PERSONAL_ACCESS_TOKEN from secrets. Raises RuntimeError if absent.""" | ||
| try: | ||
| token = get_secret("GITHUB_TOKEN") | ||
| token = get_secret("GITHUB_PERSONAL_ACCESS_TOKEN") | ||
| if token: | ||
@@ -221,3 +221,3 @@ return token | ||
| raise RuntimeError( | ||
| "GITHUB_TOKEN secret is not set. " | ||
| "GITHUB_PERSONAL_ACCESS_TOKEN secret is not set. " | ||
| "Go to OpenHands Settings → Secrets and add your GitHub Personal Access Token." | ||
@@ -238,3 +238,3 @@ ) | ||
| raise RuntimeError( | ||
| "GITHUB_TOKEN is invalid or expired. " | ||
| "GITHUB_PERSONAL_ACCESS_TOKEN is invalid or expired. " | ||
| "Update it in OpenHands Settings → Secrets." | ||
@@ -255,3 +255,3 @@ ) | ||
| raise RuntimeError( | ||
| f"Repository '{repo}' not found or not accessible with the current GITHUB_TOKEN. " | ||
| f"Repository '{repo}' not found or not accessible with the current GITHUB_PERSONAL_ACCESS_TOKEN. " | ||
| "Check the repo name (format: owner/repo) and token permissions." | ||
@@ -262,3 +262,3 @@ ) | ||
| f"Access denied to repository '{repo}'. " | ||
| "Ensure GITHUB_TOKEN has the required permissions." | ||
| "Ensure GITHUB_PERSONAL_ACCESS_TOKEN has the required permissions." | ||
| ) | ||
@@ -278,3 +278,3 @@ raise RuntimeError(f"GitHub /repos/{repo} check failed: {exc.code}") | ||
| raise RuntimeError( | ||
| f"GITHUB_TOKEN cannot post comments to private repository '{repo}'. " | ||
| f"GITHUB_PERSONAL_ACCESS_TOKEN cannot post comments to private repository '{repo}'. " | ||
| "A classic PAT needs the 'repo' scope; " | ||
@@ -287,3 +287,3 @@ "a fine-grained PAT needs 'Issues: Read and Write' permission." | ||
| raise RuntimeError( | ||
| f"GITHUB_TOKEN cannot post comments to public repository '{repo}'. " | ||
| f"GITHUB_PERSONAL_ACCESS_TOKEN cannot post comments to public repository '{repo}'. " | ||
| "A classic PAT needs the 'public_repo' scope; " | ||
@@ -603,3 +603,3 @@ "a fine-grained PAT needs 'Issues: Read and Write' permission." | ||
| 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"The GITHUB_PERSONAL_ACCESS_TOKEN secret is available if you need to interact with the " | ||
| f"GitHub API (fetch the PR diff, create commits, update labels, etc.).\n" | ||
@@ -606,0 +606,0 @@ f"When you are finished, summarise what you did clearly — that summary " |
@@ -48,4 +48,4 @@ --- | ||
| |---|---|---| | ||
| | `GITHUB_TOKEN` | Classic PAT | `repo` (private repos) or `public_repo` (public repos) | | ||
| | `GITHUB_TOKEN` | Fine-grained PAT | Issues: Read and Write | | ||
| | `GITHUB_PERSONAL_ACCESS_TOKEN` | Classic PAT | `repo` (private repos) or `public_repo` (public repos) | | ||
| | `GITHUB_PERSONAL_ACCESS_TOKEN` | Fine-grained PAT | Issues: Read and Write | | ||
@@ -55,3 +55,3 @@ Check with: | ||
| curl -s https://api.github.com/user \ | ||
| -H "Authorization: Bearer $GITHUB_TOKEN" \ | ||
| -H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \ | ||
| -H "Accept: application/vnd.github+json" \ | ||
@@ -76,3 +76,3 @@ | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('login') or d.get('message'))" | ||
| ### Step 1 - Verify GITHUB_TOKEN | ||
| ### Step 1 - Verify GITHUB_PERSONAL_ACCESS_TOKEN | ||
@@ -82,3 +82,3 @@ 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 | ||
| *"GITHUB_PERSONAL_ACCESS_TOKEN is not set. Please add it in OpenHands Settings → Secrets | ||
| (classic PAT with `repo` or `public_repo` scope, or a fine-grained PAT | ||
@@ -99,3 +99,3 @@ with Issues: Read and Write)."* Then stop. | ||
| curl -s "https://api.github.com/repos/{owner}/{repo}" \ | ||
| -H "Authorization: Bearer $GITHUB_TOKEN" \ | ||
| -H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \ | ||
| -H "Accept: application/vnd.github+json" \ | ||
@@ -131,3 +131,3 @@ | python3 -c " | ||
| Ask the user: *"Which GitHub users may trigger this automation? | ||
| Press Enter to allow only the authenticated `GITHUB_TOKEN` owner. | ||
| Press Enter to allow only the authenticated `GITHUB_PERSONAL_ACCESS_TOKEN` owner. | ||
| You may also provide comma-separated GitHub logins, or `*` to allow any | ||
@@ -270,3 +270,3 @@ non-bot commenter on the monitored repository."* | ||
| 1. **Loads state** from the JSON file (see `references/state-schema.md`). | ||
| 2. **Resolves and validates GITHUB_TOKEN** — aborts immediately if absent or invalid. | ||
| 2. **Resolves and validates GITHUB_PERSONAL_ACCESS_TOKEN** — aborts immediately if absent or invalid. | ||
| 3. **Polls for new events** since the previous `last_poll` timestamp: | ||
@@ -317,3 +317,3 @@ - `GET /repos/{owner}/{repo}/issues/comments?since=…` for `issue_comment` | ||
| |---|---|---| | ||
| | Bot doesn't respond to comments | `GITHUB_TOKEN` missing or wrong scopes | Verify token with `curl /user`; check scopes in Step 1 | | ||
| | Bot doesn't respond to comments | `GITHUB_PERSONAL_ACCESS_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 | | ||
@@ -320,0 +320,0 @@ | 404 on repo access | Repo name wrong or token has no access | Re-check `owner/repo` spelling; add token as collaborator | |
@@ -69,3 +69,4 @@ # Slack Channel Monitor | ||
| 3. Confirm the trigger phrase (or use the default `@openhands`) | ||
| 4. Generate and upload a customised automation script | ||
| 4. Generate and upload a customised automation script by copying the template | ||
| and changing only the configuration constants | ||
| 5. Create the automation with cron schedule `* * * * *` | ||
@@ -72,0 +73,0 @@ |
@@ -122,5 +122,10 @@ --- | ||
| Read `scripts/main.py` from this skill's directory. Apply exactly three | ||
| constant substitutions near the top of the file: | ||
| Read `scripts/main.py` from this skill's directory and **copy it verbatim**. | ||
| Apply exactly three constant substitutions near the top of the file: | ||
| > **Do not reimplement, simplify, or hand-write a replacement script.** | ||
| > The template already contains the correct secret-loading, state-path, | ||
| > conversation-creation, and context-forwarding logic. Only the three | ||
| > configuration constants below should change unless syntax validation fails. | ||
| | Placeholder | Replace with | | ||
@@ -135,3 +140,4 @@ |---|---| | ||
| mkdir -p /tmp/slack-monitor-build | ||
| # (write the customised main.py to /tmp/slack-monitor-build/main.py) | ||
| # copy scripts/main.py to /tmp/slack-monitor-build/main.py | ||
| # then replace only the three constants above | ||
| ``` | ||
@@ -144,4 +150,16 @@ | ||
| Fix any syntax errors before proceeding. | ||
| Then run a quick integrity check to confirm the template structure is still | ||
| present and only the configuration block was customised: | ||
| ```bash | ||
| grep -n 'TRIGGER_PHRASE = "' /tmp/slack-monitor-build/main.py | ||
| grep -n 'CHANNEL_IDS: list\[str\] =' /tmp/slack-monitor-build/main.py | ||
| grep -n 'DEFAULT_OPENHANDS_URL = "' /tmp/slack-monitor-build/main.py | ||
| grep -n 'def get_secret' /tmp/slack-monitor-build/main.py | ||
| grep -n 'def _state_file_path' /tmp/slack-monitor-build/main.py | ||
| grep -n 'def create_conversation' /tmp/slack-monitor-build/main.py | ||
| ``` | ||
| If any of those checks fail, stop and re-copy the template instead of trying to | ||
| repair a hand-written variant. | ||
| ### Step 4 - Package and upload | ||
@@ -148,0 +166,0 @@ |
@@ -201,3 +201,17 @@ """Tests for scripts/sync_extensions.py core functions.""" | ||
| def test_colon_triggers_are_normalized_for_filenames(self, tmp_path, monkeypatch): | ||
| skill_dir = tmp_path / "skills" / "test-skill" | ||
| skill_dir.mkdir(parents=True) | ||
| (skill_dir / "SKILL.md").write_text( | ||
| "---\nname: test-skill\ndescription: Test\ntriggers:\n - /test:command\n---\nBody\n" | ||
| ) | ||
| monkeypatch.setattr("sync_extensions.SKILL_DIRS", [tmp_path / "skills"]) | ||
| specs = collect_needed_commands() | ||
| assert [spec.path.relative_to(tmp_path).as_posix() for spec in specs] == [ | ||
| "skills/test-skill/commands/test-command.md" | ||
| ] | ||
| # ── load_marketplaces ──────────────────────────────────────────────── | ||
@@ -204,0 +218,0 @@ |
| module.exports = async ({ github, context, core }) => { | ||
| const issueNumber = Number(process.env.ISSUE_NUMBER); | ||
| const summary = (process.env.SUMMARY || "").trim(); | ||
| const classification = process.env.CLASSIFICATION || "no-match"; | ||
| const autoClose = process.env.AUTO_CLOSE_CANDIDATE === "true"; | ||
| const closeAfterDays = process.env.CLOSE_AFTER_DAYS || "3"; | ||
| let candidates = []; | ||
| try { | ||
| candidates = JSON.parse(process.env.CANDIDATE_ISSUES_JSON || "[]"); | ||
| } catch (error) { | ||
| core.setFailed(`Invalid candidate JSON: ${error.message}`); | ||
| return; | ||
| } | ||
| if (!Array.isArray(candidates)) { | ||
| core.setFailed("CANDIDATE_ISSUES_JSON is not an array"); | ||
| return; | ||
| } | ||
| if (candidates.length === 0) { | ||
| core.warning(`No candidate issues were returned for issue #${issueNumber}; skipping.`); | ||
| return; | ||
| } | ||
| const canonicalIssueRaw = process.env.CANONICAL_ISSUE_NUMBER || candidates[0].number; | ||
| const canonicalIssueNumber = canonicalIssueRaw ? Number(canonicalIssueRaw) : Number.NaN; | ||
| const candidateLabel = "duplicate-candidate"; | ||
| function parseDuplicateCheckMarker(body) { | ||
| if (!body) return null; | ||
| const match = body.match(/<!-- openhands-duplicate-check canonical=(\d+) auto-close=(true|false) -->/); | ||
| if (!match) return null; | ||
| return { | ||
| canonicalIssueNumber: Number(match[1]), | ||
| autoClose: match[2] === "true", | ||
| }; | ||
| } | ||
| async function ensureCanonicalIssueIsOpenIssue() { | ||
| let canonicalIssue; | ||
| try { | ||
| ({ data: canonicalIssue } = await github.rest.issues.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: canonicalIssueNumber, | ||
| })); | ||
| } catch (error) { | ||
| if (error.status === 404) { | ||
| core.setFailed(`Canonical issue #${canonicalIssueNumber} does not exist.`); | ||
| return false; | ||
| } | ||
| throw error; | ||
| } | ||
| if (canonicalIssue.pull_request) { | ||
| core.setFailed(`Canonical issue #${canonicalIssueNumber} is a pull request, not an issue.`); | ||
| return false; | ||
| } | ||
| if (canonicalIssue.state !== "open" || canonicalIssue.locked) { | ||
| core.setFailed(`Canonical issue #${canonicalIssueNumber} must be an open, unlocked issue.`); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| async function ensureCandidateLabelOnIssue() { | ||
| try { | ||
| await github.rest.issues.getLabel({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| name: candidateLabel, | ||
| }); | ||
| } catch (error) { | ||
| if (error.status !== 404) throw error; | ||
| await github.rest.issues.createLabel({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| name: candidateLabel, | ||
| color: "f97316", | ||
| description: "Potential duplicate awaiting auto-close or maintainer review", | ||
| }); | ||
| } | ||
| const { data: issue } = await github.rest.issues.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| }); | ||
| const labelNames = (issue.labels || []).map((label) => | ||
| typeof label === "string" ? label : label.name, | ||
| ); | ||
| if (!labelNames.includes(candidateLabel)) { | ||
| await github.rest.issues.addLabels({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| labels: [candidateLabel], | ||
| }); | ||
| } | ||
| } | ||
| async function removeCandidateLabelFromIssue() { | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| name: candidateLabel, | ||
| }); | ||
| } catch (error) { | ||
| if (error.status !== 404) throw error; | ||
| } | ||
| } | ||
| if (!Number.isInteger(canonicalIssueNumber) || canonicalIssueNumber <= 0) { | ||
| core.setFailed(`No canonical issue number was returned for issue #${issueNumber}.`); | ||
| return; | ||
| } | ||
| if (canonicalIssueNumber === issueNumber) { | ||
| core.setFailed(`Duplicate check cannot mark issue #${issueNumber} as a duplicate of itself.`); | ||
| return; | ||
| } | ||
| if (!(await ensureCanonicalIssueIsOpenIssue())) return; | ||
| const marker = `<!-- openhands-duplicate-check canonical=${canonicalIssueNumber} auto-close=${autoClose ? "true" : "false"} -->`; | ||
| const header = candidates.length === 1 | ||
| ? "Found 1 possible duplicate issue:" | ||
| : `Found ${candidates.length} possible duplicate issues:`; | ||
| const candidateLines = candidates.map((candidate, index) => | ||
| `${index + 1}. [#${candidate.number}](${candidate.url}) — ${candidate.title}`, | ||
| ); | ||
| const sections = []; | ||
| if (summary) sections.push(summary, ""); | ||
| sections.push(header, "", ...candidateLines); | ||
| if (classification === "overlapping-scope") { | ||
| sections.push( | ||
| "", | ||
| "These may not be exact duplicates, but the scope appears to overlap enough that keeping discussion in one place may be more useful.", | ||
| ); | ||
| } | ||
| if (autoClose) { | ||
| sections.push( | ||
| "", | ||
| `This issue will be automatically closed as a duplicate in ${closeAfterDays} days.`, | ||
| "", | ||
| "- If your issue is a duplicate, please close it and 👍 the existing issue instead", | ||
| "- To prevent auto-closure, add a comment or 👎 this comment", | ||
| ); | ||
| } | ||
| sections.push( | ||
| "", | ||
| marker, | ||
| "_This comment was created by an AI assistant (OpenHands) on behalf of the repository maintainer._", | ||
| ); | ||
| const body = sections.join("\n").trim(); | ||
| const maxCommentPages = 50; | ||
| let allComments = []; | ||
| let page = 1; | ||
| while (page <= maxCommentPages) { | ||
| const { data: comments } = await github.rest.issues.listComments({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| per_page: 100, | ||
| page, | ||
| }); | ||
| if (!comments || comments.length === 0) break; | ||
| allComments = allComments.concat(comments); | ||
| if (comments.length < 100) break; | ||
| page += 1; | ||
| } | ||
| if (page > maxCommentPages) { | ||
| core.setFailed(`Stopped loading comments for issue #${issueNumber} after ${maxCommentPages} pages.`); | ||
| return; | ||
| } | ||
| const existing = allComments.find((comment) => | ||
| comment.body && comment.body.includes("<!-- openhands-duplicate-check "), | ||
| ); | ||
| if (existing) { | ||
| const existingMarker = parseDuplicateCheckMarker(existing.body); | ||
| if (existingMarker) { | ||
| if ( | ||
| existingMarker.canonicalIssueNumber !== canonicalIssueNumber || | ||
| existingMarker.autoClose !== autoClose | ||
| ) { | ||
| await github.rest.issues.updateComment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| comment_id: existing.id, | ||
| body, | ||
| }); | ||
| if (autoClose) await ensureCandidateLabelOnIssue(); | ||
| else await removeCandidateLabelFromIssue(); | ||
| core.info(`Updated existing duplicate check comment ${existing.id} on issue #${issueNumber}.`); | ||
| return; | ||
| } | ||
| if (autoClose) await ensureCandidateLabelOnIssue(); | ||
| else await removeCandidateLabelFromIssue(); | ||
| } else { | ||
| core.warning( | ||
| `Duplicate check comment already exists on issue #${issueNumber} but its marker could not be parsed; leaving label state unchanged.`, | ||
| ); | ||
| } | ||
| core.info(`Duplicate check comment already exists on issue #${issueNumber}; skipping.`); | ||
| return; | ||
| } | ||
| await github.rest.issues.createComment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| body, | ||
| }); | ||
| if (autoClose) await ensureCandidateLabelOnIssue(); | ||
| }; |
| module.exports = async ({ github, context, core }) => { | ||
| const issueNumber = context.issue.number; | ||
| const commenter = context.payload.comment?.user?.login ?? ""; | ||
| const normalizedCommenter = commenter.toLowerCase(); | ||
| if (normalizedCommenter.endsWith("[bot]") || normalizedCommenter === "all-hands-bot") { | ||
| core.info(`Skipping duplicate-candidate label removal for bot comment from ${commenter || "unknown"}`); | ||
| return; | ||
| } | ||
| core.info(`Removing duplicate-candidate label from issue #${issueNumber} after comment from ${commenter}`); | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issueNumber, | ||
| name: "duplicate-candidate", | ||
| }); | ||
| } catch (error) { | ||
| if (error.status === 404) { | ||
| core.info(`duplicate-candidate label was already removed from issue #${issueNumber}`); | ||
| return; | ||
| } | ||
| throw error; | ||
| } | ||
| }; |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: Create an automation that reviews GitHub pull requests when they are opened or updated. Inspects the diff, changed files, tests, and existing discussion via GitHub MCP, then posts a concise review highlighting risks, security issues, missing tests, and next steps. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| 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. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: Create an automation that drafts incident retrospectives. Gathers incident-channel messages from Slack, collects linked tickets and follow-ups from Linear, and publishes a retrospective draft to Notion with a timeline, impact summary, root-cause hypotheses, and action items. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: Create an automation that triages new Linear issues. Inspects the issue title, description, team, customer, priority, and recent related issues via Linear MCP. Suggests labels, priority, likely owner, duplicates, and posts a clarifying comment. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: This skill should be used when the user asks to "create an automation", "schedule a task", "set up a cron job", "webhook integration", "event-triggered automation", or mentions automations, scheduled tasks, cron scheduling, or webhook events in OpenHands Cloud. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: Create an automation that writes a recurring research brief. Uses Tavily MCP for web research and Notion MCP to publish the final brief with executive summary, implications, and source citations. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: This skill should be used when the user asks to "monitor a Slack channel", "watch Slack for messages", "create a Slack bot that responds to mentions", "set up an OpenHands Slack integration", "trigger OpenHands from Slack", "respond to @openhands in Slack", or "poll Slack channels for a trigger phrase". Guides the user through creating a cron automation that watches up to 10 Slack channels and starts an OpenHands conversation whenever a configurable trigger phrase is detected. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: Create an automation that generates an async standup digest from Slack. Searches selected channels for messages since the previous workday, groups updates by project, highlights blockers and decisions, and posts a summary to a target channel. | ||
| --- | ||
| Read and follow the complete instructions in the SKILL.md file located in this skill's directory. | ||
| $ARGUMENTS |
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
2041354
0.9%416
0.73%20391
0.32%172
0.58%52
1.96%