@openhands/extensions 0.30.0 → 0.32.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 (47) hide show
  1. package/.agents/skills/custom-codereview-guide.md +3 -4
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.codex-plugin/marketplace.json +14 -0
  4. package/.github/workflows/pr-artifacts.yml +4 -83
  5. package/.github/workflows/tests.yml +13 -0
  6. package/.plugin/marketplace.json +14 -0
  7. package/.release-please-manifest.json +1 -1
  8. package/AGENTS.md +4 -3
  9. package/README.md +3 -2
  10. package/automations/README.md +9 -2
  11. package/automations/bundle-index.js +1 -1
  12. package/automations/catalog/github-pr-reviewer/manifest.json +2 -2
  13. package/automations/catalog/incident-retrospective-drafter/manifest.json +4 -0
  14. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +3 -0
  15. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +3 -0
  16. package/automations/catalog/jira-issue-to-pr/manifest.json +3 -0
  17. package/automations/catalog/linear-issue-to-bitbucket-pr/manifest.json +4 -0
  18. package/automations/catalog/linear-issue-to-github-pr/manifest.json +4 -0
  19. package/automations/catalog/linear-issue-to-gitlab-mr/manifest.json +4 -0
  20. package/automations/catalog/linear-triage-assistant/manifest.json +4 -0
  21. package/automations/catalog/research-brief-writer/manifest.json +3 -0
  22. package/automations/catalog/slack-channel-monitor/manifest.json +3 -0
  23. package/automations/catalog/slack-standup-digest/manifest.json +3 -0
  24. package/automations/catalog.schema.json +18 -1
  25. package/automations/index.d.ts +8 -0
  26. package/marketplaces/openhands-extensions.json +14 -0
  27. package/package.json +1 -1
  28. package/pyproject.toml +1 -1
  29. package/python/openhands_extensions/_version.py +1 -1
  30. package/skills/bitbucket-data-center/SKILL.md +26 -9
  31. package/skills/github-pr-reviewer/README.md +6 -0
  32. package/skills/github-pr-reviewer/SKILL.md +7 -0
  33. package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
  34. package/skills/index.js +15 -4
  35. package/skills/slack-channel-monitor/README.md +6 -2
  36. package/skills/slack-channel-monitor/SKILL.md +29 -17
  37. package/skills/slack-channel-monitor/references/windows.md +6 -2
  38. package/skills/slack-channel-monitor/scripts/main.py +156 -11
  39. package/skills/software-factory/.claude-plugin/plugin.json +19 -0
  40. package/skills/software-factory/.codex-plugin/plugin.json +19 -0
  41. package/skills/software-factory/.plugin/plugin.json +19 -0
  42. package/skills/software-factory/README.md +64 -0
  43. package/skills/software-factory/SKILL.md +229 -0
  44. package/skills/software-factory/commands/software-factory.md +8 -0
  45. package/skills/software-factory/scripts/factory.py +630 -0
  46. package/tests/test_github_stale_ci_pr_closer.py +52 -1
  47. 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,20 @@
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
+ "defaultEnabled": true,
101
+ "keywords": [
102
+ "onboarding",
103
+ "software-factory",
104
+ "github",
105
+ "automation",
106
+ "pull-request"
107
+ ]
108
+ },
95
109
  {
96
110
  "name": "agent-canvas-environment",
97
111
  "source": "./skills/agent-canvas-environment",
@@ -92,6 +92,20 @@
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
+ "defaultEnabled": true,
101
+ "keywords": [
102
+ "onboarding",
103
+ "software-factory",
104
+ "github",
105
+ "automation",
106
+ "pull-request"
107
+ ]
108
+ },
95
109
  {
96
110
  "name": "agent-canvas-environment",
97
111
  "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,20 @@
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
+ "defaultEnabled": true,
101
+ "keywords": [
102
+ "onboarding",
103
+ "software-factory",
104
+ "github",
105
+ "automation",
106
+ "pull-request"
107
+ ]
108
+ },
95
109
  {
96
110
  "name": "agent-canvas-environment",
97
111
  "source": "./skills/agent-canvas-environment",
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.30.0"
2
+ ".": "0.32.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 ... | — |
@@ -42,7 +42,7 @@ two records of the same fact from drifting apart, and it is why the file is as s
42
42
  | The setup route | `/automations/new/<id>` |
43
43
  | The integrations the card lists | `requires.integrations`, which every entry carries |
44
44
  | The capabilities endpoint | The host queries it for every automation |
45
- | The trigger variants a deployment can offer | The keys of `setup.form.triggers`; an entry with multiple keys is usable when at least one variant is supported |
45
+ | The trigger variants a deployment can offer | The keys of `setup.form.triggers`; an entry with multiple keys is usable when at least one variant is supported. An entry without a setup form states its kind in `triggers` instead |
46
46
  | Schedule limits, timezone list, event choices, and model profiles | The `cron`, `timezone`, `event-source`, `event-type`, and `llm-profile` field types |
47
47
  | Local validation rules | The `required` flag and `constraints` on each field, applied to the selected trigger variant |
48
48
  | The preflight call | `POST /v1/validate` with the entry id, the create endpoint, and the rendered payload |
@@ -101,6 +101,12 @@ That way a skill can rename its trigger without leaving a stale copy behind in t
101
101
  `setup` is optional. Entries that carry one never repeat `id`, `name`, `category`, or `description`.
102
102
  `setup.version` selects how the block is interpreted; a future format ships a new constant.
103
103
 
104
+ `triggers` is stated only by an entry whose setup does not declare `form.triggers`, which otherwise already
105
+ names the variants: `cron`, `event`, or both, describing how the automation runs once it has been set up.
106
+ An entry lists both when its skill can set up either, for example an event trigger on a deployment that can
107
+ receive events and cron polling elsewhere. Agent Canvas renders it as the card's trigger badge and filter.
108
+ The schema rejects an entry that states it both ways, so the fact stays in one place.
109
+
104
110
  ### Triggers and args
105
111
 
106
112
  `form` separates the two things a user is configuring, and both are keyed by field name:
@@ -115,7 +121,8 @@ That way a skill can rename its trigger without leaving a stale copy behind in t
115
121
  the endpoint implied by the action key: `prompt` -> `/v1/preset/prompt`, `plugin` -> `/v1/preset/plugin`,
116
122
  and `upload` -> `/v1/uploads` followed by `/v1`.
117
123
 
118
- An assisted entry declares no triggers, because the trigger is settled during the conversation.
124
+ An assisted entry declares no `form.triggers`, because the trigger is settled during the conversation. It may
125
+ still list the kinds that conversation can set up in the top-level `triggers`.
119
126
 
120
127
  ### What the form produces
121
128
 
@@ -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
  }
@@ -23,6 +23,10 @@
23
23
  },
24
24
  "popularityRank": 78,
25
25
  "estimatedSetupMinutes": 8,
26
+ "triggers": [
27
+ "cron",
28
+ "event"
29
+ ],
26
30
  "skill": "incident-retrospective",
27
31
  "exampleImplementation": "Trigger: manual, cron, or incident label added\nRequired MCPs: Slack, Linear, Notion\n\n1. Gather incident messages, timestamps, and linked issue references.\n2. Pull Linear follow-up tickets, owners, and status.\n3. TODO: define the incident identifier format and approved Notion template.\n4. Build a timeline, impact summary, root-cause hypotheses, and action items.\n5. Publish a draft retrospective and notify the incident owner.",
28
32
  "setup": {
@@ -18,6 +18,9 @@
18
18
  },
19
19
  "popularityRank": 83,
20
20
  "estimatedSetupMinutes": 6,
21
+ "triggers": [
22
+ "cron"
23
+ ],
21
24
  "skill": "jira-issue-to-pr",
22
25
  "exampleImplementation": "Trigger: cron polling (e.g. every 5 minutes)\nRequired integrations: Atlassian Rovo MCP for Jira access and a Bitbucket connection. On OpenHands Cloud and Enterprise the poller reads and comments on Jira through the connected Atlassian Rovo MCP server, so it needs no Jira API token; the conversation it starts pushes with the connected Bitbucket integration, or commits through the Bitbucket tools of the same Atlassian Rovo MCP server when it has no token. A local Agent Canvas needs a Jira API token and a Bitbucket token as secrets instead.\n\n1. Connect Atlassian Rovo MCP and Bitbucket, then collect the Jira site, label to watch, and cron schedule from the user, and set git_provider to bitbucket. A local Agent Canvas also needs the Jira base URL, email, and API token secret name. No Bitbucket repo is needed at deploy time - each ticket must include the target repo (workspace/repo on bitbucket.org) in its body.\n2. Search the Jira Cloud instance for open issues carrying the configured label.\n3. Deduplicate against a KV-store-backed set of already-processed issue keys so re-runs never create duplicate PRs.\n4. For each new issue, start an independent OpenHands agent conversation that extracts the Bitbucket repo from the ticket body, clones it, creates a branch named after the Jira key, implements or scaffolds the requested change, and opens a pull request.\n5. Immediately after the conversation is created, post a Jira comment on the issue: 'I'm on it: <conversation URL>'.\n6. Persist the processed issue key immediately after dispatching so the next poll skips it."
23
26
  }
@@ -18,6 +18,9 @@
18
18
  },
19
19
  "popularityRank": 84,
20
20
  "estimatedSetupMinutes": 6,
21
+ "triggers": [
22
+ "cron"
23
+ ],
21
24
  "skill": "jira-issue-to-pr",
22
25
  "exampleImplementation": "Trigger: cron polling (e.g. every 5 minutes)\nRequired integrations: Atlassian Rovo MCP for Jira access and a GitLab connection. On OpenHands Cloud and Enterprise the poller reads and comments on Jira through the connected Atlassian Rovo MCP server, so it needs no Jira API token; the conversation it starts pushes with the connected GitLab integration, or commits through the connected GitLab MCP server when it has no token. Calling GitLab MCP directly needs GitLab 19.4 or later. On an older GitLab, sign in with GitLab or connect it under Settings -> Integrations; add a GITLAB_TOKEN secret only if you can do neither. A local Agent Canvas needs a Jira API token and a GitLab token (api scope) as secrets instead.\n\n1. Connect Atlassian Rovo MCP and GitLab, then collect the Jira site, label to watch, and cron schedule from the user, and set git_provider to gitlab. A local Agent Canvas also needs the Jira base URL, email, and API token secret name. No GitLab project is needed at deploy time - each ticket must include the target project (group/project, subgroups included, or its URL on a self-managed host) in its body.\n2. Search the Jira Cloud instance for open issues carrying the configured label.\n3. Deduplicate against a KV-store-backed set of already-processed issue keys so re-runs never create duplicate merge requests.\n4. For each new issue, start an independent OpenHands agent conversation that extracts the GitLab project from the ticket body, clones it, creates a branch named after the Jira key, implements or scaffolds the requested change, and opens a merge request.\n5. Immediately after the conversation is created, post a Jira comment on the issue: 'I'm on it: <conversation URL>'.\n6. Persist the processed issue key immediately after dispatching so the next poll skips it."
23
26
  }
@@ -15,5 +15,8 @@
15
15
  },
16
16
  "popularityRank": 85,
17
17
  "estimatedSetupMinutes": 5,
18
+ "triggers": [
19
+ "cron"
20
+ ],
18
21
  "exampleImplementation": "Trigger: cron polling (e.g. every 5 minutes)\nRequired integrations: Atlassian Rovo MCP for Jira access and a GitHub connection. On OpenHands Cloud and Enterprise the poller reads and comments on Jira through the connected Atlassian Rovo MCP server, so it needs no Jira API token. A local Agent Canvas needs a Jira API token and a GitHub personal access token (repo + workflow scope) as secrets instead.\n\n1. Connect Atlassian Rovo MCP and GitHub, then collect the Jira site, label to watch, and cron schedule from the user. A local Agent Canvas also needs the Jira base URL, email, and API token secret name. No GitHub repo is needed at deploy time - each ticket must include the target repo (owner/repo) in its body.\n2. Search the Jira Cloud instance for open issues carrying the configured label.\n3. Deduplicate against a KV-store-backed set of already-processed issue keys so re-runs never create duplicate PRs.\n4. For each new issue, start an independent OpenHands agent conversation that extracts the GitHub repo from the ticket body, clones it, creates a branch named after the Jira key, implements or scaffolds the requested change, and opens a pull request.\n5. Immediately after the conversation is created, post a Jira comment on the issue: 'I'm on it: <conversation URL>'.\n6. Persist the processed issue key immediately after dispatching so the next poll skips it."
19
22
  }
@@ -18,6 +18,10 @@
18
18
  },
19
19
  "popularityRank": 87,
20
20
  "estimatedSetupMinutes": 6,
21
+ "triggers": [
22
+ "cron",
23
+ "event"
24
+ ],
21
25
  "skill": "ticket-to-code-change",
22
26
  "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."
23
27
  }
@@ -18,6 +18,10 @@
18
18
  },
19
19
  "popularityRank": 89,
20
20
  "estimatedSetupMinutes": 6,
21
+ "triggers": [
22
+ "cron",
23
+ "event"
24
+ ],
21
25
  "skill": "ticket-to-code-change",
22
26
  "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."
23
27
  }
@@ -18,6 +18,10 @@
18
18
  },
19
19
  "popularityRank": 88,
20
20
  "estimatedSetupMinutes": 6,
21
+ "triggers": [
22
+ "cron",
23
+ "event"
24
+ ],
21
25
  "skill": "ticket-to-code-change",
22
26
  "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."
23
27
  }
@@ -12,6 +12,10 @@
12
12
  },
13
13
  "popularityRank": 90,
14
14
  "estimatedSetupMinutes": 3,
15
+ "triggers": [
16
+ "cron",
17
+ "event"
18
+ ],
15
19
  "skill": "linear-triage",
16
20
  "exampleImplementation": "Trigger: linear Issue create\nRequired MCP: Linear\n\n1. Load the new issue, team context, project, labels, and recent similar issues.\n2. Score priority and classify the request as bug, feature, support, or chore.\n3. TODO: map local team labels and priority conventions.\n4. Add a triage comment with rationale and suggested next actions.\n5. Optionally update labels, priority, and assignee."
17
21
  }
@@ -15,6 +15,9 @@
15
15
  },
16
16
  "popularityRank": 84,
17
17
  "estimatedSetupMinutes": 7,
18
+ "triggers": [
19
+ "cron"
20
+ ],
18
21
  "skill": "research-brief",
19
22
  "exampleImplementation": "Trigger: cron, weekly or daily\nRequired MCPs: Tavily, Notion\n\n1. Run focused Tavily searches for configured topics and competitors.\n2. Deduplicate sources and rank them by freshness and authority.\n3. TODO: confirm citation format and destination Notion database schema.\n4. Write an executive summary, implications, and recommended actions.\n5. Create or update a Notion page with citations and source links."
20
23
  }
@@ -12,5 +12,8 @@
12
12
  },
13
13
  "popularityRank": 92,
14
14
  "estimatedSetupMinutes": 7,
15
+ "triggers": [
16
+ "cron"
17
+ ],
15
18
  "exampleImplementation": "Trigger: cron, every minute\nRequired secret: SLACK_BOT_TOKEN or SLACK_USER_TOKEN\n\n1. Poll up to 10 configured Slack channels for new messages since the last run.\n2. Match messages containing the trigger phrase (default: @openhands).\n3. React with 👀 and start an OpenHands conversation with the message and recent channel context.\n4. Post a thread reply with a link to the conversation, then post the agent response when done.\n5. Keep the thread in a short follow-up watch window and forward only replies that repeat the trigger phrase to the same conversation, with per-thread backoff for quiet threads."
16
19
  }
@@ -12,5 +12,8 @@
12
12
  },
13
13
  "popularityRank": 94,
14
14
  "estimatedSetupMinutes": 5,
15
+ "triggers": [
16
+ "cron"
17
+ ],
15
18
  "exampleImplementation": "Trigger: cron, weekday mornings\nRequired MCP: Slack\n\n1. Search configured channels for messages since the last digest window.\n2. Cluster updates into shipped work, active work, blockers, and decisions.\n3. TODO: choose a safe allowlist for channels and whether DMs are excluded.\n4. Draft a Slack message with owners, links, and unanswered questions.\n5. Optionally post to the configured standup channel."
16
19
  }