@openhands/extensions 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.agents/skills/custom-codereview-guide.md +3 -4
  2. package/.claude-plugin/marketplace.json +13 -0
  3. package/.codex-plugin/marketplace.json +13 -0
  4. package/.github/workflows/pr-artifacts.yml +4 -83
  5. package/.github/workflows/tests.yml +13 -0
  6. package/.plugin/marketplace.json +13 -0
  7. package/.release-please-manifest.json +1 -1
  8. package/AGENTS.md +4 -3
  9. package/README.md +3 -2
  10. package/automations/bundle-index.js +1 -1
  11. package/automations/catalog/github-pr-reviewer/manifest.json +2 -2
  12. package/marketplaces/openhands-extensions.json +13 -0
  13. package/package.json +1 -1
  14. package/pyproject.toml +1 -1
  15. package/python/openhands_extensions/_version.py +1 -1
  16. package/skills/github-pr-reviewer/README.md +6 -0
  17. package/skills/github-pr-reviewer/SKILL.md +7 -0
  18. package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
  19. package/skills/index.js +12 -2
  20. package/skills/slack-channel-monitor/README.md +6 -2
  21. package/skills/slack-channel-monitor/SKILL.md +29 -17
  22. package/skills/slack-channel-monitor/references/windows.md +6 -2
  23. package/skills/slack-channel-monitor/scripts/main.py +156 -11
  24. package/skills/software-factory/.claude-plugin/plugin.json +19 -0
  25. package/skills/software-factory/.codex-plugin/plugin.json +19 -0
  26. package/skills/software-factory/.plugin/plugin.json +19 -0
  27. package/skills/software-factory/README.md +64 -0
  28. package/skills/software-factory/SKILL.md +229 -0
  29. package/skills/software-factory/commands/software-factory.md +8 -0
  30. package/skills/software-factory/scripts/factory.py +630 -0
  31. package/tests/test_github_stale_ci_pr_closer.py +52 -1
  32. package/tests/test_software_factory_script.py +231 -0
@@ -146,10 +146,9 @@ deep change lacks it, weigh the gap by risk:
146
146
  the diff would materially slow or weaken the review.
147
147
  - **LOW** risk: never block on missing design context alone.
148
148
 
149
- Approving a same-repository PR removes its `.pr/` directory. If a temporary
150
- `.pr/` page is the only design explanation, submit a COMMENT review instead of
151
- approving, so the page remains for the human maintainer's decision, unless the
152
- PR description already carries the durable equivalent.
149
+ Temporary `.pr/` artifacts remain available during review and are removed through
150
+ a cleanup PR after merge to `main`. Preserve any design explanation needed beyond
151
+ that cleanup in the PR description or permanent documentation.
153
152
 
154
153
  Design context aids review; it does not excuse correctness, security,
155
154
  architecture, or repository-ownership problems.
@@ -92,6 +92,19 @@
92
92
  "openhands"
93
93
  ]
94
94
  },
95
+ {
96
+ "name": "software-factory",
97
+ "source": "./skills/software-factory",
98
+ "description": "Set up a GitHub software factory fast: ask only for the repositories and the ticket source (GitHub issues, Jira or Linear), create a working issue-to-PR and PR-review pipeline from catalog automations with defaults, then let the user stop or extend it step by step.",
99
+ "category": "automations",
100
+ "keywords": [
101
+ "onboarding",
102
+ "software-factory",
103
+ "github",
104
+ "automation",
105
+ "pull-request"
106
+ ]
107
+ },
95
108
  {
96
109
  "name": "agent-canvas-environment",
97
110
  "source": "./skills/agent-canvas-environment",
@@ -92,6 +92,19 @@
92
92
  "openhands"
93
93
  ]
94
94
  },
95
+ {
96
+ "name": "software-factory",
97
+ "source": "./skills/software-factory",
98
+ "description": "Set up a GitHub software factory fast: ask only for the repositories and the ticket source (GitHub issues, Jira or Linear), create a working issue-to-PR and PR-review pipeline from catalog automations with defaults, then let the user stop or extend it step by step.",
99
+ "category": "automations",
100
+ "keywords": [
101
+ "onboarding",
102
+ "software-factory",
103
+ "github",
104
+ "automation",
105
+ "pull-request"
106
+ ]
107
+ },
95
108
  {
96
109
  "name": "agent-canvas-environment",
97
110
  "source": "./skills/agent-canvas-environment",
@@ -6,84 +6,9 @@ on:
6
6
  pull_request_target:
7
7
  types: [opened, synchronize, reopened, closed]
8
8
  branches: [main]
9
- pull_request_review:
10
- types: [submitted]
11
9
 
12
10
  jobs:
13
- # Auto-remove .pr/ directory from same-repository PRs when approved.
14
- cleanup-on-approval:
15
- concurrency:
16
- group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
17
- cancel-in-progress: false
18
- if: >-
19
- github.event_name == 'pull_request_review' &&
20
- github.event.review.state == 'approved' &&
21
- github.event.pull_request.head.repo.full_name == github.repository
22
- runs-on: ubuntu-latest
23
- permissions:
24
- contents: write
25
- pull-requests: write
26
- steps:
27
- # Use PAT so the push triggers CI workflows that will complete and
28
- # satisfy branch protection. We can't use [skip ci] because GitHub Apps
29
- # can create stuck checks that block merging.
30
- - uses: actions/checkout@v7
31
- with:
32
- ref: ${{ github.event.pull_request.head.ref }}
33
- token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
34
-
35
- - name: Remove .pr/ directory
36
- id: remove
37
- run: |
38
- if [ -d ".pr" ]; then
39
- git config user.name "allhands-bot"
40
- git config user.email "allhands-bot@users.noreply.github.com"
41
- git rm -rf .pr/
42
- git commit -m "chore: Remove PR-only artifacts [automated]"
43
- git push || {
44
- echo "::error::Failed to push cleanup commit. Check branch protection rules."
45
- exit 1
46
- }
47
- echo "removed=true" >> "$GITHUB_OUTPUT"
48
- echo "::notice::Removed .pr/ directory"
49
- else
50
- echo "removed=false" >> "$GITHUB_OUTPUT"
51
- echo "::notice::No .pr/ directory to remove"
52
- fi
53
-
54
- - name: Update PR comment after cleanup
55
- if: steps.remove.outputs.removed == 'true'
56
- uses: actions/github-script@v9
57
- with:
58
- script: |
59
- const marker = '<!-- pr-artifacts-notice -->';
60
- const body = `${marker}
61
- ✅ **PR Artifacts Cleaned Up**
62
-
63
- The \`.pr/\` directory has been automatically removed.
64
- `;
65
-
66
- const comments = await github.paginate(
67
- github.rest.issues.listComments,
68
- {
69
- owner: context.repo.owner,
70
- repo: context.repo.repo,
71
- issue_number: context.issue.number,
72
- per_page: 100,
73
- },
74
- );
75
-
76
- const existing = comments.find(c => c.body.includes(marker));
77
- if (existing) {
78
- await github.rest.issues.updateComment({
79
- owner: context.repo.owner,
80
- repo: context.repo.repo,
81
- comment_id: existing.id,
82
- body: body,
83
- });
84
- }
85
-
86
- # Inspect the fork through the API; never check out untrusted code with a write token.
11
+ # Inspect PR artifacts through the API; never check out untrusted code with a write token.
87
12
  check-pr-artifacts:
88
13
  if: >-
89
14
  github.event_name == 'pull_request_target' &&
@@ -144,14 +69,10 @@ jobs:
144
69
  return;
145
70
  }
146
71
 
147
- const isFork = headRepository.full_name !== context.payload.repository.full_name;
148
- const cleanup = isFork
149
- ? 'Because this is a fork PR, the workflow will **open or update a cleanup PR against `main` after merge**.'
150
- : 'The directory will be **automatically removed when the PR is approved**.';
151
72
  const body = `${marker}
152
73
  📁 **PR Artifacts Notice**
153
74
 
154
- This PR contains a \`.pr/\` directory with temporary PR-specific documents. ${cleanup}
75
+ This PR contains a \`.pr/\` directory with temporary PR-specific documents. The directory will be **automatically removed after this PR merges to \`main\`** through a cleanup PR opened or updated by this workflow.
155
76
  `;
156
77
 
157
78
  core.warning('.pr/ directory contains temporary PR-only artifacts');
@@ -171,8 +92,8 @@ jobs:
171
92
  });
172
93
  }
173
94
 
174
- # Fork branches cannot be modified reliably. If artifacts reach the trusted base,
175
- # remove them through a pull request so branch protections remain enforced.
95
+ # Remove artifacts from all merged PRs through the trusted base cleanup path,
96
+ # preserving branch protections and avoiding a cleanup push during review.
176
97
  cleanup-after-merge:
177
98
  concurrency:
178
99
  group: cleanup-pr-artifacts-${{ github.event.pull_request.base.ref }}
@@ -14,6 +14,19 @@ jobs:
14
14
  - name: Checkout repository
15
15
  uses: actions/checkout@v7
16
16
 
17
+ - name: Reject temporary artifacts in release PRs
18
+ if: >-
19
+ github.event_name == 'pull_request' &&
20
+ startsWith(github.head_ref, 'release-please--')
21
+ run: |
22
+ set -euo pipefail
23
+ artifacts="$(git ls-tree -r --name-only HEAD -- .pr)"
24
+ if [ -n "$artifacts" ]; then
25
+ echo "::error::Release PR contains temporary .pr artifacts. Merge the cleanup PR and let release-please update this PR before merging."
26
+ printf '%s\n' "$artifacts"
27
+ exit 1
28
+ fi
29
+
17
30
  - name: Install uv
18
31
  uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
19
32
  with:
@@ -92,6 +92,19 @@
92
92
  "openhands"
93
93
  ]
94
94
  },
95
+ {
96
+ "name": "software-factory",
97
+ "source": "./skills/software-factory",
98
+ "description": "Set up a GitHub software factory fast: ask only for the repositories and the ticket source (GitHub issues, Jira or Linear), create a working issue-to-PR and PR-review pipeline from catalog automations with defaults, then let the user stop or extend it step by step.",
99
+ "category": "automations",
100
+ "keywords": [
101
+ "onboarding",
102
+ "software-factory",
103
+ "github",
104
+ "automation",
105
+ "pull-request"
106
+ ]
107
+ },
95
108
  {
96
109
  "name": "agent-canvas-environment",
97
110
  "source": "./skills/agent-canvas-environment",
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.30.0"
2
+ ".": "0.31.0"
3
3
  }
package/AGENTS.md CHANGED
@@ -146,7 +146,7 @@ When editing or adding skills in this repo, follow these rules (and add new skil
146
146
 
147
147
  ## PR-specific documents (`.pr/`)
148
148
 
149
- When working on a PR that requires design documents, live-test logs, development-only scripts, or other temporary artifacts that should **not** be merged to `main`, store them in a `.pr/` directory at the repository root.
149
+ When working on a PR that requires design documents, live-test logs, development-only scripts, or other temporary artifacts that should be removed after merging to `main`, store them in a `.pr/` directory at the repository root.
150
150
 
151
151
  ```bash
152
152
  mkdir -p .pr
@@ -160,14 +160,15 @@ mkdir -p .pr
160
160
  The `PR Artifacts` workflow (`.github/workflows/pr-artifacts.yml`) owns the lifecycle of this directory:
161
161
 
162
162
  1. **Notification**: When a PR contains `.pr/`, a single comment is posted to the PR conversation alerting reviewers.
163
- 2. **Auto-cleanup on approval**: For same-repository PRs, the directory is automatically removed by a follow-up commit when the PR is approved.
164
- 3. **Post-merge cleanup**: If artifacts reach `main`, including through a fork PR, the workflow opens or updates a cleanup PR against `main`.
163
+ 2. **Post-merge cleanup**: After a same-repository or fork PR merges to `main`, the workflow automatically opens or updates a cleanup PR to remove the directory from `main`.
165
164
 
166
165
  Important notes:
167
166
 
168
167
  - Do not put anything in `.pr/` that needs to be preserved.
169
168
  - The `.pr/` check is informational during development; it posts a notice rather than blocking the PR.
170
169
  - Cleanup PRs follow the normal review and required-check protections for `main`.
170
+ - npm packaging excludes `.pr/` while its cleanup PR is pending, so temporary review artifacts do not ship in releases.
171
+ - Release PRs fail the required `test` check while their committed tree contains `.pr/`. Merge the artifact-cleanup PR and let release-please update the release PR before merging it.
171
172
 
172
173
  ## CI / validation gotchas
173
174
 
package/README.md CHANGED
@@ -100,7 +100,7 @@ The JS and Python versions are kept in lock-step by `release-please` and guarded
100
100
  ## Extensions Catalog
101
101
 
102
102
  <!-- BEGIN AUTO-GENERATED CATALOG -->
103
- This repository contains **2 marketplace(s)** with **77 extensions** (66 skills, 11 plugins).
103
+ This repository contains **2 marketplace(s)** with **78 extensions** (67 skills, 11 plugins).
104
104
 
105
105
  ### large-codebase
106
106
 
@@ -119,7 +119,7 @@ OpenHands skills for interacting, improving, and refactoring large codebases
119
119
 
120
120
  Official skills and plugins for OpenHands — the open-source AI software engineer.
121
121
 
122
- **73 extensions** (64 skills, 9 plugins)
122
+ **74 extensions** (65 skills, 9 plugins)
123
123
 
124
124
  | Name | Type | Description | Commands |
125
125
  |------|------|-------------|----------|
@@ -186,6 +186,7 @@ Official skills and plugins for OpenHands — the open-source AI software engine
186
186
  | skill-creator | skill | Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an ex... | — |
187
187
  | slack-channel-monitor | skill | Create a cron automation that polls up to 10 Slack channels every minute and starts an OpenHands conversation when a ... | `/slack-monitor:poll` |
188
188
  | slack-standup-digest | skill | Create an automation that generates an async standup digest from Slack. Searches selected channels for messages since... | `/standup-digest:setup` |
189
+ | software-factory | skill | Set up a GitHub software factory fast: ask only for the repositories and the ticket source (GitHub issues, Jira or Li... | `/software-factory` |
189
190
  | ssh | skill | Establish and manage SSH connections to remote machines, including key generation, configuration, and file transfers.... | — |
190
191
  | swift-linux | skill | Install and configure Swift programming language on Debian Linux for server-side development. Use when building Swift... | — |
191
192
  | technical-writing | skill | Write and revise technical explanations in flowing, direct, conversational prose that stays concise without becoming ... | — |
@@ -39,6 +39,6 @@ export const AUTOMATION_BUNDLE_FILES = {
39
39
  },
40
40
  "github-stale-ci-pr-closer": {
41
41
  "github_client.py": "\"\"\"Shared GitHub transport and repository operations for GitHub automations.\"\"\"\n\nimport argparse\nimport json\nimport os\nimport re\nimport subprocess\nfrom functools import cached_property\nfrom pathlib import Path\nfrom urllib.error import HTTPError\nfrom urllib.parse import parse_qsl, urlencode, urlsplit\nfrom urllib.request import Request, urlopen\n\n\n# The secret name of the GitHub token an OpenHands Cloud or Enterprise user's\n# connected GitHub integration provides: how this run's sandbox serves it, and\n# how a conversation sees it in its environment.\nCLOUD_GITHUB_TOKEN_SECRET = \"github_token\"\nCLOUD_GITHUB_TOKEN_ENV = \"GITHUB_TOKEN\"\n\n\ndef is_cloud_run() -> bool:\n \"\"\"Whether this run is on OpenHands Cloud or Enterprise.\n\n The automation service hands a run the Agent Server URL only on a local\n Agent Canvas; elsewhere the run talks to the OpenHands API instead.\n \"\"\"\n return bool(os.environ.get(\"OPENHANDS_CLOUD_API_URL\")) and not os.environ.get(\n \"AGENT_SERVER_URL\"\n )\n\n\ndef _load_cloud_secret(name: str) -> str | None:\n \"\"\"Read one named secret of this run's sandbox from the OpenHands API.\"\"\"\n api = os.environ[\"OPENHANDS_CLOUD_API_URL\"].rstrip(\"/\")\n request = Request(\n f\"{api}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}\",\n headers={\"X-Session-API-Key\": os.environ[\"SESSION_API_KEY\"]},\n )\n try:\n with urlopen(request, timeout=90) as response:\n return response.read().decode().strip()\n except HTTPError as exc:\n if exc.code == 404:\n return None\n raise\n\n\ndef _load_secret(name: str) -> str:\n \"\"\"Read one named secret from the environment, or from the configured Agent\n Server on a local run and the OpenHands API on a cloud one.\"\"\"\n value = os.environ.get(name)\n if value:\n return value\n\n if is_cloud_run():\n value = _load_cloud_secret(name)\n else:\n from openhands.sdk.workspace import RemoteWorkspace\n\n workspace = RemoteWorkspace(\n host=os.environ[\"AGENT_SERVER_URL\"],\n api_key=os.environ[\"SESSION_API_KEY\"],\n working_dir=os.environ.get(\"WORKSPACE_BASE\", \"/workspace\"),\n )\n try:\n secret = workspace.get_secrets([name]).get(name)\n value = secret.get_value() if secret else None\n finally:\n workspace.reset_client()\n if not value:\n raise ValueError(f\"The GitHub credential {name} is unavailable\")\n return value\n\n\ndef github_request(\n token: str,\n method: str,\n path: str,\n params: dict | None = None,\n body: dict | None = None,\n accept: str = \"application/vnd.github+json\",\n) -> tuple:\n url = f\"https://api.github.com{path}\"\n if params:\n url = f\"{url}?{urlencode(params)}\"\n headers = {\n \"Authorization\": f\"Bearer {token}\",\n \"Accept\": accept,\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n \"Content-Type\": \"application/json\",\n }\n data = json.dumps(body).encode() if body is not None else None\n req = Request(url, data=data, headers=headers, method=method)\n with urlopen(req, timeout=90) as r:\n raw = r.read()\n return (json.loads(raw) if raw.strip() else {}), dict(r.headers)\n\n\ndef github_paginate(token: str, path: str, params: dict | None = None) -> list:\n results = []\n base_params = dict(params or {})\n base_params.setdefault(\"per_page\", 100)\n for page in range(1, 101):\n base_params[\"page\"] = page\n data, _ = github_request(token, \"GET\", path, params=base_params)\n if not isinstance(data, list):\n raise TypeError(\"Expected a paginated GitHub list\")\n results.extend(data)\n if len(data) < int(base_params[\"per_page\"]):\n return results\n raise RuntimeError(\"GitHub pagination exceeded limit\")\n\n\nclass GitHubRepository:\n name = \"GitHub automation\"\n\n def __init__(\n self,\n config_path=Path(\"config.json\"),\n *,\n github_token_secret,\n repository=None,\n conversation=None,\n dispatcher=None,\n ):\n self.config = json.loads(Path(config_path).read_text())\n self.repository = repository or self.config[\"repository\"]\n if not re.fullmatch(r\"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\", self.repository):\n raise ValueError(\"repository must be owner/repo\")\n if not re.fullmatch(r\"[A-Z_][A-Z0-9_]*\", github_token_secret):\n raise ValueError(\n \"Expected the environment variable containing the GitHub token\"\n )\n self.token_name = github_token_secret\n try:\n self.token = _load_secret(github_token_secret)\n except ValueError:\n if not is_cloud_run():\n raise\n # No secret is saved under that name, so use the user's connected\n # GitHub integration, under the name a conversation sees it by.\n self.token = _load_cloud_secret(CLOUD_GITHUB_TOKEN_SECRET)\n if not self.token:\n raise\n self.token_name = CLOUD_GITHUB_TOKEN_ENV\n self.conversation = conversation\n self.conversation_id = str(conversation.id) if conversation else None\n self.dispatcher = dispatcher\n # A cloud run is not given a workspace base; its sandbox has /workspace.\n self.workspace = Path(os.environ.get(\"WORKSPACE_BASE\", \"/workspace\"))\n self.project = self.workspace\n self.evidence = self.workspace / \"evidence\"\n self.evidence.mkdir(exist_ok=True)\n self._completed_dependencies = {}\n\n @cached_property\n def base_branch(self):\n return self.config.get(\"base_branch\") or self.gh(\"GET\", \"\")[\"default_branch\"]\n\n @property\n def github_instructions(self):\n # A cloud conversation runs in a sandbox of its own, so this run's\n # workspace path means nothing to it.\n where = \"your working directory\" if is_cloud_run() else self.project\n return (\n f\"Use `GH_TOKEN=${self.token_name} gh api` for GitHub requests. \"\n \"Never print the credential value. \"\n f\"Only {self.repository} is in scope. Work in {where}. \"\n \"Do not modify the automation bundle or its configuration.\"\n )\n\n def gh(self, method, path, body=None):\n return github_request(\n self.token, method, f\"/repos/{self.repository}\" + path, body=body\n )[0]\n\n def api(self, method, path, params=None, body=None):\n \"\"\"Call a GitHub endpoint that is not scoped to one repository.\"\"\"\n return github_request(self.token, method, path, params=params, body=body)[0]\n\n def shell(self, args, cwd=None, timeout=300):\n result = subprocess.run(\n args,\n cwd=cwd or self.project,\n text=True,\n stdout=subprocess.PIPE,\n stderr=subprocess.STDOUT,\n timeout=timeout,\n check=False,\n )\n if result.returncode:\n raise RuntimeError(\n f\"{args[0]} failed: {result.stdout[-4000:].replace(self.token, '[REDACTED]')}\"\n )\n return result.stdout.strip()\n\n def comment(self, number, text):\n return self.gh(\n \"POST\",\n f\"/issues/{number}/comments\",\n {\n \"body\": text\n + f\"\\n\\nFactory role: `{self.name}`; conversation: `{self.conversation_id}`.\"\n + \"\\n\\n_This comment was posted by an AI agent (OpenHands)._\"\n },\n )\n\n def open_issues(self):\n return [\n i for i in self.gh_pages(\"/issues?state=open\") if \"pull_request\" not in i\n ]\n\n def statuses(self, sha):\n result = {}\n for item in self.gh_pages(f\"/commits/{sha}/statuses\"):\n result.setdefault(item[\"context\"], item[\"state\"])\n return result\n\n def check_runs(self, sha):\n \"\"\"Return every check run GitHub reported for one commit SHA.\n\n Reading check runs needs no branch-protection or ruleset access, so this\n is the head-eligibility signal the automation's own token can always\n see. The endpoint answers with an object rather than a list, so it\n paginates through ``gh`` instead of ``gh_pages``.\n \"\"\"\n runs = []\n for page in range(1, 101):\n data = self.gh(\n \"GET\", f\"/commits/{sha}/check-runs?per_page=100&page={page}\"\n )\n batch = data.get(\"check_runs\") or []\n runs.extend(batch)\n if len(runs) >= int(data.get(\"total_count\") or 0) or not batch:\n return runs\n raise RuntimeError(\"GitHub check-run pagination exceeded limit\")\n\n def required_check_contexts(self, number):\n \"\"\"Return the status contexts GitHub marks required on one pull request.\n\n `isRequired` is the merge-policy source of truth and is PR-scoped, so it\n is correct for a stacked PR whose symbolic base branch carries no branch\n rules of its own. It is a field on each context in the head's status\n check rollup and takes the pull request number, so a single GraphQL query\n answers with the required check runs and any required commit statuses,\n each already attributed to the exact head. Only contexts GitHub itself\n reports as required are returned, so an optional workflow that fails\n before creating any check run is not present.\n \"\"\"\n owner, name = self.repository.split(\"/\", 1)\n query = (\n \"query($owner:String!,$name:String!,$number:Int!){\"\n \"repository(owner:$owner,name:$name){\"\n \"pullRequest(number:$number){\"\n \"commits(last:1){\"\n \"nodes{\"\n \"commit{\"\n \"statusCheckRollup{\"\n \"contexts(first:100){\"\n \"pageInfo{hasNextPage}\"\n \"nodes{\"\n \"__typename\"\n \" ... on CheckRun{name isRequired(pullRequestNumber:$number)}\"\n \" ... on StatusContext{context isRequired(pullRequestNumber:$number)}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n )\n data = self.api(\n \"POST\",\n \"/graphql\",\n body={\n \"query\": query,\n \"variables\": {\"owner\": owner, \"name\": name, \"number\": number},\n },\n )\n if data.get(\"errors\"):\n raise RuntimeError(\n f\"GitHub required-check query failed: {data['errors'][0].get('message')}\"\n )\n nodes = (\n (((data.get(\"data\") or {}).get(\"repository\") or {}).get(\"pullRequest\") or {})\n .get(\"commits\", {})\n .get(\"nodes\")\n ) or []\n contexts = (\n ((nodes[0].get(\"commit\") or {}).get(\"statusCheckRollup\") or {}).get(\n \"contexts\", {}\n )\n if nodes\n else {}\n )\n if (contexts.get(\"pageInfo\") or {}).get(\"hasNextPage\"):\n raise RuntimeError(\n \"GitHub required-check rollup exceeds one page; the required \"\n \"set may be incomplete\"\n )\n return [\n {\n \"name\": node.get(\"name\") or node.get(\"context\"),\n \"kind\": node.get(\"__typename\"),\n }\n for node in (contexts.get(\"nodes\") or [])\n if node.get(\"isRequired\") and (node.get(\"name\") or node.get(\"context\"))\n ]\n\n def workflow_runs(self, sha):\n \"\"\"Return every Actions workflow run GitHub reported for one commit SHA.\n\n A workflow that fails before any job starts - a workflow-level error, or\n a `pull_request` run whose jobs never materialize - still records a\n failed check suite, but it contributes no check runs, so the commit's\n check-run rollup and `gh pr checks` both report success. Reading the\n workflow runs directly is the only way the gate can see that failure.\n Like the check-run endpoint this answers with an object, so it paginates\n manually.\n \"\"\"\n runs = []\n for page in range(1, 101):\n data = self.gh(\n \"GET\", f\"/actions/runs?head_sha={sha}&per_page=100&page={page}\"\n )\n batch = data.get(\"workflow_runs\") or []\n runs.extend(batch)\n if len(runs) >= int(data.get(\"total_count\") or 0) or not batch:\n return runs\n raise RuntimeError(\"GitHub workflow-run pagination exceeded limit\")\n\n def completed_dependency(self, number):\n if number in self._completed_dependencies:\n return self._completed_dependencies[number]\n try:\n dependency = self.gh(\"GET\", f\"/issues/{number}\")\n except HTTPError as exc:\n if exc.code == 404:\n return False\n raise\n completed = (\n dependency[\"state\"] == \"closed\"\n and dependency.get(\"state_reason\") == \"completed\"\n )\n self._completed_dependencies[number] = completed\n return completed\n\n def dependencies_complete(self, issue):\n \"\"\"Honor explicit Depends on lines; unknown/incomplete issues remain blocked.\"\"\"\n for line in re.findall(\n \"^Depends on:\\\\s*(.+)$\",\n issue.get(\"body\") or \"\",\n re.MULTILINE | re.IGNORECASE,\n ):\n for number in re.findall(\"#(\\\\d+)\", line):\n if not self.completed_dependency(number):\n return False\n return True\n\n def gh_pages(self, endpoint):\n split = urlsplit(endpoint)\n return github_paginate(\n self.token,\n f\"/repos/{self.repository}\" + split.path,\n params=dict(parse_qsl(split.query)),\n )\n\n\ndef run_repositories(automation_type, conversation=None, dispatcher=None):\n parser = argparse.ArgumentParser(description=automation_type.__doc__)\n parser.add_argument(\"--github-token-secret\")\n args = parser.parse_args()\n config = json.loads(Path(\"config.json\").read_text())\n token_name = args.github_token_secret or config.get(\n \"github_token_secret\", \"GITHUB_PERSONAL_ACCESS_TOKEN\"\n )\n repositories = config.get(\"repos\") or [config[\"repository\"]]\n failures = []\n for repository in repositories:\n options = dict(\n github_token_secret=token_name,\n repository=repository,\n conversation=conversation,\n )\n if dispatcher is not None:\n options[\"dispatcher\"] = dispatcher\n automation = automation_type(**options)\n try:\n automation.run()\n except Exception as exc: # noqa: BLE001 - one repository must not block others\n failures.append(repository)\n print(\n json.dumps({\"repository\": repository, \"error\": type(exc).__name__}),\n flush=True,\n )\n if failures:\n raise RuntimeError(\"Automation failed for: \" + \", \".join(failures))\n return str(conversation.id) if conversation else None\n",
42
- "worker.py": "\"\"\"Warn and close abandoned pull requests with persistently failing required CI.\"\"\"\n\nimport json\nimport os\nimport time\nfrom datetime import datetime\nfrom pathlib import Path\nfrom urllib.error import HTTPError\nfrom urllib.parse import quote\nfrom urllib.request import Request, urlopen\n\nfrom github_client import GitHubRepository, run_repositories\n\nWARN_AFTER_SECONDS = 7 * 24 * 60 * 60\nCLOSE_AFTER_SECONDS = 7 * 24 * 60 * 60\nWARNING_MARKER = \"<!-- openhands-stale-ci-warning -->\"\nCLOSE_MARKER = \"<!-- openhands-stale-ci-close -->\"\nPASSING_CONCLUSIONS = {\"success\", \"neutral\", \"skipped\"}\nPULL_REQUESTS_QUERY = \"\"\"\nquery($owner: String!, $name: String!, $cursor: String) {\n repository(owner: $owner, name: $name) {\n pullRequests(first: 40, after: $cursor, states: OPEN) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number isDraft baseRefName headRefOid\n author { login }\n commits(last: 1) {\n nodes {\n commit {\n statusCheckRollup {\n contexts(first: 100) {\n totalCount\n nodes {\n __typename\n ... on CheckRun {\n databaseId name status conclusion startedAt completedAt\n checkSuite { app { databaseId } }\n }\n ... on StatusContext { context state createdAt }\n }\n }\n }\n }\n }\n }\n }\n }\n }\n}\n\"\"\"\nKV_TOKEN = os.environ.get(\"AUTOMATION_KV_TOKEN\", \"\")\nKV_BASE = os.environ.get(\"AUTOMATION_API_URL\", \"\").rstrip(\"/\")\n\n\ndef _timestamp(value):\n return datetime.fromisoformat(value).timestamp()\n\n\ndef _state_key(repo):\n payload = json.loads(os.environ.get(\"AUTOMATION_EVENT_PAYLOAD\", \"{}\"))\n automation_id = payload.get(\"automation_id\", \"default\")\n return f\"github-stale-ci-pr-closer:{automation_id}:{repo.replace('/', '__')}\"\n\n\ndef _state_path(repo):\n workspace = Path(\n os.environ.get(\"WORKSPACE_BASE\", Path.home() / \".openhands/workspaces\")\n )\n root = (\n workspace.resolve().parent.parent\n if os.environ.get(\"WORKSPACE_BASE\")\n else workspace\n )\n directory = root / \"automation-state\"\n directory.mkdir(parents=True, exist_ok=True)\n return directory / (_state_key(repo) + \".json\")\n\n\ndef _kv_request(key, method, value=None):\n if not KV_TOKEN or not KV_BASE:\n return None\n data = json.dumps({\"value\": value}).encode() if value is not None else None\n request = Request(\n f\"{KV_BASE}/v1/kv/{quote(key, safe='')}\",\n data=data,\n method=method,\n headers={\n \"Authorization\": f\"Bearer {KV_TOKEN}\",\n \"Content-Type\": \"application/json\",\n },\n )\n try:\n with urlopen(request) as response:\n payload = json.loads(response.read() or b\"{}\")\n return payload.get(\"value\")\n except HTTPError as exc:\n if method == \"GET\" and exc.code == 404:\n return None\n raise\n\n\ndef load_state(repo):\n if KV_TOKEN and KV_BASE:\n return _kv_request(_state_key(repo), \"GET\") or {\"version\": 1, \"prs\": {}}\n path = _state_path(repo)\n if not path.exists():\n return {\"version\": 1, \"prs\": {}}\n return json.loads(path.read_text())\n\n\ndef save_state(repo, state):\n state[\"version\"] = 1\n if KV_TOKEN and KV_BASE:\n _kv_request(_state_key(repo), \"PUT\", state)\n return\n path = _state_path(repo)\n temporary = path.with_suffix(\".tmp\")\n temporary.write_text(json.dumps(state, indent=2, sort_keys=True) + \"\\n\")\n os.replace(temporary, path)\n\n\ndef complete_run(status=\"COMPLETED\", error=None):\n \"\"\"Report this detached entrypoint's terminal state to Automations.\"\"\"\n url = os.environ.get(\"AUTOMATION_CALLBACK_URL\")\n if not url:\n return\n body = {\"status\": status, \"run_id\": os.environ.get(\"AUTOMATION_RUN_ID\", \"\")}\n if error:\n body[\"error\"] = error\n # A run on OpenHands Cloud or Enterprise is not given a callback key; it\n # authenticates with its API key.\n api_key = os.environ.get(\"AUTOMATION_CALLBACK_API_KEY\") or os.environ.get(\n \"OPENHANDS_API_KEY\", \"\"\n )\n request = Request(\n url,\n data=json.dumps(body).encode(),\n headers={\n \"Authorization\": f\"Bearer {api_key}\",\n \"Content-Type\": \"application/json\",\n },\n )\n with urlopen(request):\n pass\n\n\nclass StaleCIPullRequestCloser(GitHubRepository):\n name = \"github-stale-ci-pr-closer\"\n\n def required_checks(self, branch):\n cached = getattr(self, \"_required_checks\", {})\n if branch in cached:\n return cached[branch]\n try:\n rules = self.gh(\"GET\", f\"/rules/branches/{quote(branch, safe='')}\")\n except HTTPError as exc:\n if exc.code == 404:\n return []\n raise\n required = []\n for rule in rules:\n if rule.get(\"type\") != \"required_status_checks\":\n continue\n required.extend(\n rule.get(\"parameters\", {}).get(\"required_status_checks\", [])\n )\n cached[branch] = required\n self._required_checks = cached\n return required\n\n @staticmethod\n def _required_ci_result(runs, statuses, required):\n if not required:\n return \"unconfigured\", None\n latest_runs = {}\n for run in sorted(runs, key=lambda item: item.get(\"id\", 0), reverse=True):\n latest_runs.setdefault(\n (run.get(\"name\"), (run.get(\"app\") or {}).get(\"id\")), run\n )\n latest_statuses = {}\n for status in sorted(\n statuses,\n key=lambda item: item.get(\"updated_at\") or item.get(\"created_at\") or \"\",\n reverse=True,\n ):\n latest_statuses.setdefault(status.get(\"context\"), status)\n\n states = []\n failures = []\n for item in required:\n context = item[\"context\"]\n integration = item.get(\"integration_id\") or None\n run = latest_runs.get((context, integration))\n if run is None and integration is None:\n matches = [\n value for (name, _), value in latest_runs.items() if name == context\n ]\n run = max(matches, key=lambda value: value.get(\"id\", 0), default=None)\n if run is not None:\n if run.get(\"status\") != \"completed\" or run.get(\"conclusion\") is None:\n states.append(\"pending\")\n elif run.get(\"conclusion\") in PASSING_CONCLUSIONS:\n states.append(\"passing\")\n else:\n states.append(\"failing\")\n completed = run.get(\"completed_at\") or run.get(\"started_at\")\n if completed:\n failures.append(_timestamp(completed))\n continue\n status = latest_statuses.get(context) if integration is None else None\n if status is None or status.get(\"state\") in {\"pending\", \"expected\"}:\n states.append(\"pending\")\n elif status.get(\"state\") == \"success\":\n states.append(\"passing\")\n else:\n states.append(\"failing\")\n completed = status.get(\"updated_at\") or status.get(\"created_at\")\n if completed:\n failures.append(_timestamp(completed))\n if \"pending\" in states:\n return \"pending\", None\n if \"failing\" in states:\n return \"failing\", max(failures) if failures else None\n return \"passing\", None\n\n def required_ci_result(self, sha, required):\n if not required:\n return \"unconfigured\", None\n runs = []\n for page in range(1, 11):\n batch = self.gh(\n \"GET\", f\"/commits/{sha}/check-runs?per_page=100&page={page}\"\n )\n page_runs = batch.get(\"check_runs\", [])\n runs.extend(page_runs)\n if len(page_runs) < 100:\n break\n else:\n raise RuntimeError(f\"More than 1,000 check runs found for {sha}\")\n statuses = self.gh_pages(f\"/commits/{sha}/statuses\")\n return self._required_ci_result(runs, statuses, required)\n\n def required_ci_state(self, sha, required):\n return self.required_ci_result(sha, required)[0]\n\n @staticmethod\n def _graphql_ci_result(contexts, required):\n runs = []\n statuses = []\n for context in contexts:\n if context.get(\"__typename\") == \"CheckRun\":\n app = ((context.get(\"checkSuite\") or {}).get(\"app\") or {}).get(\n \"databaseId\"\n )\n runs.append(\n {\n \"id\": context.get(\"databaseId\"),\n \"name\": context.get(\"name\"),\n \"app\": {\"id\": app},\n \"status\": (context.get(\"status\") or \"\").lower(),\n \"conclusion\": (context.get(\"conclusion\") or \"\").lower() or None,\n \"started_at\": context.get(\"startedAt\"),\n \"completed_at\": context.get(\"completedAt\"),\n }\n )\n elif context.get(\"__typename\") == \"StatusContext\":\n statuses.append(\n {\n \"context\": context.get(\"context\"),\n \"state\": (context.get(\"state\") or \"\").lower(),\n \"created_at\": context.get(\"createdAt\"),\n }\n )\n return StaleCIPullRequestCloser._required_ci_result(runs, statuses, required)\n\n def open_pull_requests(self):\n \"\"\"Read every open PR and its latest check rollup with explicit pagination.\"\"\"\n owner, name = self.repository.split(\"/\", 1)\n cursor = None\n seen_cursors = set()\n pull_requests = []\n while True:\n for attempt in range(4):\n try:\n result = self.api(\n \"POST\",\n \"/graphql\",\n body={\n \"query\": PULL_REQUESTS_QUERY,\n \"variables\": {\n \"owner\": owner,\n \"name\": name,\n \"cursor\": cursor,\n },\n },\n )\n break\n except HTTPError as exc:\n if exc.code < 500 or attempt == 3:\n raise\n time.sleep(2**attempt)\n if result.get(\"errors\"):\n raise RuntimeError(\"GitHub GraphQL pull request query failed\")\n connection = result[\"data\"][\"repository\"][\"pullRequests\"]\n for node in connection[\"nodes\"]:\n commits = node.get(\"commits\", {}).get(\"nodes\", [])\n rollup = (\n ((commits[-1].get(\"commit\") or {}).get(\"statusCheckRollup\") or {})\n if commits\n else {}\n )\n contexts = rollup.get(\"contexts\") or {\"nodes\": [], \"totalCount\": 0}\n required = self.required_checks(node[\"baseRefName\"])\n if contexts.get(\"totalCount\", 0) > len(contexts.get(\"nodes\", [])):\n state, failed_at = self.required_ci_result(\n node[\"headRefOid\"], required\n )\n else:\n state, failed_at = self._graphql_ci_result(\n contexts.get(\"nodes\", []), required\n )\n pull_requests.append(\n {\n \"number\": node[\"number\"],\n \"draft\": node[\"isDraft\"],\n \"head\": {\"sha\": node[\"headRefOid\"]},\n \"base\": {\"ref\": node[\"baseRefName\"]},\n \"user\": {\"login\": (node.get(\"author\") or {}).get(\"login\", \"\")},\n \"_ci_state\": state,\n \"_failed_at\": failed_at,\n }\n )\n page = connection[\"pageInfo\"]\n if not page[\"hasNextPage\"]:\n return pull_requests\n cursor = page[\"endCursor\"]\n if not cursor or cursor in seen_cursors:\n raise RuntimeError(\"GitHub returned an invalid pull request cursor\")\n seen_cursors.add(cursor)\n\n def comments(self, number):\n return self.gh_pages(f\"/issues/{number}/comments\")\n\n def post_comment(self, number, body):\n return self.gh(\"POST\", f\"/issues/{number}/comments\", {\"body\": body})\n\n @staticmethod\n def author_followed_up(pr, comments, after):\n author = (pr.get(\"user\") or {}).get(\"login\", \"\").lower()\n return any(\n (comment.get(\"user\") or {}).get(\"login\", \"\").lower() == author\n and _timestamp(comment[\"created_at\"]) > after\n for comment in comments\n )\n\n def _backfill_first_failed_at(self, pr, record):\n \"\"\"Age a record from the required-check failure, unless it is a reset.\n\n Versions before this one stored first *observation* time, so a record\n whose CI had already been failing for longer than we had been watching\n under-counted the window; re-age it to the newest required-check\n failure. Versions before this one also cleared `warning` on author\n follow-up to open a fresh window without leaving a marker, so such a\n reset is indistinguishable from a first sighting by record shape alone\n and is recognized from the comment history instead: the author of the\n pull request commented after the newest required-check failure. Aging\n that record would collapse the fresh window the follow-up just opened,\n so it is left alone.\n \"\"\"\n failed_at = pr.get(\"_failed_at\")\n if failed_at is None:\n return\n if self.author_followed_up(pr, self.comments(pr[\"number\"]), failed_at):\n record[\"reset_at\"] = record[\"first_failed_at\"]\n return\n record[\"first_failed_at\"] = min(record[\"first_failed_at\"], failed_at)\n\n def reconcile(self, pr, records, now):\n number = pr[\"number\"]\n key = str(number)\n if pr.get(\"draft\"):\n records.pop(key, None)\n return \"draft\"\n head = pr[\"head\"][\"sha\"]\n ci_state = pr.get(\"_ci_state\")\n if ci_state is None:\n required = self.required_checks(pr[\"base\"][\"ref\"])\n ci_state = self.required_ci_state(head, required)\n if ci_state != \"failing\":\n records.pop(key, None)\n return ci_state\n\n record = records.get(key)\n if not record or record.get(\"head_sha\") != head:\n failed_at = pr.get(\"_failed_at\")\n record = {\n \"head_sha\": head,\n \"first_failed_at\": now if failed_at is None else failed_at,\n }\n records[key] = record\n\n warning = record.get(\"warning\")\n if not warning and not record.get(\"reset_at\"):\n self._backfill_first_failed_at(pr, record)\n if not warning:\n if now - record[\"first_failed_at\"] < WARN_AFTER_SECONDS:\n return \"observing\"\n comments = self.comments(number)\n head_marker = f\"<!-- openhands-stale-ci-warning head={head} -->\"\n existing = [\n comment\n for comment in comments\n if head_marker in (comment.get(\"body\") or \"\")\n ]\n existing.sort(\n key=lambda comment: comment.get(\"created_at\", \"\"), reverse=True\n )\n if existing:\n warning_at = _timestamp(existing[0][\"created_at\"])\n if warning_at >= record[\n \"first_failed_at\"\n ] and not self.author_followed_up(pr, comments, warning_at):\n warning = {\n \"at\": warning_at,\n \"head_sha\": head,\n \"comment_id\": existing[0].get(\"id\"),\n }\n record[\"warning\"] = warning\n if not warning:\n comment = self.post_comment(\n number,\n f\"{WARNING_MARKER}\\n{head_marker}\\nRequired CI has remained failing for \"\n \"seven days. Please repair it or comment with an update. If the author \"\n \"does not push or comment and required CI remains failing for another \"\n \"seven days, this pull request will be closed automatically.\",\n )\n record[\"warning\"] = {\n \"at\": now,\n \"head_sha\": head,\n \"comment_id\": comment.get(\"id\"),\n }\n return \"warned\"\n\n comments = self.comments(number)\n if self.author_followed_up(pr, comments, warning[\"at\"]):\n records[key] = {\n \"head_sha\": head,\n \"first_failed_at\": now,\n \"reset_at\": now,\n }\n return \"followed-up\"\n if now - warning[\"at\"] < CLOSE_AFTER_SECONDS:\n return \"waiting\"\n\n if not any(CLOSE_MARKER in (comment.get(\"body\") or \"\") for comment in comments):\n self.post_comment(\n number,\n f\"{CLOSE_MARKER}\\nClosing because required CI is still failing seven days \"\n \"after the automated warning and the author has not pushed or commented. \"\n \"The pull request can be reopened when work resumes.\",\n )\n self.gh(\"PATCH\", f\"/pulls/{number}\", {\"state\": \"closed\"})\n records.pop(key, None)\n return \"closed\"\n\n def run(self):\n state = load_state(self.repository)\n records = state.setdefault(\"prs\", {})\n open_prs = self.open_pull_requests()\n open_by_number = {pr[\"number\"]: pr for pr in open_prs}\n open_numbers = {str(number) for number in open_by_number}\n for key in set(records) - open_numbers:\n records.pop(key, None)\n now = time.time()\n candidates = {\n pr[\"number\"]\n for pr in open_prs\n if not pr[\"draft\"]\n and pr[\"_ci_state\"] == \"failing\"\n and pr[\"_failed_at\"] is not None\n and now - pr[\"_failed_at\"] >= WARN_AFTER_SECONDS\n }\n candidates.update(int(number) for number in records)\n for number in sorted(candidates, reverse=True):\n pr = open_by_number.get(number)\n if pr is None:\n continue\n outcome = self.reconcile(pr, records, now)\n print(\n json.dumps(\n {\n \"repository\": self.repository,\n \"pr\": pr[\"number\"],\n \"outcome\": outcome,\n }\n )\n )\n save_state(self.repository, state)\n\n\nif __name__ == \"__main__\":\n try:\n run_repositories(StaleCIPullRequestCloser)\n except Exception as exc:\n complete_run(\"FAILED\", str(exc))\n raise\n else:\n complete_run()\n"
42
+ "worker.py": "\"\"\"Warn and close abandoned pull requests with persistently failing required CI.\"\"\"\n\nimport json\nimport os\nimport time\nfrom datetime import datetime\nfrom pathlib import Path\nfrom urllib.error import HTTPError\nfrom urllib.parse import quote\nfrom urllib.request import Request, urlopen\n\nfrom github_client import GitHubRepository, run_repositories\n\nWARN_AFTER_SECONDS = 7 * 24 * 60 * 60\nCLOSE_AFTER_SECONDS = 7 * 24 * 60 * 60\nWARNING_MARKER = \"<!-- openhands-stale-ci-warning -->\"\nCLOSE_MARKER = \"<!-- openhands-stale-ci-close -->\"\nPASSING_CONCLUSIONS = {\"success\", \"neutral\", \"skipped\"}\nPULL_REQUESTS_QUERY = \"\"\"\nquery($owner: String!, $name: String!, $cursor: String) {\n repository(owner: $owner, name: $name) {\n pullRequests(first: 40, after: $cursor, states: OPEN) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number isDraft baseRefName headRefOid\n author { login }\n commits(last: 1) {\n nodes {\n commit {\n statusCheckRollup {\n contexts(first: 100) {\n totalCount\n nodes {\n __typename\n ... on CheckRun {\n databaseId name status conclusion startedAt completedAt\n checkSuite { app { databaseId } }\n }\n ... on StatusContext { context state createdAt }\n }\n }\n }\n }\n }\n }\n }\n }\n }\n}\n\"\"\"\nKV_TOKEN = os.environ.get(\"AUTOMATION_KV_TOKEN\", \"\")\nKV_BASE = os.environ.get(\"AUTOMATION_API_URL\", \"\").rstrip(\"/\")\n\n\ndef _timestamp(value):\n return datetime.fromisoformat(value).timestamp()\n\n\ndef _state_key(repo):\n payload = json.loads(os.environ.get(\"AUTOMATION_EVENT_PAYLOAD\", \"{}\"))\n automation_id = payload.get(\"automation_id\", \"default\")\n return f\"github-stale-ci-pr-closer:{automation_id}:{repo.replace('/', '__')}\"\n\n\ndef _state_path(repo):\n workspace = Path(\n os.environ.get(\"WORKSPACE_BASE\", Path.home() / \".openhands/workspaces\")\n )\n root = (\n workspace.resolve().parent.parent\n if os.environ.get(\"WORKSPACE_BASE\")\n else workspace\n )\n directory = root / \"automation-state\"\n directory.mkdir(parents=True, exist_ok=True)\n return directory / (_state_key(repo) + \".json\")\n\n\ndef _kv_request(key, method, value=None):\n if not KV_TOKEN or not KV_BASE:\n return None\n data = json.dumps(value).encode() if value is not None else None\n request = Request(\n f\"{KV_BASE}/v1/kv/{quote(key, safe='')}\",\n data=data,\n method=method,\n headers={\n \"Authorization\": f\"Bearer {KV_TOKEN}\",\n \"Content-Type\": \"application/json\",\n },\n )\n try:\n with urlopen(request) as response:\n payload = json.loads(response.read() or b\"{}\")\n return payload.get(\"value\")\n except HTTPError as exc:\n if method == \"GET\" and exc.code == 404:\n return None\n raise\n\n\ndef load_state(repo):\n if KV_TOKEN and KV_BASE:\n state = _kv_request(_state_key(repo), \"GET\") or {\"version\": 1, \"prs\": {}}\n if \"prs\" not in state and \"value\" in state:\n # Earlier versions saved {\"value\": state}, and the store keeps the\n # whole body, so each save nested the last one a level deeper.\n state = state[\"value\"]\n state.pop(\"value\", None)\n return state\n path = _state_path(repo)\n if not path.exists():\n return {\"version\": 1, \"prs\": {}}\n return json.loads(path.read_text())\n\n\ndef save_state(repo, state):\n state[\"version\"] = 1\n if KV_TOKEN and KV_BASE:\n _kv_request(_state_key(repo), \"PUT\", state)\n return\n path = _state_path(repo)\n temporary = path.with_suffix(\".tmp\")\n temporary.write_text(json.dumps(state, indent=2, sort_keys=True) + \"\\n\")\n os.replace(temporary, path)\n\n\ndef complete_run(status=\"COMPLETED\", error=None):\n \"\"\"Report this detached entrypoint's terminal state to Automations.\"\"\"\n url = os.environ.get(\"AUTOMATION_CALLBACK_URL\")\n if not url:\n return\n body = {\"status\": status, \"run_id\": os.environ.get(\"AUTOMATION_RUN_ID\", \"\")}\n if error:\n body[\"error\"] = error\n # A run on OpenHands Cloud or Enterprise is not given a callback key; it\n # authenticates with its API key.\n api_key = os.environ.get(\"AUTOMATION_CALLBACK_API_KEY\") or os.environ.get(\n \"OPENHANDS_API_KEY\", \"\"\n )\n request = Request(\n url,\n data=json.dumps(body).encode(),\n headers={\n \"Authorization\": f\"Bearer {api_key}\",\n \"Content-Type\": \"application/json\",\n },\n )\n with urlopen(request):\n pass\n\n\nclass StaleCIPullRequestCloser(GitHubRepository):\n name = \"github-stale-ci-pr-closer\"\n\n def required_checks(self, branch):\n cached = getattr(self, \"_required_checks\", {})\n if branch in cached:\n return cached[branch]\n try:\n rules = self.gh(\"GET\", f\"/rules/branches/{quote(branch, safe='')}\")\n except HTTPError as exc:\n if exc.code == 404:\n return []\n raise\n required = []\n for rule in rules:\n if rule.get(\"type\") != \"required_status_checks\":\n continue\n required.extend(\n rule.get(\"parameters\", {}).get(\"required_status_checks\", [])\n )\n cached[branch] = required\n self._required_checks = cached\n return required\n\n @staticmethod\n def _required_ci_result(runs, statuses, required):\n if not required:\n return \"unconfigured\", None\n latest_runs = {}\n for run in sorted(runs, key=lambda item: item.get(\"id\", 0), reverse=True):\n latest_runs.setdefault(\n (run.get(\"name\"), (run.get(\"app\") or {}).get(\"id\")), run\n )\n latest_statuses = {}\n for status in sorted(\n statuses,\n key=lambda item: item.get(\"updated_at\") or item.get(\"created_at\") or \"\",\n reverse=True,\n ):\n latest_statuses.setdefault(status.get(\"context\"), status)\n\n states = []\n failures = []\n for item in required:\n context = item[\"context\"]\n integration = item.get(\"integration_id\") or None\n run = latest_runs.get((context, integration))\n if run is None and integration is None:\n matches = [\n value for (name, _), value in latest_runs.items() if name == context\n ]\n run = max(matches, key=lambda value: value.get(\"id\", 0), default=None)\n if run is not None:\n if run.get(\"status\") != \"completed\" or run.get(\"conclusion\") is None:\n states.append(\"pending\")\n elif run.get(\"conclusion\") in PASSING_CONCLUSIONS:\n states.append(\"passing\")\n else:\n states.append(\"failing\")\n completed = run.get(\"completed_at\") or run.get(\"started_at\")\n if completed:\n failures.append(_timestamp(completed))\n continue\n status = latest_statuses.get(context) if integration is None else None\n if status is None or status.get(\"state\") in {\"pending\", \"expected\"}:\n states.append(\"pending\")\n elif status.get(\"state\") == \"success\":\n states.append(\"passing\")\n else:\n states.append(\"failing\")\n completed = status.get(\"updated_at\") or status.get(\"created_at\")\n if completed:\n failures.append(_timestamp(completed))\n if \"pending\" in states:\n return \"pending\", None\n if \"failing\" in states:\n return \"failing\", max(failures) if failures else None\n return \"passing\", None\n\n def required_ci_result(self, sha, required):\n if not required:\n return \"unconfigured\", None\n runs = []\n for page in range(1, 11):\n batch = self.gh(\n \"GET\", f\"/commits/{sha}/check-runs?per_page=100&page={page}\"\n )\n page_runs = batch.get(\"check_runs\", [])\n runs.extend(page_runs)\n if len(page_runs) < 100:\n break\n else:\n raise RuntimeError(f\"More than 1,000 check runs found for {sha}\")\n statuses = self.gh_pages(f\"/commits/{sha}/statuses\")\n return self._required_ci_result(runs, statuses, required)\n\n def required_ci_state(self, sha, required):\n return self.required_ci_result(sha, required)[0]\n\n @staticmethod\n def _graphql_ci_result(contexts, required):\n runs = []\n statuses = []\n for context in contexts:\n if context.get(\"__typename\") == \"CheckRun\":\n app = ((context.get(\"checkSuite\") or {}).get(\"app\") or {}).get(\n \"databaseId\"\n )\n runs.append(\n {\n \"id\": context.get(\"databaseId\"),\n \"name\": context.get(\"name\"),\n \"app\": {\"id\": app},\n \"status\": (context.get(\"status\") or \"\").lower(),\n \"conclusion\": (context.get(\"conclusion\") or \"\").lower() or None,\n \"started_at\": context.get(\"startedAt\"),\n \"completed_at\": context.get(\"completedAt\"),\n }\n )\n elif context.get(\"__typename\") == \"StatusContext\":\n statuses.append(\n {\n \"context\": context.get(\"context\"),\n \"state\": (context.get(\"state\") or \"\").lower(),\n \"created_at\": context.get(\"createdAt\"),\n }\n )\n return StaleCIPullRequestCloser._required_ci_result(runs, statuses, required)\n\n def open_pull_requests(self):\n \"\"\"Read every open PR and its latest check rollup with explicit pagination.\"\"\"\n owner, name = self.repository.split(\"/\", 1)\n cursor = None\n seen_cursors = set()\n pull_requests = []\n while True:\n for attempt in range(4):\n try:\n result = self.api(\n \"POST\",\n \"/graphql\",\n body={\n \"query\": PULL_REQUESTS_QUERY,\n \"variables\": {\n \"owner\": owner,\n \"name\": name,\n \"cursor\": cursor,\n },\n },\n )\n break\n except HTTPError as exc:\n if exc.code < 500 or attempt == 3:\n raise\n time.sleep(2**attempt)\n if result.get(\"errors\"):\n raise RuntimeError(\"GitHub GraphQL pull request query failed\")\n connection = result[\"data\"][\"repository\"][\"pullRequests\"]\n for node in connection[\"nodes\"]:\n commits = node.get(\"commits\", {}).get(\"nodes\", [])\n rollup = (\n ((commits[-1].get(\"commit\") or {}).get(\"statusCheckRollup\") or {})\n if commits\n else {}\n )\n contexts = rollup.get(\"contexts\") or {\"nodes\": [], \"totalCount\": 0}\n required = self.required_checks(node[\"baseRefName\"])\n if contexts.get(\"totalCount\", 0) > len(contexts.get(\"nodes\", [])):\n state, failed_at = self.required_ci_result(\n node[\"headRefOid\"], required\n )\n else:\n state, failed_at = self._graphql_ci_result(\n contexts.get(\"nodes\", []), required\n )\n pull_requests.append(\n {\n \"number\": node[\"number\"],\n \"draft\": node[\"isDraft\"],\n \"head\": {\"sha\": node[\"headRefOid\"]},\n \"base\": {\"ref\": node[\"baseRefName\"]},\n \"user\": {\"login\": (node.get(\"author\") or {}).get(\"login\", \"\")},\n \"_ci_state\": state,\n \"_failed_at\": failed_at,\n }\n )\n page = connection[\"pageInfo\"]\n if not page[\"hasNextPage\"]:\n return pull_requests\n cursor = page[\"endCursor\"]\n if not cursor or cursor in seen_cursors:\n raise RuntimeError(\"GitHub returned an invalid pull request cursor\")\n seen_cursors.add(cursor)\n\n def comments(self, number):\n return self.gh_pages(f\"/issues/{number}/comments\")\n\n def post_comment(self, number, body):\n return self.gh(\"POST\", f\"/issues/{number}/comments\", {\"body\": body})\n\n @staticmethod\n def author_followed_up(pr, comments, after):\n author = (pr.get(\"user\") or {}).get(\"login\", \"\").lower()\n return any(\n (comment.get(\"user\") or {}).get(\"login\", \"\").lower() == author\n and _timestamp(comment[\"created_at\"]) > after\n for comment in comments\n )\n\n def _backfill_first_failed_at(self, pr, record):\n \"\"\"Age a record from the required-check failure, unless it is a reset.\n\n Versions before this one stored first *observation* time, so a record\n whose CI had already been failing for longer than we had been watching\n under-counted the window; re-age it to the newest required-check\n failure. Versions before this one also cleared `warning` on author\n follow-up to open a fresh window without leaving a marker, so such a\n reset is indistinguishable from a first sighting by record shape alone\n and is recognized from the comment history instead: the author of the\n pull request commented after the newest required-check failure. Aging\n that record would collapse the fresh window the follow-up just opened,\n so it is left alone.\n \"\"\"\n failed_at = pr.get(\"_failed_at\")\n if failed_at is None:\n return\n if self.author_followed_up(pr, self.comments(pr[\"number\"]), failed_at):\n record[\"reset_at\"] = record[\"first_failed_at\"]\n return\n record[\"first_failed_at\"] = min(record[\"first_failed_at\"], failed_at)\n\n def reconcile(self, pr, records, now):\n number = pr[\"number\"]\n key = str(number)\n if pr.get(\"draft\"):\n records.pop(key, None)\n return \"draft\"\n head = pr[\"head\"][\"sha\"]\n ci_state = pr.get(\"_ci_state\")\n if ci_state is None:\n required = self.required_checks(pr[\"base\"][\"ref\"])\n ci_state = self.required_ci_state(head, required)\n if ci_state != \"failing\":\n records.pop(key, None)\n return ci_state\n\n record = records.get(key)\n if not record or record.get(\"head_sha\") != head:\n failed_at = pr.get(\"_failed_at\")\n record = {\n \"head_sha\": head,\n \"first_failed_at\": now if failed_at is None else failed_at,\n }\n records[key] = record\n\n warning = record.get(\"warning\")\n if not warning and not record.get(\"reset_at\"):\n self._backfill_first_failed_at(pr, record)\n if not warning:\n if now - record[\"first_failed_at\"] < WARN_AFTER_SECONDS:\n return \"observing\"\n comments = self.comments(number)\n head_marker = f\"<!-- openhands-stale-ci-warning head={head} -->\"\n existing = [\n comment\n for comment in comments\n if head_marker in (comment.get(\"body\") or \"\")\n ]\n existing.sort(\n key=lambda comment: comment.get(\"created_at\", \"\"), reverse=True\n )\n if existing:\n warning_at = _timestamp(existing[0][\"created_at\"])\n if warning_at >= record[\n \"first_failed_at\"\n ] and not self.author_followed_up(pr, comments, warning_at):\n warning = {\n \"at\": warning_at,\n \"head_sha\": head,\n \"comment_id\": existing[0].get(\"id\"),\n }\n record[\"warning\"] = warning\n if not warning:\n comment = self.post_comment(\n number,\n f\"{WARNING_MARKER}\\n{head_marker}\\nRequired CI has remained failing for \"\n \"seven days. Please repair it or comment with an update. If the author \"\n \"does not push or comment and required CI remains failing for another \"\n \"seven days, this pull request will be closed automatically.\",\n )\n record[\"warning\"] = {\n \"at\": now,\n \"head_sha\": head,\n \"comment_id\": comment.get(\"id\"),\n }\n return \"warned\"\n\n comments = self.comments(number)\n if self.author_followed_up(pr, comments, warning[\"at\"]):\n records[key] = {\n \"head_sha\": head,\n \"first_failed_at\": now,\n \"reset_at\": now,\n }\n return \"followed-up\"\n if now - warning[\"at\"] < CLOSE_AFTER_SECONDS:\n return \"waiting\"\n\n if not any(CLOSE_MARKER in (comment.get(\"body\") or \"\") for comment in comments):\n self.post_comment(\n number,\n f\"{CLOSE_MARKER}\\nClosing because required CI is still failing seven days \"\n \"after the automated warning and the author has not pushed or commented. \"\n \"The pull request can be reopened when work resumes.\",\n )\n self.gh(\"PATCH\", f\"/pulls/{number}\", {\"state\": \"closed\"})\n records.pop(key, None)\n return \"closed\"\n\n def run(self):\n state = load_state(self.repository)\n records = state.setdefault(\"prs\", {})\n open_prs = self.open_pull_requests()\n open_by_number = {pr[\"number\"]: pr for pr in open_prs}\n open_numbers = {str(number) for number in open_by_number}\n for key in set(records) - open_numbers:\n records.pop(key, None)\n now = time.time()\n candidates = {\n pr[\"number\"]\n for pr in open_prs\n if not pr[\"draft\"]\n and pr[\"_ci_state\"] == \"failing\"\n and pr[\"_failed_at\"] is not None\n and now - pr[\"_failed_at\"] >= WARN_AFTER_SECONDS\n }\n candidates.update(int(number) for number in records)\n for number in sorted(candidates, reverse=True):\n pr = open_by_number.get(number)\n if pr is None:\n continue\n outcome = self.reconcile(pr, records, now)\n print(\n json.dumps(\n {\n \"repository\": self.repository,\n \"pr\": pr[\"number\"],\n \"outcome\": outcome,\n }\n )\n )\n save_state(self.repository, state)\n\n\nif __name__ == \"__main__\":\n try:\n run_repositories(StaleCIPullRequestCloser)\n except Exception as exc:\n complete_run(\"FAILED\", str(exc))\n raise\n else:\n complete_run()\n"
43
43
  }
44
44
  };
@@ -79,7 +79,7 @@
79
79
  "triggerReviewer": {
80
80
  "type": "text",
81
81
  "label": "Requested reviewer",
82
- "help": "GitHub login whose review request starts an event-driven review.",
82
+ "help": "GitHub login whose review request starts an event-driven review. The review is published by the account whose token the automation uses, so on OpenHands Cloud and Enterprise set this to the connected account's login.",
83
83
  "default": "all-hands-bot",
84
84
  "required": true,
85
85
  "constraints": {
@@ -128,7 +128,7 @@
128
128
  "githubTokenSecret": {
129
129
  "type": "text",
130
130
  "label": "GitHub token secret",
131
- "help": "Name of a saved Agent Server secret. Include it in the selected profile so the delegated agent can use GitHub.",
131
+ "help": "Name of the secret holding the GitHub token. On OpenHands Cloud and Enterprise, leave the default when GitHub is connected under Settings > Integrations: no saved secret is needed, the connected account's token is used, and the review is published by that account. On a local Agent Canvas this is the secret the GitHub integration saved; include it in the selected profile so the delegated agent can use GitHub.",
132
132
  "default": "GITHUB_PERSONAL_ACCESS_TOKEN",
133
133
  "required": true
134
134
  }
@@ -92,6 +92,19 @@
92
92
  "openhands"
93
93
  ]
94
94
  },
95
+ {
96
+ "name": "software-factory",
97
+ "source": "./skills/software-factory",
98
+ "description": "Set up a GitHub software factory fast: ask only for the repositories and the ticket source (GitHub issues, Jira or Linear), create a working issue-to-PR and PR-review pipeline from catalog automations with defaults, then let the user stop or extend it step by step.",
99
+ "category": "automations",
100
+ "keywords": [
101
+ "onboarding",
102
+ "software-factory",
103
+ "github",
104
+ "automation",
105
+ "pull-request"
106
+ ]
107
+ },
95
108
  {
96
109
  "name": "agent-canvas-environment",
97
110
  "source": "./skills/agent-canvas-environment",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openhands/extensions",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Public OpenHands extension catalogs for skills, plugins, integrations, and automation templates.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-extensions"
3
- version = "0.30.0"
3
+ version = "0.31.0"
4
4
  description = "OpenHands extensions, plugins, and skills (Python bindings for the integration catalog)"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -18,7 +18,7 @@ from importlib.metadata import PackageNotFoundError, version
18
18
  #: Kept in lock-step with pyproject.toml/package.json by the version test.
19
19
  #: release-please only rewrites a semver found on the annotated line itself, so
20
20
  #: the annotation must stay on the assignment; on its own line it is a no-op.
21
- _FALLBACK_VERSION = "0.30.0" # x-release-please-version
21
+ _FALLBACK_VERSION = "0.31.0" # x-release-please-version
22
22
 
23
23
  try:
24
24
  __version__: str = version("openhands-extensions")
@@ -117,6 +117,12 @@ write issue comments, and **write pull request reviews** — the review is
117
117
  published and the optional human reviewer is requested through the pull request
118
118
  API, so read-only pull request access is not enough.
119
119
 
120
+ On OpenHands Cloud and Enterprise, the Agent Canvas catalog template needs no
121
+ saved secret when GitHub is connected under Settings -> Integrations: a secret
122
+ saved under the configured name is used if present, otherwise the connected
123
+ account's token, and the review is published by that account. Set the
124
+ requested reviewer to that account's login.
125
+
120
126
  ## Quick Start
121
127
 
122
128
  Ask OpenHands for either trigger mode:
@@ -254,6 +254,13 @@ requesting the handoff.
254
254
 
255
255
  When several repositories are monitored, the token must cover all of them.
256
256
 
257
+ On OpenHands Cloud and Enterprise, the Agent Canvas catalog template needs no
258
+ saved secret when GitHub is connected under Settings -> Integrations: a secret
259
+ saved under the configured name is used if present, otherwise the connected
260
+ account's token, and the review is published by that account. The requested
261
+ reviewer should be that account's login. The steps below apply to the local
262
+ setup through this skill.
263
+
257
264
  Check with:
258
265
  ```bash
259
266
  curl -s https://api.github.com/user \
@@ -79,7 +79,7 @@ def _state_path(repo):
79
79
  def _kv_request(key, method, value=None):
80
80
  if not KV_TOKEN or not KV_BASE:
81
81
  return None
82
- data = json.dumps({"value": value}).encode() if value is not None else None
82
+ data = json.dumps(value).encode() if value is not None else None
83
83
  request = Request(
84
84
  f"{KV_BASE}/v1/kv/{quote(key, safe='')}",
85
85
  data=data,
@@ -101,7 +101,13 @@ def _kv_request(key, method, value=None):
101
101
 
102
102
  def load_state(repo):
103
103
  if KV_TOKEN and KV_BASE:
104
- return _kv_request(_state_key(repo), "GET") or {"version": 1, "prs": {}}
104
+ state = _kv_request(_state_key(repo), "GET") or {"version": 1, "prs": {}}
105
+ if "prs" not in state and "value" in state:
106
+ # Earlier versions saved {"value": state}, and the store keeps the
107
+ # whole body, so each save nested the last one a level deeper.
108
+ state = state["value"]
109
+ state.pop("value", None)
110
+ return state
105
111
  path = _state_path(repo)
106
112
  if not path.exists():
107
113
  return {"version": 1, "prs": {}}