New:Socket for Asana Is Now Available.Learn more
Get Started

@openhands/extensions

Package Overview
Dependencies
Maintainers
3
Versions
24
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@openhands/extensions - npm Package Compare versions

Comparing version
0.18.0
to
0.19.0
+24
.github/dependabot.yml
# Keep dependency update scheduling centralized in this repository configuration.
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
commit-message:
prefix: ci(deps)
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
commit-message:
prefix: chore(deps)
include: scope
- package-ecosystem: pip
directory: /
schedule:
interval: weekly
commit-message:
prefix: chore(deps)
"""Determine whether an issue meets the `ready-for-dev` readiness criteria.
The criteria are type-specific and tailored to extension contributions
(skills, plugins, integrations, and automations):
- Bug reports (labeled `bug`): the Actual Behavior section must describe a
reproducible run and include a supported validation command (`uv`,
`pytest`, `python`, `pip`, `npm`, or `node`), plus a non-empty Acceptance
Criteria section with at least one checklist item.
- Enhancements (labeled `enhancement`): the body must contain non-empty
Desired Behavior and Acceptance Criteria sections, the latter with at
least one checklist item.
GitHub issue forms render each field as an `### <Label>` (h3) heading followed
by the field text, with empty optional fields rendered as `_No response_`. This
parser splits the body on those headings so each criterion is checked against
the right field rather than the whole body.
Local usage:
python .github/scripts/check_issue_readiness.py --body-file /tmp/issue.md \
--labels bug
python .github/scripts/check_issue_readiness.py --event-path "$GITHUB_EVENT_PATH"
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
BUG_LABEL = "bug"
ENHANCEMENT_LABEL = "enhancement"
# Issue-form fields render as `### Label` h3 headings. Match case-insensitively
# and tolerate trailing whitespace/colons. `^###\s+` is specific enough because
# h1/h2 are not produced by issue forms.
HEADING_RE = re.compile(r"(?m)^###\s+(.+?)\s*$")
# `_No response_` is what GitHub writes for an empty optional form field.
NO_RESPONSE = "_No response_"
# A reproducible command must appear in the Actual Behavior section. These are
# the commands a contributor can run in this repository: the Python test
# tooling (`uv run pytest`, `pytest`, `python`, `pip`) and the catalog build /
# validation tooling (`npm run build:*`, `node`).
RUN_METHOD_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"\bpython\b", re.IGNORECASE),
re.compile(r"\bpytest\b", re.IGNORECASE),
re.compile(r"\buv\b", re.IGNORECASE),
re.compile(r"\bpip\b", re.IGNORECASE),
re.compile(r"\bnpm\b", re.IGNORECASE),
re.compile(r"\bnode\b", re.IGNORECASE),
)
# An Acceptance Criteria item is a markdown checklist bullet (`- [ ]` or
# `- [x]`). We require at least one so the section is verifiable.
CHECKLIST_ITEM_RE = re.compile(r"(?m)^\s*[-*]\s*\[[ xX]\]")
@dataclass
class ReadinessResult:
"""Outcome of a readiness check."""
ready: bool
reasons: list[str] = field(default_factory=list)
def add(self, reason: str) -> None:
self.reasons.append(reason)
self.ready = False
def visible_text(text: str) -> str:
"""Return field text with HTML comments stripped and emptiness normalized."""
cleaned = re.sub(r"<!--[\s\S]*?-->", "", text).strip()
if cleaned == NO_RESPONSE:
return ""
return cleaned
def extract_sections(body: str) -> dict[str, str]:
"""Split the body into a {heading: text} map using `### <heading>` boundaries.
Issue forms render every field this way. Free-form issues (not created via a
form) may still use `###` headings; if they don't, the map is empty and the
caller falls back to whole-body checks.
"""
matches = list(HEADING_RE.finditer(body))
sections: dict[str, str] = {}
for index, match in enumerate(matches):
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
sections[match.group(1).strip().lower()] = body[start:end]
return sections
def find_section(sections: dict[str, str], *labels: str) -> str:
"""Return the first matching section text by case-insensitive label."""
for label in labels:
if label in sections:
return sections[label]
return ""
def references_run_method(text: str) -> bool:
return any(pattern.search(text) for pattern in RUN_METHOD_PATTERNS)
def has_checklist_item(text: str) -> bool:
return bool(CHECKLIST_ITEM_RE.search(text))
def check_bug(sections: dict[str, str]) -> ReadinessResult:
result = ReadinessResult(ready=True)
actual = visible_text(find_section(sections, "actual behavior", "actual"))
if not actual:
result.add(
"Fill in the `### Actual Behavior` section with reproducible steps "
"and the observed result."
)
elif not references_run_method(actual):
result.add(
"The Actual Behavior section must include a reproducible command "
"such as `uv run pytest`, `pytest`, `python`, `pip`, `npm run`, "
"or `node`."
)
acceptance = visible_text(
find_section(sections, "acceptance criteria", "acceptance")
)
if not acceptance:
result.add(
"Add an `### Acceptance Criteria` section with testable checklist items."
)
elif not has_checklist_item(acceptance):
result.add(
"The Acceptance Criteria section must contain at least one checklist item "
"(`- [ ] ...`)."
)
return result
def check_enhancement(sections: dict[str, str]) -> ReadinessResult:
result = ReadinessResult(ready=True)
desired = visible_text(find_section(sections, "desired behavior", "desired"))
if not desired:
result.add(
"Add a `### Desired Behavior` section describing the behavior you want."
)
acceptance = visible_text(
find_section(sections, "acceptance criteria", "acceptance")
)
if not acceptance:
result.add(
"Add an `### Acceptance Criteria` section with testable checklist items."
)
elif not has_checklist_item(acceptance):
result.add(
"The Acceptance Criteria section must contain at least one checklist item "
"(`- [ ] ...`)."
)
return result
def evaluate_readiness(body: str, labels: list[str]) -> ReadinessResult:
"""Return the readiness result for an issue body + label set.
An issue is only a candidate when it carries the `bug` or `enhancement`
label. If it has neither, it is treated as not-ready-for-dev (the gate does
not apply a label it cannot validate).
"""
label_set = {label.lower() for label in labels}
sections = extract_sections(body or "")
if BUG_LABEL in label_set:
return check_bug(sections)
if ENHANCEMENT_LABEL in label_set:
return check_enhancement(sections)
return ReadinessResult(
ready=False,
reasons=[
"The issue has neither the `bug` nor `enhancement` label, so its "
"readiness criteria cannot be evaluated. Add the appropriate label."
],
)
def body_and_labels_from_event(event_path: Path) -> tuple[str, list[str]]:
payload = json.loads(event_path.read_text())
issue = payload.get("issue") or payload.get("pull_request")
if not isinstance(issue, dict):
raise ValueError("GitHub event payload does not contain an issue object")
body = issue.get("body")
body = body if isinstance(body, str) else ""
labels = [
label["name"] for label in issue.get("labels", []) if isinstance(label, dict)
]
return body, labels
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Evaluate whether an issue meets the ready-for-dev criteria."
)
parser.add_argument(
"--body-file", type=Path, help="Read the issue body from a file."
)
parser.add_argument(
"--labels",
help="Comma-separated issue labels (e.g. 'bug,skills').",
default="",
)
parser.add_argument(
"--event-path",
type=Path,
default=Path(os.environ["GITHUB_EVENT_PATH"])
if "GITHUB_EVENT_PATH" in os.environ
else None,
help="Read body and labels from a GitHub event payload.",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit a JSON result instead of human-readable text.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.body_file is not None:
body = args.body_file.read_text()
labels = [label.strip() for label in args.labels.split(",") if label.strip()]
elif args.event_path is not None:
body, labels = body_and_labels_from_event(args.event_path)
else:
raise SystemExit("Pass --body-file or set GITHUB_EVENT_PATH.")
result = evaluate_readiness(body, labels)
if args.json:
print(json.dumps({"ready": result.ready, "reasons": result.reasons}))
# In --json mode the exit code is not meaningful: the result is consumed
# via the printed JSON, and the workflow must run to completion for both
# ready and not-ready issues (label add/remove, feedback comment).
return 0
if result.ready:
print("Issue meets ready-for-dev criteria.")
else:
print("Issue does not meet ready-for-dev criteria:")
for reason in result.reasons:
print(f" - {reason}")
return 0 if result.ready else 1
if __name__ == "__main__":
sys.exit(main())
"""Validate a pull request description against the repository PR template.
Two gates:
1. Template sections: the `## Why`, `## Summary`, and `## How to Test`
sections from `.github/pull_request_template.md` must be present and
filled in.
2. Linked-issue readiness: every issue linked via a closing keyword
(`Fixes #123`, `Closes #123`, `Resolves #123`) or referenced in the
`## Issue Number` section must exist and, unless it predates the
`ready-for-dev` rollout, carry the `ready-for-dev` label. This blocks
PRs until their linked issue meets the readiness criteria.
Local usage:
python .github/scripts/check_pr_description.py --body-file /tmp/pr.md
GITHUB_TOKEN=... python .github/scripts/check_pr_description.py \
--event-path "$GITHUB_EVENT_PATH"
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
# These are the only PR-template sections that must remain and contain content.
REQUIRED_TEMPLATE_FIELDS: tuple[str, ...] = ("Why", "Summary", "How to Test")
HTML_COMMENT_RE = re.compile(r"<!--[\s\S]*?-->")
HEADING_RE = re.compile(r"(?m)^##\s+(.+?)\s*$")
ISSUE_REF_RE = re.compile(r"(?i)\b(?:fix|clos|resolv)(?:e?(?:s|d)?|ing)?\s+#(\d+)")
BARE_ISSUE_REF_RE = re.compile(r"(?<!\w)#(\d+)")
READY_FOR_DEV_LABEL = "ready-for-dev"
# Issues created before the `ready-for-dev` rollout are grandfathered: the
# issue-readiness workflow only labels issues on `issues` events, so long-open
# issues were never evaluated. Requiring the label retroactively would block
# existing PRs linked to those issues. The cutoff is the UTC day AFTER the
# rollout/deployment day (2026-08-24), so every issue predating deployment -
# including ones opened earlier that same day, before the workflow existed -
# is exempt. Issues created on or after 2026-08-25 must carry the label.
READY_FOR_DEV_ROLLOUT_ISO = "2026-08-25"
def visible_text(text: str) -> str:
"""Return PR body content that should count as author-provided text."""
lines = []
for line in HTML_COMMENT_RE.sub("", text).splitlines():
stripped = line.strip()
if stripped and stripped != "-":
lines.append(stripped)
return "\n".join(lines).strip()
def extract_sections(body: str) -> dict[str, str]:
matches = list(HEADING_RE.finditer(body))
sections: dict[str, str] = {}
for index, match in enumerate(matches):
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
sections[match.group(1).strip()] = body[start:end]
return sections
def extract_linked_issue_numbers(body: str) -> list[int]:
numbers: list[int] = []
seen: set[int] = set()
for match in ISSUE_REF_RE.finditer(body):
number = int(match.group(1))
if number not in seen:
numbers.append(number)
seen.add(number)
sections = extract_sections(body)
issue_section = sections.get("Issue Number", "")
for match in BARE_ISSUE_REF_RE.finditer(issue_section):
number = int(match.group(1))
if number not in seen:
numbers.append(number)
seen.add(number)
return numbers
def fetch_issue_details(
repo: str, issue_number: int, token: str
) -> tuple[list[str], str]:
"""Return an issue's (labels, created_at) from the GitHub API."""
request = urllib.request.Request(
f"https://api.github.com/repos/{repo}/issues/{issue_number}",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
},
)
with urllib.request.urlopen(request, timeout=10) as response: # noqa: S310 - trusted HTTPS API
issue = json.loads(response.read().decode())
labels = [
label["name"] for label in issue.get("labels", []) if isinstance(label, dict)
]
created_at = issue.get("created_at", "")
return labels, created_at
def validate_linked_issue_ready(
body: str, repo: str | None = None, token: str | None = None
) -> list[str]:
numbers = extract_linked_issue_numbers(body)
if not numbers:
return [
"Link an issue in the `## Issue Number` section (e.g. `Fixes #123`). "
"Newly opened issues must carry the `ready-for-dev` label."
]
if not repo or not token:
return []
checked: list[int] = []
not_ready_new: list[int] = []
for number in numbers:
try:
labels, created_at = fetch_issue_details(repo, number, token)
except urllib.error.HTTPError as exc:
if exc.code == 404:
continue
raise
checked.append(number)
if READY_FOR_DEV_LABEL in (label.lower() for label in labels):
continue
if created_at[:10] < READY_FOR_DEV_ROLLOUT_ISO:
# Predates the rollout; grandfathered to avoid retroactive blocking.
continue
not_ready_new.append(number)
if not checked:
refs = ", ".join(f"#{number}" for number in numbers)
return [f"Referenced issue(s) {refs} could not be found in this repository."]
if not_ready_new:
refs = ", ".join(f"#{number}" for number in not_ready_new)
return [
f"Linked issue(s) ({refs}) carry neither `ready-for-dev` nor a "
"pre-rollout creation date. Newly referenced issues must meet the "
"readiness criteria before a PR can be opened."
]
return []
def validate_pr_body(body: str) -> list[str]:
errors: list[str] = []
sections = extract_sections(body)
for section in REQUIRED_TEMPLATE_FIELDS:
if section not in sections:
errors.append(f"Keep the `## {section}` section from the PR template.")
elif not visible_text(sections[section]):
errors.append(f"Fill in the `## {section}` section of the PR template.")
return errors
def body_from_event(event_path: Path) -> tuple[str, str | None]:
"""Return the (pull request body, repository full name) from an event payload."""
payload = json.loads(event_path.read_text())
pull_request = payload.get("pull_request")
if not isinstance(pull_request, dict):
raise ValueError("GitHub event payload does not contain a pull_request object")
body = pull_request.get("body")
body = body if isinstance(body, str) else ""
repo = payload.get("repository", {}).get("full_name")
return body, repo if isinstance(repo, str) else None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Validate pull request description readiness from --body-file "
"or a GitHub event payload."
)
)
parser.add_argument(
"--body-file", type=Path, help="Read a PR description body from a file."
)
parser.add_argument(
"--event-path",
type=Path,
default=Path(os.environ["GITHUB_EVENT_PATH"])
if "GITHUB_EVENT_PATH" in os.environ
else None,
help="Read the PR description body from a GitHub event payload.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.body_file is not None:
body = args.body_file.read_text()
repo = None
elif args.event_path is not None:
body, repo = body_from_event(args.event_path)
else:
raise SystemExit("Pass --body-file or set GITHUB_EVENT_PATH.")
errors = validate_pr_body(body)
token = os.environ.get("GITHUB_TOKEN")
errors.extend(validate_linked_issue_ready(body, repo, token))
for error in errors:
print(f"::error::{error}")
if errors:
print(f"PR description validation failed with {len(errors)} error(s).")
return 1
print("PR description validation passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env node
/**
* Post (or update) a `ready-for-dev` readiness comment on an issue.
*
* Upserts by a hidden HTML marker so repeated runs update the same comment
* instead of spamming duplicates. Uses the GITHUB_TOKEN available to the
* workflow via the GH_TOKEN env var.
*
* Usage:
* node post-readiness-comment.mjs \
* --issue-number 123 \
* --repo OpenHands/extensions \
* --reasons-file /tmp/reasons.txt \
* --ready # optional: post a "ready" message instead of "not ready"
*/
import { readFileSync } from "node:fs";
const MARKER = "<!-- issue-readiness-check -->";
const API_ROOT = process.env.GITHUB_API_URL ?? "https://api.github.com";
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
const key = rawKey.replaceAll("-", "_");
if (inlineValue !== undefined) {
args[key] = inlineValue;
continue;
}
const next = argv[i + 1];
if (next && !next.startsWith("--")) {
args[key] = next;
i += 1;
} else {
args[key] = "";
}
}
return args;
}
function requireValue(name, value) {
if (!value) throw new Error(`Missing required value: ${name}`);
return value;
}
async function listComments(repo, issueNumber, token) {
// Paginate through all comments so the marker is found even on issues
// with >100 comments.
const all = [];
let page = 1;
while (true) {
const url = `${API_ROOT}/repos/${repo}/issues/${issueNumber}/comments?per_page=100&page=${page}`;
const resp = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!resp.ok) {
throw new Error(`Failed to list comments: ${resp.status} ${await resp.text()}`);
}
const batch = await resp.json();
all.push(...batch);
if (batch.length < 100) break;
page += 1;
}
return all;
}
async function createComment(repo, issueNumber, body, token) {
const url = `${API_ROOT}/repos/${repo}/issues/${issueNumber}/comments`;
const resp = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({ body }),
});
if (!resp.ok) {
throw new Error(`Failed to create comment: ${resp.status} ${await resp.text()}`);
}
return resp.json();
}
async function updateComment(repo, commentId, body, token) {
const url = `${API_ROOT}/repos/${repo}/issues/comments/${commentId}`;
const resp = await fetch(url, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({ body }),
});
if (!resp.ok) {
throw new Error(`Failed to update comment: ${resp.status} ${await resp.text()}`);
}
return resp.json();
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const issueNumber = requireValue("issue-number", args.issue_number);
const repo = requireValue("repo", args.repo);
const token = process.env.GH_TOKEN;
if (!token) throw new Error("GH_TOKEN env var is required");
const isReady = args.ready === "" || args.ready === "true";
const reasonsFile = args.reasons_file || args.reasons;
const reasons = reasonsFile
? readFileSync(reasonsFile, "utf8")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
: [];
const reasonList = reasons.length
? reasons.map((r) => `- ${r}`).join("\n")
: "- The issue does not meet the ready-for-dev criteria.";
const body = isReady
? [
MARKER,
"### \u2705 `ready-for-dev` label applied",
"",
"This issue meets the readiness criteria and is ready for contribution.",
"A contributor can now open a PR that links this issue (`Fixes #<number>`).",
"",
"_This is an automated readiness check posted by a workflow._",
].join("\n")
: [
MARKER,
"### \u26a0\ufe0f Not yet `ready-for-dev`",
"",
"This issue does not yet meet the readiness criteria, so the",
"`ready-for-dev` label has not been applied (or has been removed).",
"The criteria are type-specific:",
"",
"**Bug reports** (`bug` label):",
"- The `### Actual Behavior` section must describe reproducible steps",
" and include a command such as `uv run pytest`, `pytest`, `python`,",
" `pip`, `npm run`, or `node`.",
"- An `### Acceptance Criteria` section with at least one checklist item (`- [ ] ...`).",
"",
"**Enhancements** (`enhancement` label):",
"- A `### Desired Behavior` section.",
"- An `### Acceptance Criteria` section with at least one checklist item.",
"",
"What is missing:",
"",
reasonList,
"",
"Edit the issue to address the gaps and the `ready-for-dev` label will be",
"applied automatically.",
"",
"_This is an automated readiness check posted by a workflow._",
].join("\n");
const comments = await listComments(repo, issueNumber, token);
const existing = comments.find((comment) => comment.body?.includes(MARKER));
if (existing) {
await updateComment(repo, existing.id, body, token);
console.log(`Updated readiness comment ${existing.id} on issue #${issueNumber}`);
} else {
const created = await createComment(repo, issueNumber, body, token);
console.log(`Created readiness comment ${created.id} on issue #${issueNumber}`);
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
"""Re-run the PR Description Check for open PRs linked to an issue.
Invoked from the issue-readiness workflow when the `ready-for-dev` label is
added to or removed from an issue, so the PR gate reflects the settled
readiness state without waiting for the next PR event.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from check_pr_description import extract_linked_issue_numbers
def _run(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(args, capture_output=True, text=True, check=False)
def _linked_open_prs(repo: str, issue_number: int) -> list[dict]:
"""Return open PRs that cross-reference ``issue_number``."""
owner, name = repo.split("/", 1)
query = """
query($owner: String!, $name: String!, $num: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $num) {
timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT]) {
nodes {
__typename
... on CrossReferencedEvent {
source {
__typename
... on PullRequest { number headRefOid state }
}
}
}
}
}
}
}
"""
result = _run(
[
"gh",
"api",
"graphql",
"-f",
f"query={query}",
"-F",
f"owner={owner}",
"-F",
f"name={name}",
"-F",
f"num={issue_number}",
"--jq",
".data.repository.issue.timelineItems.nodes",
]
)
if result.returncode != 0:
print(f"::warning::Could not query linked PRs: {result.stderr.strip()}")
return []
try:
nodes = json.loads(result.stdout)
except json.JSONDecodeError as exc:
print(f"::warning::Unexpected linked-PR query output: {exc}")
return []
prs = []
for node in nodes:
source = node.get("source") if isinstance(node, dict) else None
if not isinstance(source, dict):
continue
if source.get("__typename") != "PullRequest":
continue
if source.get("state") != "OPEN":
continue
prs.append(source)
return prs
def _rerun_pr_description_check(repo: str, head_sha: str) -> bool:
"""Re-run the latest PR Description Check for the given head SHA."""
runs_result = _run(
[
"gh",
"api",
"-X",
"GET",
f"repos/{repo}/actions/runs",
"-f",
f"head_sha={head_sha}",
"-f",
"per_page=100",
"--jq",
r'.workflow_runs[] | select(.name=="PR Description Check") | '
r'select(.event=="pull_request_target") | "\(.id) \(.created_at)"',
]
)
if runs_result.returncode != 0:
print(f"::warning::Could not list PR checks: {runs_result.stderr.strip()}")
return False
lines = [line.split() for line in runs_result.stdout.splitlines() if line.strip()]
if not lines:
return False
# Created-at is sortable; pick the most recent run for this commit.
latest = sorted(lines, key=lambda item: " ".join(item[1:]))[-1][0]
rerun = _run(
["gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{latest}/rerun"]
)
if rerun.returncode != 0:
print(
f"::warning::Could not re-run PR description check ({latest}): "
f"{rerun.stderr.strip()}"
)
return False
print(f"Re-ran PR description check (run {latest}) for linked open PR.")
return True
def main() -> int:
payload = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
if {"action", "issue", "label", "repository"} - set(payload):
return 0
action = payload["action"]
raw_label = payload.get("label")
label = raw_label.get("name") if isinstance(raw_label, dict) else None
if action not in ("labeled", "unlabeled") or label != "ready-for-dev":
return 0
raw_repo = payload.get("repository")
repo = raw_repo.get("full_name") if isinstance(raw_repo, dict) else None
raw_issue = payload.get("issue")
issue_number = raw_issue.get("number") if isinstance(raw_issue, dict) else None
if not repo or not issue_number:
return 0
print(
f"ready-for-dev {action}: refreshing PR gates linked to issue #{issue_number}."
)
refreshed = 0
for pr in _linked_open_prs(repo, issue_number):
pr_number = pr["number"]
body_result = _run(
[
"gh",
"pr",
"view",
str(pr_number),
"--repo",
repo,
"--json",
"body",
"--jq",
".body",
]
)
if body_result.returncode != 0:
continue
if issue_number not in extract_linked_issue_numbers(body_result.stdout):
# Cross-referenced but not treated as a linked issue by the gate;
# nothing to refresh.
continue
if _rerun_pr_description_check(repo, pr["headRefOid"]):
refreshed += 1
print(f"Refreshed PR gates for {refreshed} linked PR(s).")
return 0
if __name__ == "__main__":
sys.exit(main())
---
name: Issue Readiness Check
# Manages the `ready-for-dev` label based on type-specific readiness criteria
# tailored to extension contributions (skills, plugins, integrations,
# automations).
#
# Bug reports (label `bug`): the Actual Behavior section must reference a
# reproducible command (`uv run pytest`, `pytest`, `python`, `pip`, `npm run`,
# or `node`), plus an Acceptance Criteria section with at least one checklist
# item.
#
# Enhancements (label `enhancement`): the body must contain Desired Behavior and
# Acceptance Criteria sections, the latter with at least one checklist item.
#
# When criteria are met the label is added (idempotently); when they are not the
# label is removed. A feedback comment is posted (or updated) in three cases:
# 1. The issue is first opened or reopened - always comment so the author
# knows whether their issue is ready or what is missing.
# 2. The label is being added (issue became ready) - comment celebrating it.
# 3. The label is being removed (issue was ready but is no longer) - comment
# explaining what changed.
# Edits that do not change the label state do not produce a new comment. All
# comments are upserted by a hidden marker so there is at most one per issue.
#
# The readiness step uses `--json`, which always exits 0, so a not-ready result
# never aborts the workflow under `set -euo pipefail`; label and comment steps
# run for both outcomes.
on:
issues:
types: [opened, edited, reopened, labeled, unlabeled]
permissions:
issues: write
pull-requests: read
contents: read
concurrency:
group: issue-readiness-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
check:
if: github.event.issue.state == 'open' && github.event.issue.pull_request == null
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout trusted workflow scripts
uses: actions/checkout@v4
- name: Evaluate issue readiness
id: readiness
env:
GITHUB_EVENT_PATH: ${{ github.event_path }}
run: |
set -euo pipefail
python .github/scripts/check_issue_readiness.py --json > /tmp/result.json
echo "ready=$(jq -r '.ready' /tmp/result.json)" >> "$GITHUB_OUTPUT"
# Write reasons to a file so the comment step can read them
# without shell-escaping multiline text.
jq -r '.reasons[]?' /tmp/result.json > /tmp/reasons.txt || true
echo "reasons_file=/tmp/reasons.txt" >> "$GITHUB_OUTPUT"
- name: Add ready-for-dev label
if: steps.readiness.outputs.ready == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Capture stderr — tolerate "label already present" (exit 0
# from gh) but surface unexpected errors to the step summary.
if ! gh issue edit "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" \
--add-label "ready-for-dev" 2>/tmp/gh-err.txt; then
echo "::warning::Failed to add ready-for-dev label:" >> "$GITHUB_STEP_SUMMARY"
cat /tmp/gh-err.txt >> "$GITHUB_STEP_SUMMARY"
fi
- name: Remove ready-for-dev label
if: steps.readiness.outputs.ready != 'true' && contains(github.event.issue.labels.*.name, 'ready-for-dev')
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Capture stderr — tolerate "label already removed" but
# surface unexpected errors.
if ! gh issue edit "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" \
--remove-label "ready-for-dev" 2>/tmp/gh-err.txt; then
echo "::warning::Failed to remove ready-for-dev label:" >> "$GITHUB_STEP_SUMMARY"
cat /tmp/gh-err.txt >> "$GITHUB_STEP_SUMMARY"
fi
- name: Post feedback comment
if: >-
github.event.action == 'opened'
|| github.event.action == 'reopened'
|| (steps.readiness.outputs.ready == 'true' && !contains(github.event.issue.labels.*.name, 'ready-for-dev'))
|| (steps.readiness.outputs.ready != 'true' && contains(github.event.issue.labels.*.name, 'ready-for-dev'))
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ "${{ steps.readiness.outputs.ready }}" = "true" ]; then
node .github/scripts/post-readiness-comment.mjs \
--issue-number "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" \
--ready
else
node .github/scripts/post-readiness-comment.mjs \
--issue-number "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" \
--reasons-file "${{ steps.readiness.outputs.reasons_file }}"
fi
# When the ready-for-dev label is added or removed, re-run the PR
# description check for every open PR that links this issue, so the
# `ready-for-dev` gate does not go stale between PR events. Runs after
# `check` so the label reflects the settled readiness state.
refresh-linked-pr-gates:
name: Refresh PR gates for linked issues
needs: check
if: >-
github.event.issue.state == 'open'
&& github.event.issue.pull_request == null
&& (github.event.action == 'labeled' || github.event.action == 'unlabeled')
&& github.event.label.name == 'ready-for-dev'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
actions: write
steps:
- name: Checkout trusted workflow scripts
uses: actions/checkout@v4
- name: Re-run PR description checks for linked open PRs
env:
GH_TOKEN: ${{ github.token }}
run: python .github/scripts/refresh_linked_pr_checks.py
---
name: PR Description Check
# Use pull_request_target so fork PR descriptions can be checked, but only run
# trusted validation code from the base branch checkout below.
on:
pull_request_target:
types: [opened, edited, reopened, ready_for_review, synchronize]
permissions:
contents: read
pull-requests: read
issues: read
jobs:
validate-pr-description:
name: Validate PR description
# Draft PRs may still have incomplete descriptions; validate when review starts.
# Release PRs are generated by the trusted prepare-release workflow without
# the standard human-authored PR template.
if: >-
github.event.pull_request.draft == false &&
github.event.pull_request.user.login != 'dependabot[bot]' &&
!(github.event.pull_request.head.repo.full_name == github.repository &&
startsWith(github.event.pull_request.head.ref, 'release-please-'))
runs-on: ubuntu-latest
steps:
- name: Checkout trusted workflow scripts
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Validate PR template sections and linked-issue readiness
env:
GITHUB_TOKEN: ${{ github.token }}
run: python .github/scripts/check_pr_description.py
{
"id": "jira-issue-to-bitbucket-pr",
"name": "Jira issue to Bitbucket PR",
"category": "Project management",
"description": "Watch Jira for implementation-ready issues, start an agent to make the requested code change, and open a Bitbucket pull request.",
"requires": {
"integrations": {
"jira": {
"message": "Reads implementation-ready issues and posts progress back to Jira."
},
"bitbucket": {
"message": "Clones the target repository, pushes a branch, and opens the pull request."
}
},
"features": [
"conversationDispatch"
]
},
"popularityRank": 83,
"estimatedSetupMinutes": 6,
"skill": "ticket-to-code-change",
"exampleImplementation": "Trigger: Jira issue marked implementation-ready\nRequired integrations: Jira, Bitbucket\n\n1. Watch the selected Jira team or project for issues carrying a configurable implementation-ready label or state.\n2. Deduplicate issue IDs so each transition dispatches once.\n3. Read the issue, linked dependencies, acceptance criteria, and target repository.\n4. Start an independent OpenHands conversation that clones the Bitbucket repository, creates an issue-keyed branch, implements and tests the change, and opens a pull request.\n5. Post the conversation and pull request links back to the Jira issue and update its status according to the team's workflow."
}
{
"id": "jira-issue-to-gitlab-mr",
"name": "Jira issue to GitLab MR",
"category": "Project management",
"description": "Watch Jira for implementation-ready issues, start an agent to make the requested code change, and open a GitLab merge request.",
"requires": {
"integrations": {
"jira": {
"message": "Reads implementation-ready issues and posts progress back to Jira."
},
"gitlab": {
"message": "Clones the target repository, pushes a branch, and opens the merge request."
}
},
"features": [
"conversationDispatch"
]
},
"popularityRank": 84,
"estimatedSetupMinutes": 6,
"skill": "ticket-to-code-change",
"exampleImplementation": "Trigger: Jira issue marked implementation-ready\nRequired integrations: Jira, GitLab\n\n1. Watch the selected Jira team or project for issues carrying a configurable implementation-ready label or state.\n2. Deduplicate issue IDs so each transition dispatches once.\n3. Read the issue, linked dependencies, acceptance criteria, and target repository.\n4. Start an independent OpenHands conversation that clones the GitLab repository, creates an issue-keyed branch, implements and tests the change, and opens a merge request.\n5. Post the conversation and merge request links back to the Jira issue and update its status according to the team's workflow."
}
{
"id": "linear-issue-to-bitbucket-pr",
"name": "Linear issue to Bitbucket PR",
"category": "Project management",
"description": "Watch Linear for implementation-ready issues, start an agent to make the requested code change, and open a Bitbucket pull request.",
"requires": {
"integrations": {
"linear": {
"message": "Reads implementation-ready issues and posts progress back to Linear."
},
"bitbucket": {
"message": "Clones the target repository, pushes a branch, and opens the pull request."
}
},
"features": [
"conversationDispatch"
]
},
"popularityRank": 87,
"estimatedSetupMinutes": 6,
"skill": "ticket-to-code-change",
"exampleImplementation": "Trigger: Linear issue marked implementation-ready\nRequired integrations: Linear, Bitbucket\n\n1. Watch the selected Linear team or project for issues carrying a configurable implementation-ready label or state.\n2. Deduplicate issue IDs so each transition dispatches once.\n3. Read the issue, linked dependencies, acceptance criteria, and target repository.\n4. Start an independent OpenHands conversation that clones the Bitbucket repository, creates an issue-keyed branch, implements and tests the change, and opens a pull request.\n5. Post the conversation and pull request links back to the Linear issue and update its status according to the team's workflow."
}
{
"id": "linear-issue-to-github-pr",
"name": "Linear issue to GitHub PR",
"category": "Project management",
"description": "Watch Linear for implementation-ready issues, start an agent to make the requested code change, and open a GitHub pull request.",
"requires": {
"integrations": {
"linear": {
"message": "Reads implementation-ready issues and posts progress back to Linear."
},
"github": {
"message": "Clones the target repository, pushes a branch, and opens the pull request."
}
},
"features": [
"conversationDispatch"
]
},
"popularityRank": 89,
"estimatedSetupMinutes": 6,
"skill": "ticket-to-code-change",
"exampleImplementation": "Trigger: Linear issue marked implementation-ready\nRequired integrations: Linear, GitHub\n\n1. Watch the selected Linear team or project for issues carrying a configurable implementation-ready label or state.\n2. Deduplicate issue IDs so each transition dispatches once.\n3. Read the issue, linked dependencies, acceptance criteria, and target repository.\n4. Start an independent OpenHands conversation that clones the GitHub repository, creates an issue-keyed branch, implements and tests the change, and opens a pull request.\n5. Post the conversation and pull request links back to the Linear issue and update its status according to the team's workflow."
}
{
"id": "linear-issue-to-gitlab-mr",
"name": "Linear issue to GitLab MR",
"category": "Project management",
"description": "Watch Linear for implementation-ready issues, start an agent to make the requested code change, and open a GitLab merge request.",
"requires": {
"integrations": {
"linear": {
"message": "Reads implementation-ready issues and posts progress back to Linear."
},
"gitlab": {
"message": "Clones the target repository, pushes a branch, and opens the merge request."
}
},
"features": [
"conversationDispatch"
]
},
"popularityRank": 88,
"estimatedSetupMinutes": 6,
"skill": "ticket-to-code-change",
"exampleImplementation": "Trigger: Linear issue marked implementation-ready\nRequired integrations: Linear, GitLab\n\n1. Watch the selected Linear team or project for issues carrying a configurable implementation-ready label or state.\n2. Deduplicate issue IDs so each transition dispatches once.\n3. Read the issue, linked dependencies, acceptance criteria, and target repository.\n4. Start an independent OpenHands conversation that clones the GitLab repository, creates an issue-keyed branch, implements and tests the change, and opens a merge request.\n5. Post the conversation and merge request links back to the Linear issue and update its status according to the team's workflow."
}
{
"id": "qa-changes",
"name": "QA Changes Agent",
"category": "Code review",
"description": "When a pull request is opened for review, run the QA changes methodology — set up the environment, exercise the changed behavior as a real user would, and post the outcome by editing the PR description with a QA Agent section.",
"requires": {
"integrations": {
"github": {
"message": "Used to read the pull request, check the author's commit history, and edit the PR description."
}
},
"features": [
"repoClone",
"presetPrompt",
"webhookDelivery"
]
},
"popularityRank": 85,
"estimatedSetupMinutes": 5,
"skill": "qa-changes",
"exampleImplementation": "Trigger: GitHub pull_request.opened and pull_request.ready_for_review events\nRequired secret: GITHUB_TOKEN\n\n1. Receive a GitHub pull_request event (opened or ready_for_review).\n2. Filter: the PR must not be a draft (draft == false), the PR description must not already contain a QA Agent section, and the PR author must have more than 3 commits already in main.\n3. Start an OpenHands conversation that clones the repo, checks out the PR head, and runs the /qa-changes methodology: understand the change, set up the environment, exercise the changed behavior as a real user would.\n4. Instead of posting a review comment, edit the PR description to append a horizontal rule followed by a QA Agent section containing the structured QA report.",
"setup": {
"version": "1.0",
"mode": "direct",
"form": {
"triggers": {
"event": {
"on": {
"type": "select",
"label": "Trigger on",
"help": "Which GitHub pull request event starts a QA run. Opened fires when a non-draft PR is created; Ready for review fires when a draft PR is marked ready.",
"default": "pull_request.opened",
"required": true,
"options": [
{
"value": "pull_request.opened",
"label": "Pull request opened"
},
{
"value": "pull_request.ready_for_review",
"label": "Pull request ready for review"
}
]
}
}
},
"args": {
"repository": {
"type": "repo-picker",
"label": "Repository",
"help": "The repository whose pull requests will be QA'd.",
"provider": "github",
"required": true
},
"ref": {
"type": "text",
"label": "Base branch",
"help": "Branch checked out when the agent runs QA.",
"default": "main",
"required": true,
"constraints": {
"minLength": 1,
"maxLength": 255
}
}
}
},
"prompt": "A pull request was opened or marked ready for review in {{form.repository}}. Before doing any QA work, run these gate checks and stop immediately (making no changes) if any fails:\n\n1. The PR must not be a draft — it must be open for review. If the PR is a draft, stop.\n2. The PR author (the human who opened it) must have more than 3 commits already merged into the {{form.ref}} branch. Use the GitHub API to count the author's commits in {{form.ref}}. If the count is 3 or fewer, stop without making any changes.\n3. The PR description must not already contain a section named QA Agent (i.e., a header like ## QA Agent or ### QA Agent). If that section already exists, stop without making any changes or starting a conversation.\n\nIf all gate checks pass, proceed with the QA validation using the /qa-changes skill methodology:\n\n- Phase 1: Understand the change. Read the PR diff, title, and description. Identify the goal of the PR. Classify every changed file. Form a hypothesis about what the PR should achieve.\n- Phase 2: Set up the environment. Read the repo's bootstrap instructions, install dependencies, and build if needed. Note CI status but do not re-run tests.\n- Phase 3: Exercise the changed behavior. Actually run the software the way a real user would — start servers, run CLI commands, make HTTP requests, open browsers. Do NOT run the test suite, linters, or code analysis. For bug fixes, reproduce the bug before and after the fix. Show before/after evidence.\n- Phase 4: Report results.\n\nIMPORTANT — how to post your report:\nDo NOT post a new comment, review, or inline review comment. Instead, EDIT the PR description (the pull request body) to append your QA report at the very end, after a horizontal rule (---), under a new section titled QA Agent. Use the GitHub API (PATCH /repos/{owner}/{repo}/pulls/{pr_number} with a body field) to update the PR description. Append the following structure to the existing PR body:\n\n---\n\n## QA Agent\n\n{Your full QA report following the /qa-changes skill report format: verdict, summary, Does this PR achieve its stated goal? section, status table, collapsible evidence in details blocks, and issues found.}\n\nDo not make any other changes to the PR description — only append the QA Agent section at the end. Do not post any comments or reviews.",
"filter": "!pull_request.draft && !icontains(pull_request.body, 'QA Agent') && glob(repository.full_name, '{{form.repository}}')",
"message": "This deployment cannot run the webhook-driven QA automation directly. Set it up in this conversation instead: confirm the repository to QA, the base branch, and the PR event to trigger on, then create the automation."
}
}
{
"id": "slack-github-linear-daily-organization",
"name": "Slack + GitHub + Linear daily organization",
"category": "Personal productivity",
"description": "Every morning, reconcile your open GitHub PRs with Linear issues, align active work, surface relevant Slack context, and send yourself a concise daily plan.",
"exampleImplementation": "Trigger: cron, every morning at 7:00 AM in the configured timezone\nRequired integrations: GitHub, Linear, Slack\n\n1. Verify access to all three services before making changes.\n2. Reconcile open GitHub PRs with issues and import missing issues into Linear.\n3. Align active Linear work with open PRs and recent Slack context.\n4. Send a concise, prioritized daily plan to the configured Slack recipient.",
"requiredIntegrationIds": [
"slack",
"github",
"linear"
],
"popularityRank": 88,
"estimatedSetupMinutes": 10,
"suggestedTrigger": {
"type": "cron",
"schedule": "0 7 * * *",
"timezone": "[IANA_TIMEZONE]"
},
"placeholders": [
"[GITHUB_USERNAME]",
"[LINEAR_ASSIGNEE_QUERY]",
"[GITHUB_ORG_OR_REPO_SCOPE]",
"[SLACK_RECIPIENT_QUERY]",
"[IANA_TIMEZONE]"
],
"prompt": "You are running a daily GitHub/Linear/Slack organization automation.\n\nSchedule context:\n- This automation runs every morning at 7:00 AM in [IANA_TIMEZONE].\n- Treat \"today\" and \"past 1-3 days\" in [IANA_TIMEZONE].\n\nImportant safety and access rules:\n1. First, verify that you have working access to GitHub, Linear, and Slack using the available official tools/APIs. A minimal successful read for each service is enough.\n2. If any one of GitHub, Linear, or Slack is unavailable, missing credentials, or returns an authorization error, stop immediately. Do not make partial updates. If Slack is available, send the configured Slack recipient a short message saying which service was unavailable and that no changes were made; otherwise finish with that error.\n3. Prefer official APIs/MCP tools over browser interaction. Do not browse web UIs unless no API/tool path exists.\n4. When posting to Slack or creating/updating human-readable external-service content, include a short disclosure that the content was generated by an AI agent (OpenHands) on behalf of the user.\n\nIdentity and scope:\n- GitHub user to inspect: [GITHUB_USERNAME].\n- GitHub issue scope: [GITHUB_ORG_OR_REPO_SCOPE]. Examples: visible repositories in one or more organizations, or an explicit allowlist of repositories.\n- Linear assignee: resolve by searching for [LINEAR_ASSIGNEE_QUERY]. Do not guess an ID if multiple plausible matches exist.\n- Slack recipient for the final daily plan: resolve by searching for [SLACK_RECIPIENT_QUERY], or use the current authenticated Slack user if that is the intended recipient.\n\nTask 1 — Ensure the user's open PRs are associated with GitHub issues:\n1. Find all open pull requests authored by [GITHUB_USERNAME] across the configured GitHub scope.\n2. For each PR, determine whether it is already associated with a GitHub issue. Count an association if the PR body has a clear issue reference or closing keyword (for example, #123, owner/repo#123, fixes, closes, or resolves), or if GitHub's API exposes linked/closing issues.\n3. If a PR has no associated issue:\n a. Search open issues in the same repository for an obvious matching issue using the PR title, branch name, and summary. Only treat an issue as obvious if the title/topic clearly matches; do not force weak matches.\n b. If an obvious open issue exists, use that issue.\n c. If none exists, create a new GitHub issue in the same repository summarizing the PR's purpose and linking back to the PR. Include an AI disclosure in the issue body.\n d. Update the PR description to include a non-closing association such as \"Related issue: #123\" or \"Related issue: owner/repo#123\" for cross-repo references. Preserve the existing PR description. Avoid \"Fixes\", \"Closes\", or \"Resolves\" unless the PR already used a closing keyword or the issue clearly should close when the PR merges.\n\nTask 2 — Ensure GitHub issues are represented in Linear:\n1. Find all open GitHub issues, excluding PRs, in the configured GitHub issue scope.\n2. For each issue, verify that it has a corresponding Linear issue. Match by GitHub issue URL first, then by exact repository/name/number and title if needed.\n3. If no Linear issue exists, create one in the appropriate Linear team/project using the GitHub issue title, URL, repository, labels, and a concise summary. Include an AI disclosure in the Linear issue description. Choose an appropriate priority for newly created Linear issues using this rubric:\n - Urgent: security, production outage, data loss, severe user-blocking regression, active incident.\n - High: important bug/regression, release blocker, broadly affecting users, high-value requested work.\n - Medium: normal feature/bug/task with moderate impact.\n - Low: cleanup, docs, nice-to-have, unclear/low-impact backlog.\n - No priority only if there is insufficient information.\n4. For every GitHub issue that has one or more open PRs associated with it, ensure the corresponding Linear issue is assigned to the configured Linear assignee and has a started/In Progress status. If the Linear issue is already completed or canceled, do not reopen it; mention this in the final report instead.\n5. Do not downgrade or otherwise change priorities of existing Linear issues merely because they seem wrong. If an existing priority is missing and the issue is actively in progress due to an open PR, you may set a reasonable priority. If a priority seems unclear or incorrect, flag it in the final Slack report rather than changing it.\n\nTask 3 — Check Slack context for active Linear issues:\n1. Inspect active Linear issues assigned to the configured assignee and not completed/canceled, especially urgent, high-priority, and in-progress issues.\n2. Search Slack public content and, if available in the tool configuration, private/DM-accessible content for the last 1-3 days for requests, mentions, or discussions related to those Linear issue identifiers, issue titles, linked GitHub issues/PRs, and relevant repository names.\n3. Summarize actionable Slack requests/discussions. Include links to Slack messages/threads when the Slack tooling provides them.\n4. Do not expose private Slack content beyond what is necessary for the configured recipient's own daily plan.\n\nTask 4 — Produce and send the daily plan:\n1. Review all active Linear issues assigned to the configured assignee, prioritizing Urgent then High then Medium then Low, while also considering Slack requests, open PR status, blockers, and issue age.\n2. Create a concise plan for what the recipient should work on today, focusing on the most urgent and important issues first.\n3. Include:\n - Top 3-5 recommended focus items with Linear identifiers/titles and links.\n - Any GitHub PR issue-association work performed.\n - Any GitHub issues imported into Linear.\n - Any Linear issues moved to In Progress or assigned to the configured assignee because an open PR exists.\n - Any Slack requests/discussions from the past 1-3 days relevant to active Linear issues.\n - Any issue priorities that are unclear or appear incorrect; explicitly say you did not change those priorities.\n - Any access or matching limitations.\n4. Send the report to the configured Slack recipient as a DM if possible. If DM is not possible, use the best available Slack destination for that recipient. Include the AI disclosure in the message.\n5. Finish with a short execution summary including counts of PRs checked, PRs updated, GitHub issues created, GitHub issues imported to Linear, Linear issues updated, and Slack discussions found.\n\nBe careful and conservative: avoid duplicate GitHub issues, duplicate Linear issues, weak associations, or broad destructive changes."
}
{
"id": "gitlab",
"name": "GitLab",
"description": "Repositories, issues, merge requests, and CI/CD workflows through the GitLab API.",
"categories": [
"Engineering",
"Source control"
],
"appUrl": "https://gitlab.com",
"docsUrl": "https://docs.gitlab.com/api/",
"notes": "Supports GitLab.com projects through a personal access token.",
"popularityRank": 60,
"connectionOptions": [
{
"id": "api",
"provider": "http",
"auth": {
"strategy": "api_key",
"apiKeyHeaderName": "PRIVATE-TOKEN",
"credentialLabel": "Personal access token",
"credentialPlaceholder": "glpat-...",
"credentialHelp": "Create a token in [GitLab access token settings](https://gitlab.com/-/user_settings/personal_access_tokens) with the api scope.",
"credentialSecretName": "GITLAB_TOKEN",
"saveCredentialAsSecretByDefault": true
},
"http": {
"apiBaseUrl": "https://gitlab.com/api/v4",
"openApiUrl": "https://gitlab.com/gitlab-org/gitlab/-/raw/master/doc/api/openapi/openapi_v2.yaml"
}
}
],
"iconBg": "var(--oh-surface)",
"logoUrl": "https://cdn.simpleicons.org/gitlab/FC6D26",
"keywords": [
"git",
"merge request",
"repo",
"issues",
"code"
]
}
{
"name": "ticket-to-code-change",
"version": "1.0.0",
"description": "Create pull or merge requests from Jira or Linear tickets across GitHub, GitLab, and Bitbucket.",
"author": {
"name": "OpenHands",
"email": "contact@all-hands.dev"
},
"homepage": "https://github.com/OpenHands/extensions",
"repository": "https://github.com/OpenHands/extensions",
"license": "MIT",
"keywords": [
"jira",
"linear",
"github",
"gitlab",
"bitbucket",
"pull-request",
"automation"
]
}
---
# auto-generated by sync_extensions.py
description: Set up a ticket-to-code-change automation using Jira or Linear as the issue tracker and GitHub, GitLab, or Bitbucket as the source-control provider. Watches for implementation-ready tickets, starts an OpenHands conversation to implement and test the request, opens a pull or merge request, and links the result back to the ticket.
---
Read and follow the complete instructions in the SKILL.md file located in this skill's directory.
$ARGUMENTS
# Ticket to code change
Sets up a Jira or Linear automation that sends implementation-ready tickets to OpenHands and opens the resulting change in GitHub, GitLab, or Bitbucket.
See `SKILL.md` for prerequisites and the setup workflow.
---
name: ticket-to-code-change
description: >
Set up a ticket-to-code-change automation using Jira or Linear as the issue
tracker and GitHub, GitLab, or Bitbucket as the source-control provider.
Watches for implementation-ready tickets, starts an OpenHands conversation
to implement and test the request, opens a pull or merge request, and links
the result back to the ticket.
triggers:
- /ticket-to-code-change:setup
---
# Ticket to code change
Create an automation that turns implementation-ready tickets into tested pull
requests or merge requests.
## Information to collect
Ask the user for:
1. The issue tracker: Jira Cloud or Linear.
2. The source-control provider: GitHub, GitLab, or Bitbucket Cloud.
3. The project or team to watch and the label or workflow state that means
"implementation ready".
4. How a ticket identifies its target repository and base branch.
5. The polling schedule or issue event to use.
6. The ticket state to set when work starts and when the change request opens.
7. Whether linked dependencies must be completed before dispatch.
## Setup workflow
1. Verify that both required integrations are connected and can read the
selected project and repository.
2. Prefer an issue event trigger when the deployment can receive events;
otherwise use cron polling with durable issue-ID deduplication.
3. Limit each run to a small configurable number of new tickets. On initial
deployment, establish a baseline instead of dispatching the entire backlog.
4. Build a prompt that includes the full ticket, acceptance criteria,
dependency status, repository, base branch, and provider-specific request
terminology. Require the agent to run the repository's tests before opening
the pull request or merge request.
5. Create the automation through the Automation backend described in
`<RUNTIME_SERVICES>`. Use its prompt-preset endpoint and authenticate with
the runtime-provided automation API key.
6. Configure the automation to post the OpenHands conversation URL immediately
and the resulting pull-request or merge-request URL back to the ticket.
Do not dispatch tickets with unresolved dependencies or without an unambiguous
target repository; comment on the ticket with the missing information instead.
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
def _load_prod_module():
repo_root = Path(__file__).resolve().parents[1]
script_path = repo_root / ".github" / "scripts" / "check_issue_readiness.py"
name = "check_issue_readiness"
spec = importlib.util.spec_from_file_location(name, script_path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
_prod = _load_prod_module()
evaluate_readiness = _prod.evaluate_readiness
extract_sections = _prod.extract_sections
ENHANCEMENT_READY = """### Problem or Use Case
The integration catalog lacks a Linear entry.
### Desired Behavior
`integrations/catalog/linear.json` describes the Linear MCP server so the
catalog build picks it up.
### Acceptance Criteria
- [ ] `npm run build:integrations` succeeds with the new entry
- [ ] `getIntegrationCatalogEntry("linear")` returns the Linear catalog model
"""
BUG_READY = """### Actual Behavior
Running `uv run --group test pytest tests/` fails in
`test_skills_catalog.py` with a KeyError when a skill is missing from the
marketplace.
### Acceptance Criteria
- [ ] `uv run --group test pytest tests/` passes with a clear error message
"""
def test_extract_sections_splits_on_headings():
sections = extract_sections("### Alpha\n\ntext\n\n### Beta\n\nmore\n")
assert sections["alpha"] == "\ntext\n\n"
assert sections["beta"] == "\nmore\n"
def test_enhancement_ready_passes():
result = evaluate_readiness(ENHANCEMENT_READY, ["enhancement"])
assert result.ready is True
assert result.reasons == []
def test_enhancement_missing_acceptance_criteria_fails():
body = "### Desired Behavior\n\nSome desired change.\n"
result = evaluate_readiness(body, ["enhancement"])
assert result.ready is False
assert any("Acceptance Criteria" in r for r in result.reasons)
def test_enhancement_missing_desired_behavior_fails():
body = ENHANCEMENT_READY.replace(
"### Desired Behavior\n\n"
"`integrations/catalog/linear.json` describes the Linear MCP server so the\n"
"catalog build picks it up.\n\n",
"",
)
result = evaluate_readiness(body, ["enhancement"])
assert result.ready is False
assert any("Desired Behavior" in r for r in result.reasons)
def test_bug_ready_passes():
result = evaluate_readiness(BUG_READY, ["bug"])
assert result.ready is True
assert result.reasons == []
def test_bug_missing_run_method_fails():
body = BUG_READY.replace(
"Running `uv run --group test pytest tests/` fails",
"Running the test suite fails",
)
result = evaluate_readiness(body, ["bug"])
assert result.ready is False
assert any("reproducible command" in r for r in result.reasons)
def test_bug_npm_build_command_is_a_valid_run_method():
body = BUG_READY.replace(
"Running `uv run --group test pytest tests/` fails in\n"
"`test_skills_catalog.py` with a KeyError when a skill is missing from the\n"
"marketplace.",
"Running `npm run build:skills` fails with a schema error.",
)
result = evaluate_readiness(body, ["bug"])
assert result.ready is True
assert result.reasons == []
def test_bug_acceptance_needs_checklist_item():
body = BUG_READY.replace(
"- [ ] `uv run --group test pytest tests/` passes with a clear error message",
"Make the test error message clearer",
)
result = evaluate_readiness(body, ["bug"])
assert result.ready is False
assert any("checklist item" in r for r in result.reasons)
def test_no_response_field_counts_as_empty():
body = BUG_READY.replace(
"### Acceptance Criteria\n\n- [ ] `uv run --group test pytest tests/` "
"passes with a clear error message\n",
"### Acceptance Criteria\n\n_No response_\n",
)
result = evaluate_readiness(body, ["bug"])
assert result.ready is False
assert any("Acceptance Criteria" in r for r in result.reasons)
def test_no_bug_or_enhancement_label_not_ready():
result = evaluate_readiness(ENHANCEMENT_READY, [])
assert result.ready is False
assert any("bug" in r and "enhancement" in r for r in result.reasons)
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
def _load_prod_module():
repo_root = Path(__file__).resolve().parents[1]
script_path = repo_root / ".github" / "scripts" / "check_pr_description.py"
name = "check_pr_description"
spec = importlib.util.spec_from_file_location(name, script_path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
_prod = _load_prod_module()
validate_pr_body = _prod.validate_pr_body
body_from_event = _prod.body_from_event
extract_linked_issue_numbers = _prod.extract_linked_issue_numbers
validate_linked_issue_ready = _prod.validate_linked_issue_ready
fetch_issue_details = _prod.fetch_issue_details
VALID_BODY = """<!-- Keep this PR as draft until it is ready for review. -->
<!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
- [ ] A human has tested these changes.
---
## Why
The repository had no readiness gate for issues or linked PRs.
## Summary
- Add ready-for-dev issue and PR readiness gates.
## Issue Number
Fixes #508
## How to Test
Run `uv run --group test pytest tests/`.
## Video/Screenshots
N/A
## Notes
N/A
"""
def test_valid_pr_body_passes():
assert validate_pr_body(VALID_BODY) == []
def test_required_template_fields_must_be_present_and_filled():
how_to_test = "## How to Test\n\nRun `uv run --group test pytest tests/`."
body = VALID_BODY.replace(how_to_test, "## How to Test\n\n<!-- TODO -->")
body = body.replace("## Summary", "## Details")
errors = validate_pr_body(body)
assert "Fill in the `## How to Test` section of the PR template." in errors
assert "Keep the `## Summary` section from the PR template." in errors
def test_summary_placeholder_bullet_counts_as_empty():
body = VALID_BODY.replace(
"- Add ready-for-dev issue and PR readiness gates.", "-"
)
errors = validate_pr_body(body)
assert "Fill in the `## Summary` section of the PR template." in errors
def test_optional_template_sections_may_be_removed():
body = VALID_BODY.replace("## Issue Number\n\nFixes #508\n\n", "")
body = body.split("## Video/Screenshots", maxsplit=1)[0]
assert validate_pr_body(body) == []
def test_body_from_event_reads_pull_request_body(tmp_path: Path):
event_path = tmp_path / "event.json"
event_path.write_text(
json.dumps(
{
"pull_request": {"body": VALID_BODY},
"repository": {"full_name": "org/repo"},
}
)
)
body, repo = body_from_event(event_path)
assert body == VALID_BODY
assert repo == "org/repo"
def test_extract_linked_issue_numbers_keyword_and_bare_ref():
body = (
"Fixes #12\n"
"Closes #12 again\n"
"resolves #34\n"
"## Issue Number\n"
"Issue: #56, see also #12\n"
)
assert extract_linked_issue_numbers(body) == [12, 34, 56]
def test_extract_linked_issue_numbers_only_bare_ref_in_issue_section():
body = "## Summary\n\nSome work.\n\n## Issue Number\n\n#7\n"
assert extract_linked_issue_numbers(body) == [7]
def test_extract_linked_issue_numbers_no_bare_ref_outside_issue_section():
# A bare `#42` in the Summary must not be treated as a linked issue.
body = "## Summary\n\nSee #42 for background.\n\n## Issue Number\n\nN/A\n"
assert extract_linked_issue_numbers(body) == []
def test_extract_linked_issue_numbers_keyword_inside_word_is_ignored():
# "fix"/"clos"/"resolv" must not match inside larger words (e.g. "crucifixes").
body = (
"## Summary\n\n"
"crucifixes #12, encloses #34, transfixes #56.\n\n"
"## Issue Number\n\nN/A\n"
)
assert extract_linked_issue_numbers(body) == []
def test_validate_linked_issue_ready_requires_a_number():
errors = validate_linked_issue_ready(
"## Issue Number\n\nN/A\n", "org/repo", "token"
)
assert errors and "Link an issue" in errors[0]
def test_validate_linked_issue_ready_no_token_skips_network(monkeypatch):
def _fail(*args, **kwargs):
raise AssertionError("should not call the network without a token")
monkeypatch.setattr(_prod, "fetch_issue_details", _fail)
assert validate_linked_issue_ready("Fixes #12\n", None, None) == []
def test_validate_linked_issue_ready_passes_with_ready_label(monkeypatch):
monkeypatch.setattr(
_prod,
"fetch_issue_details",
lambda repo, num, token: (["ready-for-dev"], "2026-08-25T00:00:00Z"),
)
assert validate_linked_issue_ready("Fixes #12\n", "org/repo", "token") == []
def test_validate_linked_issue_ready_grandfathers_pre_rollout_issue(monkeypatch):
monkeypatch.setattr(
_prod,
"fetch_issue_details",
lambda repo, num, token: (["bug"], "2026-01-15T00:00:00Z"),
)
assert validate_linked_issue_ready("Fixes #12\n", "org/repo", "token") == []
def test_validate_linked_issue_ready_grandfathers_rollout_day_before_deployment(
monkeypatch,
):
# Opened on the rollout day (2026-08-24) before the workflow was deployed,
# so it was never labeled. It must be exempt.
monkeypatch.setattr(
_prod,
"fetch_issue_details",
lambda repo, num, token: (["bug"], "2026-08-24T06:46:00Z"),
)
assert validate_linked_issue_ready("Fixes #12\n", "org/repo", "token") == []
def test_validate_linked_issue_ready_fails_for_new_not_ready_issue(monkeypatch):
monkeypatch.setattr(
_prod,
"fetch_issue_details",
lambda repo, num, token: (["bug"], "2026-08-25T00:00:00Z"),
)
errors = validate_linked_issue_ready("Fixes #12\n", "org/repo", "token")
assert errors and "ready-for-dev" in errors[0]
def test_validate_linked_issue_ready_new_unready_not_masked_by_ready_sibling(
monkeypatch,
):
def _issues(repo, num, token):
# #12 carries ready-for-dev; #34 is new and not ready.
if num == 34:
return ["bug"], "2026-08-25T00:00:00Z"
return ["ready-for-dev"], "2026-08-25T00:00:00Z"
monkeypatch.setattr(_prod, "fetch_issue_details", _issues)
body = "Fixes #12 and Closes #34"
errors = validate_linked_issue_ready(body, "org/repo", "token")
assert "#34" in errors[0]
assert "ready-for-dev" in errors[0]
def test_validate_linked_issue_ready_returns_error_when_all_issues_not_found(
monkeypatch,
):
import urllib.error
from http.client import HTTPMessage
def _missing(repo, num, token):
raise urllib.error.HTTPError(
"https://api.github.com", 404, "Not Found", HTTPMessage(), None
)
monkeypatch.setattr(_prod, "fetch_issue_details", _missing)
errors = validate_linked_issue_ready("Fixes #12\n", "org/repo", "token")
assert errors and "could not be found" in errors[0]
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
def _load(name: str, script_name: str):
script_path = (
Path(__file__).resolve().parents[1] / ".github" / "scripts" / script_name
)
spec = importlib.util.spec_from_file_location(name, script_path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
# Import check_pr_description first so refresh_linked_pr_checks can resolve its
# `from check_pr_description import ...` against the module we loaded above.
_load("check_pr_description", "check_pr_description.py")
_prod = _load("refresh_linked_pr_checks", "refresh_linked_pr_checks.py")
def _event(action="labeled", label="ready-for-dev", number=12):
return {
"action": action,
"issue": {"number": number},
"label": {"name": label},
"repository": {"full_name": "org/repo"},
}
def _write_event(monkeypatch, payload, tmp_path):
event_path = tmp_path / "event.json"
event_path.write_text(json.dumps(payload))
monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_path))
class _FakeProc:
def __init__(self, stdout: str = ""):
self.returncode = 0
self.stdout = stdout
self.stderr = ""
def _recording_run(calls):
def _call(args):
calls.append(args)
# Listing runs returns one run; rerun is a no-op success.
if any("/actions/runs" in a for a in args):
return _FakeProc("8675309 2026-08-25T00:00:00Z\n")
return _FakeProc()
return _call
def _fail_on_call(value):
def _call(*args, **kwargs):
raise AssertionError(value)
return _call
def test_noop_for_unrelated_label(monkeypatch, tmp_path):
_write_event(monkeypatch, _event(label="bug"), tmp_path)
monkeypatch.setattr(_prod, "_linked_open_prs", _fail_on_call("unexpected"))
assert _prod.main() == 0
def test_noop_for_unrelated_action(monkeypatch, tmp_path):
_write_event(monkeypatch, _event(action="edited"), tmp_path)
monkeypatch.setattr(_prod, "_linked_open_prs", _fail_on_call("unexpected"))
assert _prod.main() == 0
def test_reruns_linked_pr_check_when_readiness_label_changes(monkeypatch, tmp_path):
_write_event(monkeypatch, _event(), tmp_path)
monkeypatch.setattr(
_prod,
"_linked_open_prs",
lambda repo, num: [{"number": 7, "headRefOid": "abc123"}],
)
monkeypatch.setattr(_prod, "_run", lambda args: _FakeProc("Fixes #12"))
seen = []
monkeypatch.setattr(
_prod,
"_rerun_pr_description_check",
lambda repo, sha: (seen.append((repo, sha)) or True),
)
assert _prod.main() == 0
assert seen == [("org/repo", "abc123")]
def test_skips_cross_referenced_pr_that_does_not_link_issue(monkeypatch, tmp_path):
_write_event(monkeypatch, _event(), tmp_path)
monkeypatch.setattr(
_prod,
"_linked_open_prs",
lambda repo, num: [{"number": 7, "headRefOid": "abc123"}],
)
# Body mentions #99 (the cross-reference) but not the event's issue #12.
monkeypatch.setattr(_prod, "_run", lambda args: _FakeProc("Fixes #99"))
seen = []
monkeypatch.setattr(
_prod,
"_rerun_pr_description_check",
lambda repo, sha: (seen.append((repo, sha)) or True),
)
assert _prod.main() == 0
assert seen == []
def test_rerun_pr_description_check_lists_runs_with_get(monkeypatch):
calls: list[list[str]] = []
monkeypatch.setattr(_prod, "_run", _recording_run(calls))
assert _prod._rerun_pr_description_check("org/repo", "abc123") is True
# Listing runs must be an explicit GET: `-f` args alone switch `gh api`
# to POST, which 404s on the runs collection endpoint (seen in the wild).
list_call = next(a for a in calls if "repos/org/repo/actions/runs" in a)
assert "-X" in list_call
assert list_call[list_call.index("-X") + 1] == "GET"
# The selected run is then re-run via an explicit POST.
rerun_call = next(a for a in calls if any("/rerun" in arg for arg in a))
assert rerun_call[rerun_call.index("api") + 1 : rerun_call.index("api") + 3] == [
"-X",
"POST",
]
+7
-0

@@ -10,2 +10,9 @@ ---

## Repository Boundaries
Review whether a PR belongs in this public extensions registry. Skills, plugins, automations, and integrations belong here; Agent Server behavior and API endpoints belong in [`OpenHands/software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk), typed browser API access belongs in [`OpenHands/typescript-client`](https://github.com/OpenHands/typescript-client), UI belongs in [`OpenHands/OpenHands`](https://github.com/OpenHands/OpenHands), and scheduling/dispatch lifecycle behavior belongs in [`OpenHands/automation`](https://github.com/OpenHands/automation).
If a PR is opened in the wrong repository, explicitly recommend that it may need to be closed and moved to the repository that owns the change rather than merged here. Apply the repository's contribution and review guidance to every PR.
## SDK Documentation Placement

@@ -12,0 +19,0 @@

+1
-1
{
".": "0.18.0"
".": "0.19.0"
}

@@ -6,2 +6,16 @@ # OpenHands Extensions — Agent Notes

## Cross-Repository Boundaries
This repository owns the public registry of reusable OpenHands skills, plugins, automations, and integrations. These extensions are consumed by OpenHands applications and SDK-based clients.
Related repositories have distinct responsibilities:
- [`OpenHands/software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) owns the Python SDK, Agent Server, agent/tool behavior, conversations, workspaces, events, and canonical API.
- [`OpenHands/typescript-client`](https://github.com/OpenHands/typescript-client) owns the browser-compatible typed Agent Server client.
- [`OpenHands/OpenHands`](https://github.com/OpenHands/OpenHands) owns Agent Canvas UI and local-stack orchestration.
- [`OpenHands/automation`](https://github.com/OpenHands/automation) owns scheduling, webhooks, run history, dispatch, and sandbox lifecycle orchestration.
Put reusable skills, plugins, automations, and integrations here; put backend execution behavior in the SDK, typed API access in `typescript-client`, application UI in Agent Canvas, and scheduling/dispatch lifecycle code in `automation`. If a PR is opened in the wrong repository, explicitly recommend closing and moving it to the owning repository. PRs must follow this repository's applicable contribution and code-review guidance.
## What this repo contains

@@ -114,2 +128,4 @@

- The `ready-for-dev` gates live in `.github/workflows/issue-readiness-check.yml` and `.github/workflows/pr-description-check.yml`, backed by `.github/scripts/check_issue_readiness.py`, `check_pr_description.py`, `refresh_linked_pr_checks.py`, and `post-readiness-comment.mjs`. Issues labeled `bug` or `enhancement` must meet type-specific readiness criteria (reproducible command plus Acceptance Criteria checklist for bugs; Desired Behavior plus Acceptance Criteria checklist for enhancements) to receive the `ready-for-dev` label. Non-draft PRs must keep the `## Why`, `## Summary`, and `## How to Test` template sections filled, and any issue linked via a closing keyword or the `## Issue Number` section must carry `ready-for-dev` unless it predates the 2026-08-25 rollout. Label transitions on an issue re-run the PR Description Check for linked open PRs. Tests live in `tests/test_check_issue_readiness.py`, `tests/test_check_pr_description.py`, and `tests/test_refresh_linked_pr_checks.py`.
## OpenHands SDK documentation policy

@@ -116,0 +132,0 @@

@@ -11,8 +11,14 @@ // This file is auto-generated by scripts/build-automation-catalog.mjs.

import entry5 from "./catalog/linear-triage-assistant/manifest.json" with { type: "json" };
import entry6 from "./catalog/jira-issue-to-pr/manifest.json" with { type: "json" };
import entry7 from "./catalog/research-brief-writer/manifest.json" with { type: "json" };
import entry8 from "./catalog/github-agents-md-maintainer/manifest.json" with { type: "json" };
import entry9 from "./catalog/upstream-fork-sync/manifest.json" with { type: "json" };
import entry10 from "./catalog/incident-retrospective-drafter/manifest.json" with { type: "json" };
import entry11 from "./catalog/news-digest/manifest.json" with { type: "json" };
import entry6 from "./catalog/linear-issue-to-github-pr/manifest.json" with { type: "json" };
import entry7 from "./catalog/linear-issue-to-gitlab-mr/manifest.json" with { type: "json" };
import entry8 from "./catalog/linear-issue-to-bitbucket-pr/manifest.json" with { type: "json" };
import entry9 from "./catalog/jira-issue-to-pr/manifest.json" with { type: "json" };
import entry10 from "./catalog/qa-changes/manifest.json" with { type: "json" };
import entry11 from "./catalog/jira-issue-to-gitlab-mr/manifest.json" with { type: "json" };
import entry12 from "./catalog/research-brief-writer/manifest.json" with { type: "json" };
import entry13 from "./catalog/jira-issue-to-bitbucket-pr/manifest.json" with { type: "json" };
import entry14 from "./catalog/github-agents-md-maintainer/manifest.json" with { type: "json" };
import entry15 from "./catalog/upstream-fork-sync/manifest.json" with { type: "json" };
import entry16 from "./catalog/incident-retrospective-drafter/manifest.json" with { type: "json" };
import entry17 from "./catalog/news-digest/manifest.json" with { type: "json" };

@@ -32,2 +38,8 @@ export const AUTOMATION_CATALOG_ENTRIES = [

entry11,
entry12,
entry13,
entry14,
entry15,
entry16,
entry17,
];

@@ -49,5 +49,30 @@ {

},
"impact": {
"description": "The value statement the host renders from an installed automation's run history. Declare it only when its basis honestly backs the phrase: for completed-runs, one completed run must always perform exactly one of the stated units of work, manually dispatched runs included, so a poller that can complete having produced nothing must phrase the run itself, never the downstream outcome.",
"type": "object",
"additionalProperties": false,
"required": ["basis", "one", "other"],
"properties": {
"basis": {
"description": "What the statement counts. The only basis today is the automation's lifetime COMPLETED-run count; a host that meets a basis it does not know renders nothing.",
"const": "completed-runs"
},
"one": { "$ref": "#/$defs/impactCopy" },
"other": {
"$comment": "The plural phrase must show the number; the singular may spell it out instead.",
"allOf": [{ "$ref": "#/$defs/impactCopy" }],
"pattern": "\\{\\{count\\}\\}"
}
}
},
"setup": { "$ref": "#/$defs/setup" }
},
"$defs": {
"impactCopy": {
"description": "One value-statement phrase. Markup-free, and the only placeholder it may open is {{count}}, which the host substitutes with the formatted run count.",
"allOf": [
{ "$ref": "#/$defs/copy" },
{ "not": { "pattern": "\\{\\{(?!count\\}\\})" } }
]
},
"setup": {

@@ -54,0 +79,0 @@ "description": "The extension-owned configuration experience for this automation: what the deployment must support, what must be connected first, what the user is asked, how a draft is validated, what request is sent, and which analytics stages are emitted. It never describes what the automation does at runtime - that is the preset, owned by OpenHands/automation. It is data, not code - there is no key that accepts JavaScript, no markup in copy, no free-form URL, and no secret value.",

@@ -20,2 +20,7 @@ {

"exampleImplementation": "Trigger: cron, weekly by default (0 9 * * 1)\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN, with permission to write contents and pull requests\n\n1. Read the repositories, branch prefix, pull request mode, and schedule from setup.\n2. Process each repository independently, with its own state, so one falling behind never blocks another.\n3. Key one unit of work to the ISO week, so a cron that fires more often, a retried run, or a restarted service cannot open the same pull request twice.\n4. Skip a repository whose previous pull request from this automation is still open; a second one would edit the same file.\n5. Ask GitHub whether AGENTS.md exists, which decides create vs update and the pull request title.\n6. Clone the default branch into a directory of its own, create the working branch, and start an OpenHands conversation with that directory as its workspace.\n7. The agent reads the repository, edits AGENTS.md, commits, pushes, and opens the pull request; the script verifies that on GitHub and opens it itself when the agent did not.\n8. Record no-changes when AGENTS.md is already accurate, which is the expected result most weeks.\n9. Remove the clone once the conversation has stopped.",
"impact": {
"basis": "completed-runs",
"one": "1 maintenance pass completed",
"other": "{{count}} maintenance passes completed"
},
"setup": {

@@ -22,0 +27,0 @@ "version": "1.0",

@@ -19,2 +19,7 @@ {

"exampleImplementation": "Trigger: cron polling for open GitHub issues with a configured label such as openhands\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN, with permission to write contents, issues, and pull requests\n\n1. Read the repositories, trigger label, branch prefix, draft mode, and polling schedule from setup.\n2. Poll each repository independently, with its own state, so issue numbers never collide.\n3. List open labelled issues, drop pull requests, and find the latest matching GitHub labeled issue event for each.\n4. Deduplicate on the label event ID so every label application queues exactly one attempt.\n5. Clone the default branch into a directory of its own, create the working branch, and start an OpenHands conversation with that directory as its workspace. The clone carries no credential and the agent is handed no secrets, because the prompt is built from an issue body that anyone can write.\n6. Comment on the issue with the branch and the conversation link.\n7. Once the conversation has stopped, commit whatever the agent left, push the branch, open a draft pull request titled after the issue, and comment the link on the issue. An agent that made no changes gets its answer posted instead.\n8. Remove the clone once the conversation has stopped, so nothing accumulates between runs.",
"impact": {
"basis": "completed-runs",
"one": "1 issue sweep completed",
"other": "{{count}} issue sweeps completed"
},
"setup": {

@@ -21,0 +26,0 @@ "version": "1.0",

@@ -20,2 +20,7 @@ {

"exampleImplementation": "Trigger: cron polling for open GitHub PRs with a configured label such as openhands-review\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN, with permission to write pull request reviews\n\n1. Read the repositories, trigger label, review tone, and polling schedule from setup.\n2. Poll each repository independently, with its own state, so PR numbers never collide.\n3. List open PRs and find the latest matching GitHub labeled issue event for each labeled PR.\n4. Deduplicate on the label event ID so every label application queues exactly one review.\n5. Extract the PR head commit into a directory of its own and start an OpenHands conversation with that directory as its workspace, so the agent reviews the exact commit without cloning anything.\n6. Post an acknowledgement with the conversation link, then confirm on GitHub that the review was published for that head SHA, falling back to posting the agent's text as a comment.\n7. Remove the checkout once the conversation has stopped, so nothing accumulates between runs.",
"impact": {
"basis": "completed-runs",
"one": "1 PR review sweep completed",
"other": "{{count}} PR review sweeps completed"
},
"setup": {

@@ -22,0 +27,0 @@ "version": "1.0",

@@ -21,2 +21,7 @@ {

"exampleImplementation": "Trigger: cron polling (e.g. every 15 minutes, 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.",
"impact": {
"basis": "completed-runs",
"one": "1 repo scan completed",
"other": "{{count}} repo scans completed"
},
"setup": {

@@ -23,0 +28,0 @@ "version": "1.0",

@@ -16,2 +16,7 @@ {

"exampleImplementation": "Trigger: cron, daily by default (0 8 * * *)\nRequired secret: none. The feeds are public URLs and the conversation is started with an empty secret allow-list and no MCP servers.\n\n1. Read the feed list, the topics, and the schedule from setup.\n2. Key one unit of work to the UTC date, so a cron that fires more often, a retried run, or a restarted service cannot write the same digest twice.\n3. Fetch every feed over plain HTTPS and parse RSS 2.0, RSS 1.0/RDF and Atom by local element name. A feed that is down, moved, or no longer a feed is reported and skipped; the run fails only when every feed fails.\n4. Drop stories already covered by an earlier digest and stories older than the lookback window. Do not judge what a story is about: that has no right answer and is the agent's call.\n5. Start no conversation at all when nothing new was published, and leave the day open for a later run. A quiet day costs no tokens.\n6. Otherwise start an OpenHands conversation with the newest stories and the topics in the prompt, and let it decide which are relevant, group them, merge duplicate coverage, and write the digest.\n7. Deliver it three ways that need no credentials: it stays in the conversation, it is printed into the run log, and its opening is kept in state.\n8. Remember the reported stories only once a digest exists, so a failed run is recovered by the next one rather than lost.",
"impact": {
"basis": "completed-runs",
"one": "1 feed check completed",
"other": "{{count}} feed checks completed"
},
"setup": {

@@ -18,0 +23,0 @@ "version": "1.0",

@@ -33,2 +33,12 @@ export interface RecommendedAutomation {

exampleImplementation: string;
/**
* The value statement the host renders from an installed automation's run
* history. Declared only when its basis honestly backs the phrase: for
* `completed-runs`, one completed run must always perform exactly one of
* the stated units of work, manually dispatched runs included, so a poller
* that can complete having produced nothing phrases the run itself, never
* the downstream outcome. `{{count}}` is the host-substituted run count; a
* host that meets a basis it does not know renders nothing.
*/
impact?: AutomationImpact;
/** Present when this automation ships an extension-owned setup experience. */

@@ -38,2 +48,8 @@ setup?: AutomationSetup;

export interface AutomationImpact {
basis: "completed-runs";
one: string;
other: string;
}
/**

@@ -40,0 +56,0 @@ * The extension-owned configuration experience for one automation.

@@ -12,71 +12,72 @@ // This file is auto-generated by scripts/build-integration-catalog.mjs.

import entry6 from "./catalog/vanta.json" with { type: "json" };
import entry7 from "./catalog/ordinal.json" with { type: "json" };
import entry8 from "./catalog/elevenlabs.json" with { type: "json" };
import entry9 from "./catalog/bitbucket.json" with { type: "json" };
import entry10 from "./catalog/xero.json" with { type: "json" };
import entry11 from "./catalog/quickbooks.json" with { type: "json" };
import entry12 from "./catalog/sonarqube.json" with { type: "json" };
import entry13 from "./catalog/okta.json" with { type: "json" };
import entry14 from "./catalog/netlify.json" with { type: "json" };
import entry15 from "./catalog/vercel.json" with { type: "json" };
import entry16 from "./catalog/supabase.json" with { type: "json" };
import entry17 from "./catalog/posthog.json" with { type: "json" };
import entry18 from "./catalog/sentry.json" with { type: "json" };
import entry19 from "./catalog/datadog.json" with { type: "json" };
import entry20 from "./catalog/canva.json" with { type: "json" };
import entry21 from "./catalog/miro.json" with { type: "json" };
import entry22 from "./catalog/webflow.json" with { type: "json" };
import entry23 from "./catalog/zoom.json" with { type: "json" };
import entry24 from "./catalog/discord.json" with { type: "json" };
import entry25 from "./catalog/stripe.json" with { type: "json" };
import entry26 from "./catalog/intercom.json" with { type: "json" };
import entry27 from "./catalog/hubspot.json" with { type: "json" };
import entry28 from "./catalog/salesforce.json" with { type: "json" };
import entry29 from "./catalog/sharepoint.json" with { type: "json" };
import entry30 from "./catalog/onedrive.json" with { type: "json" };
import entry31 from "./catalog/microsoft-teams.json" with { type: "json" };
import entry32 from "./catalog/microsoft-outlook.json" with { type: "json" };
import entry33 from "./catalog/box.json" with { type: "json" };
import entry34 from "./catalog/airtable.json" with { type: "json" };
import entry35 from "./catalog/monday.json" with { type: "json" };
import entry36 from "./catalog/trello.json" with { type: "json" };
import entry37 from "./catalog/asana.json" with { type: "json" };
import entry38 from "./catalog/confluence.json" with { type: "json" };
import entry39 from "./catalog/jira.json" with { type: "json" };
import entry40 from "./catalog/google-calendar.json" with { type: "json" };
import entry41 from "./catalog/gmail.json" with { type: "json" };
import entry42 from "./catalog/google-sheets.json" with { type: "json" };
import entry43 from "./catalog/google-drive.json" with { type: "json" };
import entry44 from "./catalog/figma.json" with { type: "json" };
import entry45 from "./catalog/google-docs.json" with { type: "json" };
import entry46 from "./catalog/apify.json" with { type: "json" };
import entry47 from "./catalog/atlassian-rovo.json" with { type: "json" };
import entry48 from "./catalog/brave-search.json" with { type: "json" };
import entry49 from "./catalog/browser-mcp.json" with { type: "json" };
import entry50 from "./catalog/clickhouse.json" with { type: "json" };
import entry51 from "./catalog/cloudflare-bindings.json" with { type: "json" };
import entry52 from "./catalog/cloudflare-browser-rendering.json" with { type: "json" };
import entry53 from "./catalog/cloudflare-builds.json" with { type: "json" };
import entry54 from "./catalog/cloudflare-docs.json" with { type: "json" };
import entry55 from "./catalog/cloudflare-observability.json" with { type: "json" };
import entry56 from "./catalog/deepwiki.json" with { type: "json" };
import entry57 from "./catalog/everything.json" with { type: "json" };
import entry58 from "./catalog/exa.json" with { type: "json" };
import entry59 from "./catalog/fetch.json" with { type: "json" };
import entry60 from "./catalog/filesystem.json" with { type: "json" };
import entry61 from "./catalog/firecrawl.json" with { type: "json" };
import entry62 from "./catalog/git.json" with { type: "json" };
import entry63 from "./catalog/huggingface.json" with { type: "json" };
import entry64 from "./catalog/kagi.json" with { type: "json" };
import entry65 from "./catalog/memory.json" with { type: "json" };
import entry66 from "./catalog/mongodb.json" with { type: "json" };
import entry67 from "./catalog/neon.json" with { type: "json" };
import entry68 from "./catalog/obsidian.json" with { type: "json" };
import entry69 from "./catalog/paypal.json" with { type: "json" };
import entry70 from "./catalog/playwright.json" with { type: "json" };
import entry71 from "./catalog/redis.json" with { type: "json" };
import entry72 from "./catalog/resend.json" with { type: "json" };
import entry73 from "./catalog/sequential-thinking.json" with { type: "json" };
import entry74 from "./catalog/superhuman-mail.json" with { type: "json" };
import entry75 from "./catalog/time.json" with { type: "json" };
import entry7 from "./catalog/gitlab.json" with { type: "json" };
import entry8 from "./catalog/ordinal.json" with { type: "json" };
import entry9 from "./catalog/elevenlabs.json" with { type: "json" };
import entry10 from "./catalog/bitbucket.json" with { type: "json" };
import entry11 from "./catalog/xero.json" with { type: "json" };
import entry12 from "./catalog/quickbooks.json" with { type: "json" };
import entry13 from "./catalog/sonarqube.json" with { type: "json" };
import entry14 from "./catalog/okta.json" with { type: "json" };
import entry15 from "./catalog/netlify.json" with { type: "json" };
import entry16 from "./catalog/vercel.json" with { type: "json" };
import entry17 from "./catalog/supabase.json" with { type: "json" };
import entry18 from "./catalog/posthog.json" with { type: "json" };
import entry19 from "./catalog/sentry.json" with { type: "json" };
import entry20 from "./catalog/datadog.json" with { type: "json" };
import entry21 from "./catalog/canva.json" with { type: "json" };
import entry22 from "./catalog/miro.json" with { type: "json" };
import entry23 from "./catalog/webflow.json" with { type: "json" };
import entry24 from "./catalog/zoom.json" with { type: "json" };
import entry25 from "./catalog/discord.json" with { type: "json" };
import entry26 from "./catalog/stripe.json" with { type: "json" };
import entry27 from "./catalog/intercom.json" with { type: "json" };
import entry28 from "./catalog/hubspot.json" with { type: "json" };
import entry29 from "./catalog/salesforce.json" with { type: "json" };
import entry30 from "./catalog/sharepoint.json" with { type: "json" };
import entry31 from "./catalog/onedrive.json" with { type: "json" };
import entry32 from "./catalog/microsoft-teams.json" with { type: "json" };
import entry33 from "./catalog/microsoft-outlook.json" with { type: "json" };
import entry34 from "./catalog/box.json" with { type: "json" };
import entry35 from "./catalog/airtable.json" with { type: "json" };
import entry36 from "./catalog/monday.json" with { type: "json" };
import entry37 from "./catalog/trello.json" with { type: "json" };
import entry38 from "./catalog/asana.json" with { type: "json" };
import entry39 from "./catalog/confluence.json" with { type: "json" };
import entry40 from "./catalog/jira.json" with { type: "json" };
import entry41 from "./catalog/google-calendar.json" with { type: "json" };
import entry42 from "./catalog/gmail.json" with { type: "json" };
import entry43 from "./catalog/google-sheets.json" with { type: "json" };
import entry44 from "./catalog/google-drive.json" with { type: "json" };
import entry45 from "./catalog/figma.json" with { type: "json" };
import entry46 from "./catalog/google-docs.json" with { type: "json" };
import entry47 from "./catalog/apify.json" with { type: "json" };
import entry48 from "./catalog/atlassian-rovo.json" with { type: "json" };
import entry49 from "./catalog/brave-search.json" with { type: "json" };
import entry50 from "./catalog/browser-mcp.json" with { type: "json" };
import entry51 from "./catalog/clickhouse.json" with { type: "json" };
import entry52 from "./catalog/cloudflare-bindings.json" with { type: "json" };
import entry53 from "./catalog/cloudflare-browser-rendering.json" with { type: "json" };
import entry54 from "./catalog/cloudflare-builds.json" with { type: "json" };
import entry55 from "./catalog/cloudflare-docs.json" with { type: "json" };
import entry56 from "./catalog/cloudflare-observability.json" with { type: "json" };
import entry57 from "./catalog/deepwiki.json" with { type: "json" };
import entry58 from "./catalog/everything.json" with { type: "json" };
import entry59 from "./catalog/exa.json" with { type: "json" };
import entry60 from "./catalog/fetch.json" with { type: "json" };
import entry61 from "./catalog/filesystem.json" with { type: "json" };
import entry62 from "./catalog/firecrawl.json" with { type: "json" };
import entry63 from "./catalog/git.json" with { type: "json" };
import entry64 from "./catalog/huggingface.json" with { type: "json" };
import entry65 from "./catalog/kagi.json" with { type: "json" };
import entry66 from "./catalog/memory.json" with { type: "json" };
import entry67 from "./catalog/mongodb.json" with { type: "json" };
import entry68 from "./catalog/neon.json" with { type: "json" };
import entry69 from "./catalog/obsidian.json" with { type: "json" };
import entry70 from "./catalog/paypal.json" with { type: "json" };
import entry71 from "./catalog/playwright.json" with { type: "json" };
import entry72 from "./catalog/redis.json" with { type: "json" };
import entry73 from "./catalog/resend.json" with { type: "json" };
import entry74 from "./catalog/sequential-thinking.json" with { type: "json" };
import entry75 from "./catalog/superhuman-mail.json" with { type: "json" };
import entry76 from "./catalog/time.json" with { type: "json" };

@@ -160,2 +161,3 @@ export const INTEGRATION_CATALOG_ENTRIES = [

entry75,
entry76,
];

@@ -46,3 +46,3 @@ {

{
"key": "DD_API_KEY",
"key": "DD-API-KEY",
"label": "Datadog API key",

@@ -55,3 +55,3 @@ "type": "password",

{
"key": "DD_APPLICATION_KEY",
"key": "DD-APPLICATION-KEY",
"label": "Datadog Application key",

@@ -58,0 +58,0 @@ "type": "password",

@@ -55,3 +55,3 @@ {

{
"key": "NOTION_API_KEY",
"key": "NOTION_TOKEN",
"label": "Internal integration token",

@@ -58,0 +58,0 @@ "type": "password",

@@ -20,3 +20,3 @@ export type MarketplaceFieldType = "text" | "password";

* Named request headers the user must supply (e.g. Datadog's
* `DD_API_KEY` / `DD_APPLICATION_KEY`). Values are sent verbatim as
* `DD-API-KEY` / `DD-APPLICATION-KEY`). Values are sent verbatim as
* headers on every MCP request. The direct analog of stdio's

@@ -23,0 +23,0 @@ * `envFields`: each entry renders one input in the install modal,

@@ -8,3 +8,3 @@ {

"metadata": {
"description": "Official skills and plugins for OpenHands \u2014 the open-source AI software engineer.",
"description": "Official skills and plugins for OpenHands — the open-source AI software engineer.",
"maintainer": "OpenHands",

@@ -17,3 +17,3 @@ "homepage": "https://github.com/OpenHands/extensions"

"source": "./skills/agent-creator",
"description": "Create file-based sub-agents as Markdown files \u2014 no Python code required. Guides the user through a structured interview and generates a ready-to-deploy .md agent file following the OpenHands SDK specification.",
"description": "Create file-based sub-agents as Markdown files — no Python code required. Guides the user through a structured interview and generates a ready-to-deploy .md agent file following the OpenHands SDK specification.",
"category": "agent-authoring",

@@ -443,3 +443,3 @@ "keywords": [

"source": "./plugins/openhands",
"description": "Unified OpenHands plugin \u2014 bundles Cloud CLI, REST API (openhands-api), and Automations (openhands-automation) into a single plugin.",
"description": "Unified OpenHands plugin — bundles Cloud CLI, REST API (openhands-api), and Automations (openhands-automation) into a single plugin.",
"category": "openhands",

@@ -486,3 +486,3 @@ "keywords": [

"source": "./plugins/pr-review",
"description": "Automated PR code review \u2014 analyzes diffs and posts inline review comments via the GitHub API.",
"description": "Automated PR code review — analyzes diffs and posts inline review comments via the GitHub API.",
"category": "code-quality",

@@ -499,3 +499,3 @@ "keywords": [

"source": "./plugins/qa-changes",
"description": "Validate pull request changes by actually running the code \u2014 setting up the environment, exercising changed behavior, and posting a structured QA report.",
"description": "Validate pull request changes by actually running the code — setting up the environment, exercising changed behavior, and posting a structured QA report.",
"category": "quality-assurance",

@@ -664,3 +664,3 @@ "keywords": [

"source": "./skills/iterate",
"description": "Iterate on a GitHub pull request \u2014 drive it through CI, code review, and QA until merge-ready. Monitors state, fixes failures, addresses review feedback, retries flaky checks, and pushes fixes in one continuous loop.",
"description": "Iterate on a GitHub pull request — drive it through CI, code review, and QA until merge-ready. Monitors state, fixes failures, addresses review feedback, retries flaky checks, and pushes fixes in one continuous loop.",
"category": "code-quality",

@@ -828,4 +828,19 @@ "keywords": [

]
},
{
"name": "ticket-to-code-change",
"source": "./skills/ticket-to-code-change",
"description": "Set up Jira or Linear ticket-to-code-change automations for GitHub, GitLab, and Bitbucket.",
"category": "automations",
"keywords": [
"jira",
"linear",
"github",
"gitlab",
"bitbucket",
"pull-request",
"automation"
]
}
]
}
{
"name": "@openhands/extensions",
"version": "0.18.0",
"version": "0.19.0",
"description": "Public OpenHands extension catalogs for skills, plugins, integrations, and automation templates.",

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

@@ -81,2 +81,4 @@ """

Before reviewing, you MUST read the repository's own guidance to understand the repo first: read `AGENTS.md` at the repository root (and any nested `AGENTS.md` covering the changed files), plus other relevant docs when present — e.g. `CONTRIBUTING.md`, `CLAUDE.md`, `.cursorrules`, and any review or coding-guideline docs. Apply that guidance to your review.
Review the PR changes below and identify issues that need to be addressed.

@@ -83,0 +85,0 @@

[project]
name = "openhands-extensions"
version = "0.18.0"
version = "0.19.0"
description = "OpenHands extensions, plugins, and skills (Python bindings for the integration catalog)"

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

@@ -21,3 +21,3 @@ """Package version, derived from installed package metadata.

#: the annotation must stay on the assignment; on its own line it is a no-op.
_FALLBACK_VERSION = "0.18.0" # x-release-please-version
_FALLBACK_VERSION = "0.19.0" # x-release-please-version

@@ -24,0 +24,0 @@ try:

@@ -9,2 +9,9 @@ # OpenHands Extensions

## Repository boundaries
`OpenHands/extensions` is the public registry for reusable skills, plugins, automations, and integrations. [`OpenHands/software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) owns Agent Server execution and the canonical API, [`OpenHands/typescript-client`](https://github.com/OpenHands/typescript-client) owns typed browser access to that API, [`OpenHands/OpenHands`](https://github.com/OpenHands/OpenHands) owns Agent Canvas UI, and [`OpenHands/automation`](https://github.com/OpenHands/automation) owns scheduling, webhooks, run history, dispatch, and sandbox lifecycle orchestration.
Put reusable extension artifacts here rather than in application repositories. If a PR is opened in the wrong repository, close and move it to the repository that owns the change.
## Repository Layout

@@ -93,3 +100,3 @@

<!-- BEGIN AUTO-GENERATED CATALOG -->
This repository contains **2 marketplace(s)** with **67 extensions** (57 skills, 10 plugins).
This repository contains **2 marketplace(s)** with **68 extensions** (58 skills, 10 plugins).

@@ -113,3 +120,3 @@ ### large-codebase

**63 extensions** (55 skills, 8 plugins)
**64 extensions** (56 skills, 8 plugins)

@@ -177,2 +184,3 @@ | Name | Type | Description | Commands |

| theme-factory | skill | Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc.... | — |
| ticket-to-code-change | skill | Set up Jira or Linear ticket-to-code-change automations for GitHub, GitLab, and Bitbucket. | `/ticket-to-code-change:setup` |
| upstream-fork-sync | skill | Keep a long-lived fork in sync with its upstream. Creates a cron automation that fetches upstream changes, rebases lo... | `/upstream-fork-sync:setup` |

@@ -179,0 +187,0 @@ | uv | skill | Common project, dependency, and environment operations using uv. | — |

@@ -45,2 +45,8 @@ """

REVIEW_STYLE_INSTRUCTIONS = ""
# Path within the checked-out repository to a repo-specific review guide
# (e.g. the repo's own code-review skill). When the file exists at this path
# relative to the repo root, its contents are read and injected verbatim into
# the review prompt so the guide is always applied deterministically, rather
# than relying on the spawned agent's skill activation. Set to "" to disable.
REPO_REVIEW_GUIDE_PATH = ".agents/skills/custom-codereview-guide.md"
DEFAULT_OPENHANDS_URL = "http://localhost:8000"

@@ -58,2 +64,3 @@

"review_style_instructions": str,
"repo_review_guide_path": str,
"openhands_url": str,

@@ -139,2 +146,3 @@ }

REVIEW_STYLE_INSTRUCTIONS = _CONFIG.get("review_style_instructions", REVIEW_STYLE_INSTRUCTIONS)
REPO_REVIEW_GUIDE_PATH = _CONFIG.get("repo_review_guide_path", REPO_REVIEW_GUIDE_PATH)
DEFAULT_OPENHANDS_URL = _CONFIG.get("openhands_url", DEFAULT_OPENHANDS_URL)

@@ -747,3 +755,24 @@

def _build_review_prompt(repo: str, pr: dict, head_sha: str, label_event: dict) -> str:
def _load_repo_review_guide(workspace_dir: Path) -> str | None:
"""Read the repo-specific review guide from the checked-out repository.
The path is taken from ``REPO_REVIEW_GUIDE_PATH``. An empty path disables
the feature. Returns the file contents, or None if the file is absent or
unreadable — a missing guide is never fatal, the review simply proceeds
without it.
"""
if not REPO_REVIEW_GUIDE_PATH:
return None
candidate = workspace_dir / REPO_REVIEW_GUIDE_PATH
try:
if candidate.is_file():
text = candidate.read_text(encoding="utf-8", errors="replace").strip()
if text:
return text
except Exception as exc:
print(f" Warning: could not read repo review guide {candidate}: {exc}")
return None
def _build_review_prompt(repo: str, pr: dict, head_sha: str, label_event: dict, repo_review_guide: str | None = None) -> str:
number = pr.get("number", "?")

@@ -764,2 +793,6 @@ title = pr.get("title", "(no title)")

extra = f"\n\nAdditional style instructions:\n{REVIEW_STYLE_INSTRUCTIONS}" if REVIEW_STYLE_INSTRUCTIONS.strip() else ""
guide_section = (
f"\n\nRepo-specific review guide (from {REPO_REVIEW_GUIDE_PATH}):\n---\n{repo_review_guide}\n---\n"
if repo_review_guide else ""
)

@@ -783,3 +816,8 @@ return (

"Do not clone, fetch, check out, or delete the repository.\n"
"2. Inspect the PR discussion, existing review comments, changed files, and the diff, "
"2. Before reviewing, you MUST read the repository's own guidance to understand the repo first.\n"
" Read `AGENTS.md` at the repository root (and any nested `AGENTS.md` covering the "
"changed files), plus other relevant docs when present - e.g. `CONTRIBUTING.md`, "
"`CLAUDE.md`, `.cursorrules`, and any review or coding-guideline docs. Apply that "
"guidance to your review.\n"
" Then inspect the PR discussion, existing review comments, changed files, and the diff, "
"together with the surrounding code in the workspace.\n"

@@ -802,3 +840,3 @@ " Use `gh` or GitHub REST API calls with `GITHUB_PERSONAL_ACCESS_TOKEN`; never print secret values.\n"

"disclosure and the verdict.\n"
f"\nReview instructions:\n{tone}{extra}\n\n"
f"\nReview instructions:\n{tone}{extra}{guide_section}\n\n"
"After GitHub accepts the review, output exactly `GITHUB_REVIEW_POSTED`. "

@@ -851,3 +889,6 @@ "If publishing still fails after the fallback in step 5, output the complete review text "

workspace_dir = _prepare_repository(github_token, repo, number, head_sha)
prompt = _build_review_prompt(repo, pr, head_sha, label_event)
repo_review_guide = _load_repo_review_guide(workspace_dir)
if repo_review_guide:
print(f" Injected repo review guide for PR #{number}")
prompt = _build_review_prompt(repo, pr, head_sha, label_event, repo_review_guide)
conv_id = create_conversation(agent_url, api_key, prompt, workspace_dir)

@@ -854,0 +895,0 @@ except Exception as exc:

@@ -142,3 +142,3 @@ ---

Read `scripts/main.py` from this skill's directory. Apply exactly five constant
Read `scripts/main.py` from this skill's directory. Apply exactly six constant
substitutions near the top of the file:

@@ -158,2 +158,3 @@

| `REVIEW_STYLE_INSTRUCTIONS = ""` | `REVIEW_STYLE_INSTRUCTIONS = "{style_instructions}"` |
| `REPO_REVIEW_GUIDE_PATH = ".agents/skills/custom-codereview-guide.md"` | leave unchanged to auto-load a repo review guide at this path, or set to `""` to disable |
| `DEFAULT_OPENHANDS_URL = "http://localhost:8000"` | leave unchanged unless the user has a preference |

@@ -160,0 +161,0 @@

@@ -398,2 +398,3 @@ """Unit tests for github-pr-reviewer main.py.

"review_style_instructions": "be kind",
"repo_review_guide_path": "docs/review.md",
"openhands_url": "http://localhost:8010",

@@ -409,2 +410,3 @@ }

"review_style_instructions": "be kind",
"repo_review_guide_path": "docs/review.md",
"openhands_url": "http://localhost:8010",

@@ -454,2 +456,74 @@ },

class TestRepoReviewGuide(unittest.TestCase):
"""The repo-specific review guide is read from the checkout and injected
into the prompt when present, and silently absent when not."""
def _pr(self):
return {
"number": 42,
"title": "Add widget",
"body": "ships it",
"html_url": "https://github.com/owner/repo/pull/42",
"user": {"login": "alice"},
"base": {"ref": "main"},
"head": {"ref": "feature", "sha": "0123456789abcdef0123456789abcdef01234567"},
"labels": [],
"changed_files": 1,
"additions": 10,
"deletions": 2,
}
def test_reads_the_guide_when_present(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
guide_dir = root / ".agents" / "skills"
guide_dir.mkdir(parents=True)
(guide_dir / "custom-codereview-guide.md").write_text("# Guide\nApprove freely.")
with patch.object(main, "REPO_REVIEW_GUIDE_PATH", ".agents/skills/custom-codereview-guide.md"):
text = main._load_repo_review_guide(root)
self.assertEqual(text, "# Guide\nApprove freely.")
def test_returns_none_when_absent(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.object(main, "REPO_REVIEW_GUIDE_PATH", ".agents/skills/custom-codereview-guide.md"):
self.assertIsNone(main._load_repo_review_guide(Path(tmp)))
def test_empty_path_disables(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "guide.md").write_text("irrelevant")
with patch.object(main, "REPO_REVIEW_GUIDE_PATH", ""):
self.assertIsNone(main._load_repo_review_guide(root))
def test_guide_text_is_injected_into_the_prompt(self):
pr = self._pr()
prompt = main._build_review_prompt(
"owner/repo", pr, "0123456789abcdef", {"id": "1", "created_at": "t"},
repo_review_guide="APPROVE all low-risk PRs.",
)
self.assertIn("APPROVE all low-risk PRs.", prompt)
self.assertIn("Repo-specific review guide", prompt)
def test_no_guide_section_when_guide_is_none(self):
pr = self._pr()
prompt = main._build_review_prompt(
"owner/repo", pr, "0123456789abcdef", {"id": "1", "created_at": "t"},
repo_review_guide=None,
)
self.assertNotIn("Repo-specific review guide", prompt)
def test_prompt_requires_reading_repository_guidance(self):
prompt = main._build_review_prompt(
"owner/repo",
self._pr(),
"0123456789abcdef",
{"id": "1", "created_at": "t"},
)
self.assertIn("MUST read", prompt)
self.assertIn("AGENTS.md", prompt)
self.assertIn("CONTRIBUTING.md", prompt)
self.assertIn("nested `AGENTS.md`", prompt)
class TestNormalizeRepo(unittest.TestCase):

@@ -456,0 +530,0 @@ """A repository is written down in more than one way, and every API path in

@@ -201,2 +201,3 @@ ---

- [`57_prompt_hooks`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk/57_prompt_hooks)
- [`58_ask_oracle_tool`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk/58_ask_oracle_tool)

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

@@ -521,2 +521,24 @@ """Contract tests for the `setup` block in automations/catalog/*/manifest.json.

IMPACT_REJECTIONS: list[tuple[str, dict]] = [
("a basis the host does not know how to compute", {"basis": "run-counter"}),
("markup in a phrase", {"one": "<b>1 sweep</b>"}),
("a placeholder from another namespace", {"other": "{{count}} sweeps for {{form.repo}}"}),
("a plural phrase that hides the count", {"other": "many sweeps completed"}),
("an extra key beside the declared three", {"detail": "and saved hours"}),
]
@pytest.mark.parametrize(
("case", "override"),
[pytest.param(case, override, id=case) for case, override in IMPACT_REJECTIONS],
)
def test_schema_refuses_an_impact_statement_the_host_must_never_render(
case: str, override: dict
) -> None:
entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json"))
entry["impact"].update(override)
assert list(VALIDATOR.iter_errors(entry)), f"schema admitted {case}"
@pytest.mark.parametrize("entry_path", list(_setup_paths()))

@@ -523,0 +545,0 @@ def test_form_placeholders_reference_declared_fields(entry_path: Path) -> None:

@@ -96,3 +96,3 @@ import json

# credentials (just via named headers, e.g. Datadog's
# DD_API_KEY/DD_APPLICATION_KEY), so it is NOT "public/no-auth".
# DD-API-KEY/DD-APPLICATION-KEY), so it is NOT "public/no-auth".
has_header_credentials = bool(transport.get("headerFields"))

@@ -123,3 +123,3 @@ if (

field["key"] for field in api_option["transport"]["headerFields"]
] == ["DD_API_KEY", "DD_APPLICATION_KEY"]
] == ["DD-API-KEY", "DD-APPLICATION-KEY"]

@@ -126,0 +126,0 @@

@@ -62,2 +62,12 @@ import importlib.util

def test_prompt_instructs_reading_repo_guidance():
"""The reviewer must be told to read AGENTS.md (and other guideline docs)
to understand the repo before reviewing."""
prompt = _format_prompt(require_evidence=False)
assert "AGENTS.md" in prompt
assert "MUST read" in prompt
assert "CONTRIBUTING.md" in prompt
def test_format_prompt_omits_evidence_requirements_by_default():

@@ -64,0 +74,0 @@ prompt = _format_prompt(require_evidence=False)

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

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