@openhands/extensions 0.24.0 → 0.26.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.
- package/.claude-plugin/marketplace.json +30 -0
- package/.codex-plugin/marketplace.json +30 -0
- package/.github/pull_request_template.md +17 -2
- package/.github/scripts/check_pr_description.py +64 -5
- package/.github/scripts/markdown_sections.py +95 -0
- package/.github/workflows/build-vscode-app.yml +30 -0
- package/.github/workflows/check-extensions.yml +3 -0
- package/.github/workflows/tests.yml +26 -0
- package/.plugin/marketplace.json +30 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +10 -3
- package/apps/README.md +20 -0
- package/apps/catalog.json +10 -0
- package/apps/index.d.ts +11 -0
- package/apps/index.js +5 -0
- package/apps/vscode/README.md +13 -0
- package/apps/vscode/canvas-extension.json +19 -0
- package/apps/vscode/extension.js +114 -0
- package/apps/vscode/scripts/package.mjs +84 -0
- package/automations/bundle-index.js +18 -14
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/automations/catalog-index.js +2 -0
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +30 -0
- package/package.json +9 -1
- package/plugins/openhands/skills/openhands-api/README.md +1 -1
- package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
- package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
- package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
- package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/README.md +59 -0
- package/plugins/runtime-api-configs/SKILL.md +240 -0
- package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
- package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/check_plugin_resources.py +116 -0
- package/scripts/sync_extensions.py +32 -5
- package/skills/canvas-extension-api/SKILL.md +34 -3
- package/skills/canvas-extension-api/references/backend-safety.md +14 -0
- package/skills/canvas-extension-api/references/connections.md +24 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
- package/skills/canvas-extension-api/references/v1-contract.md +53 -3
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
- package/skills/github/scripts/agent_conversation.py +223 -43
- package/skills/github/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/README.md +7 -0
- package/skills/github-agents-md-maintainer/SKILL.md +13 -6
- package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/worker.py +10 -1
- package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-triage/scripts/github_client.py +183 -15
- package/skills/github-pr-review/SKILL.md +12 -2
- package/skills/github-pr-reviewer/README.md +36 -0
- package/skills/github-pr-reviewer/SKILL.md +147 -4
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
- package/skills/github-pr-reviewer/scripts/main.py +17 -1
- package/skills/github-pr-reviewer/scripts/worker.py +842 -57
- package/skills/github-repo-monitor/scripts/github_client.py +183 -15
- package/skills/github-repo-monitor/scripts/main.py +32 -1
- package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/README.md +9 -0
- package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
- package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
- package/skills/gitlab-issue-to-mr/README.md +12 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
- package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
- package/skills/incident-retrospective/SKILL.md +5 -5
- package/skills/index.js +34 -16
- package/skills/jira-issue-to-pr/README.md +12 -4
- package/skills/jira-issue-to-pr/SKILL.md +129 -18
- package/skills/jira-issue-to-pr/references/setup.md +8 -0
- package/skills/jira-issue-to-pr/scripts/main.py +181 -54
- package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
- package/skills/linear-triage/SKILL.md +5 -5
- package/skills/news-digest/README.md +6 -0
- package/skills/news-digest/SKILL.md +19 -9
- package/skills/news-digest/commands/news-digest-setup.md +1 -1
- package/skills/news-digest/scripts/main.py +133 -15
- package/skills/openhands-api/README.md +1 -1
- package/skills/openhands-api/SKILL.md +3 -3
- package/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/skills/openhands-automation/SKILL.md +66 -4
- package/skills/openhands-automation/references/custom-automation.md +21 -7
- package/skills/openhands-sdk/SKILL.md +1 -0
- package/skills/research-brief/SKILL.md +5 -5
- package/skills/slack-standup-digest/SKILL.md +5 -5
- package/skills/ticket-to-code-change/SKILL.md +6 -3
- package/skills/use-jev/.claude-plugin/plugin.json +8 -0
- package/skills/use-jev/.codex-plugin/plugin.json +8 -0
- package/skills/use-jev/.plugin/plugin.json +8 -0
- package/skills/use-jev/README.md +66 -0
- package/skills/use-jev/SKILL.md +66 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
- package/tests/test_agent_conversation_dispatch.py +212 -0
- package/tests/test_apps_catalog.py +26 -0
- package/tests/test_automation_setup.py +44 -0
- package/tests/test_check_pr_description.py +89 -6
- package/tests/test_github_automation_foundation.py +158 -0
- package/tests/test_github_repo_monitor.py +107 -0
- package/tests/test_github_reviewer_delivery.py +2247 -6
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_jira_issue_to_pr.py +312 -0
- package/tests/test_jira_setup_webhook.py +309 -0
- package/tests/test_openhands_api_python.py +37 -0
- package/tests/test_skill_plugin_loading.py +25 -15
- package/tests/test_sync_extensions.py +102 -0
- package/tests/test_vscode_app.py +33 -0
- package/tests/vscode-app.test.mjs +43 -0
|
@@ -2,22 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import time
|
|
5
6
|
from urllib.error import HTTPError
|
|
6
7
|
from urllib.request import Request, urlopen
|
|
7
|
-
from uuid import NAMESPACE_URL, UUID, uuid5
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
from openhands.sdk
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
from openhands.sdk.
|
|
17
|
-
from openhands.sdk.
|
|
8
|
+
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import httpx
|
|
12
|
+
from openhands.sdk import RemoteConversation
|
|
13
|
+
from openhands.sdk.conversation.request import (
|
|
14
|
+
SendMessageRequest,
|
|
15
|
+
StartConversationRequest,
|
|
16
|
+
)
|
|
17
|
+
from openhands.sdk.conversation.state import ConversationExecutionStatus
|
|
18
|
+
from openhands.sdk.llm.message import TextContent
|
|
19
|
+
from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace
|
|
20
|
+
except ImportError:
|
|
21
|
+
# A run on OpenHands Cloud or Enterprise has no SDK on its Python path and
|
|
22
|
+
# does not need one: CloudConversations speaks the OpenHands API instead.
|
|
23
|
+
httpx = RemoteConversation = SendMessageRequest = None
|
|
24
|
+
StartConversationRequest = ConversationExecutionStatus = None
|
|
25
|
+
TextContent = LocalWorkspace = RemoteWorkspace = None
|
|
18
26
|
|
|
19
27
|
_CONVERSATION_KEY_PREFIX = "agent-conversation-"
|
|
20
28
|
|
|
29
|
+
# How long a conversation the OpenHands API was asked to start may take to
|
|
30
|
+
# appear before the start is taken to have failed and is made again.
|
|
31
|
+
_CLOUD_START_GRACE_SECONDS = 10 * 60
|
|
32
|
+
# How long to wait for a paused conversation's sandbox to come back up.
|
|
33
|
+
_CLOUD_RESUME_TIMEOUT_SECONDS = 120
|
|
34
|
+
_CLOUD_POLL_SECONDS = 3
|
|
35
|
+
|
|
21
36
|
|
|
22
37
|
def _register_tools() -> None:
|
|
23
38
|
"""Register tool models needed to deserialize an attached agent."""
|
|
@@ -50,19 +65,106 @@ def _kv_request(key: str, method: str, value: dict | None = None) -> dict | None
|
|
|
50
65
|
return body.get("value") if method == "GET" else body
|
|
51
66
|
|
|
52
67
|
|
|
68
|
+
class CloudConversations:
|
|
69
|
+
"""The conversations of a run on OpenHands Cloud or Enterprise.
|
|
70
|
+
|
|
71
|
+
Such a run has no Agent Server to attach to. The OpenHands API starts each
|
|
72
|
+
conversation in a sandbox of its own, which outlives the run's, with the
|
|
73
|
+
agent profile's model, tools and secrets resolved on the server.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
self.api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
|
|
78
|
+
self.headers = {
|
|
79
|
+
"Authorization": f"Bearer {os.environ['OPENHANDS_API_KEY']}",
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def _request(self, method: str, url: str, body=None, headers=None):
|
|
84
|
+
request = Request(
|
|
85
|
+
url if url.startswith("http") else f"{self.api}{url}",
|
|
86
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
87
|
+
headers=headers or self.headers,
|
|
88
|
+
method=method,
|
|
89
|
+
)
|
|
90
|
+
with urlopen(request, timeout=90) as response:
|
|
91
|
+
raw = response.read()
|
|
92
|
+
return json.loads(raw) if raw.strip() else {}
|
|
93
|
+
|
|
94
|
+
def get(self, conversation_id) -> dict | None:
|
|
95
|
+
"""The conversation, or None while the API has no record of it."""
|
|
96
|
+
found = self._request("GET", f"/api/v1/app-conversations?ids={conversation_id}")
|
|
97
|
+
return found[0] if found else None
|
|
98
|
+
|
|
99
|
+
def start(self, conversation_id, profile_id, title: str, prompt: str) -> None:
|
|
100
|
+
self._request(
|
|
101
|
+
"POST",
|
|
102
|
+
"/api/v1/app-conversations",
|
|
103
|
+
{
|
|
104
|
+
"conversation_id": str(conversation_id),
|
|
105
|
+
"agent_profile_id": str(profile_id),
|
|
106
|
+
"title": title,
|
|
107
|
+
"initial_message": _user_message(prompt),
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def run(self, conversation: dict) -> None:
|
|
112
|
+
"""Start the next turn of an idle conversation on its own Agent Server."""
|
|
113
|
+
try:
|
|
114
|
+
self._request(
|
|
115
|
+
"POST",
|
|
116
|
+
f"{conversation['conversation_url']}/run",
|
|
117
|
+
headers={"X-Session-API-Key": conversation["session_api_key"]},
|
|
118
|
+
)
|
|
119
|
+
except HTTPError as exc:
|
|
120
|
+
if exc.code != 409: # 409: a turn is already running
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
def send(self, conversation: dict, prompt: str) -> None:
|
|
124
|
+
"""Send the next turn, waking the conversation's sandbox if it is paused."""
|
|
125
|
+
conversation_id = conversation["id"]
|
|
126
|
+
if conversation.get("sandbox_status") != "RUNNING":
|
|
127
|
+
self._request(
|
|
128
|
+
"POST", f"/api/v1/sandboxes/{conversation['sandbox_id']}/resume"
|
|
129
|
+
)
|
|
130
|
+
deadline = time.monotonic() + _CLOUD_RESUME_TIMEOUT_SECONDS
|
|
131
|
+
# The execution status is only reported once the sandbox's Agent
|
|
132
|
+
# Server answers, which is when it can take the message.
|
|
133
|
+
while not (self.get(conversation_id) or {}).get("execution_status"):
|
|
134
|
+
if time.monotonic() > deadline:
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
f"Conversation {conversation_id} did not resume in time"
|
|
137
|
+
)
|
|
138
|
+
time.sleep(_CLOUD_POLL_SECONDS)
|
|
139
|
+
self._request(
|
|
140
|
+
"POST",
|
|
141
|
+
f"/api/v1/app-conversations/{conversation_id}/send-message",
|
|
142
|
+
{**_user_message(prompt), "run": True},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _user_message(text: str) -> dict:
|
|
147
|
+
return {"role": "user", "content": [{"type": "text", "text": text}]}
|
|
148
|
+
|
|
149
|
+
|
|
53
150
|
class AgentConversationDispatcher:
|
|
54
151
|
"""Deliver one revision at a time to a stable conversation for each subject."""
|
|
55
152
|
|
|
56
153
|
def __init__(self) -> None:
|
|
57
|
-
|
|
154
|
+
# The automation service hands a run the Agent Server URL only on a
|
|
155
|
+
# local Agent Canvas; elsewhere conversations go through the OpenHands API.
|
|
156
|
+
self.agent_url = os.environ.get("AGENT_SERVER_URL")
|
|
58
157
|
self.api_key = os.environ["SESSION_API_KEY"]
|
|
59
158
|
self.profile_id = UUID(os.environ["AUTOMATION_AGENT_PROFILE_ID"])
|
|
60
159
|
payload = json.loads(os.environ["AUTOMATION_EVENT_PAYLOAD"])
|
|
61
160
|
self.automation_id = str(payload["automation_id"])
|
|
161
|
+
self._cloud = None if self.agent_url else CloudConversations()
|
|
62
162
|
self._workspace: RemoteWorkspace | None = None
|
|
63
163
|
self._secrets = {}
|
|
64
164
|
|
|
65
165
|
def __enter__(self):
|
|
166
|
+
if self._cloud:
|
|
167
|
+
return self
|
|
66
168
|
_register_tools()
|
|
67
169
|
self._workspace = RemoteWorkspace(
|
|
68
170
|
host=self.agent_url,
|
|
@@ -76,17 +178,107 @@ class AgentConversationDispatcher:
|
|
|
76
178
|
return self
|
|
77
179
|
|
|
78
180
|
def __exit__(self, *args):
|
|
181
|
+
if self._cloud:
|
|
182
|
+
return None
|
|
79
183
|
assert self._workspace is not None
|
|
80
184
|
return self._workspace.__exit__(*args)
|
|
81
185
|
|
|
82
|
-
def deliver(
|
|
186
|
+
def deliver(
|
|
187
|
+
self, subject: str, delivery: str, prompt: str, head: str = ""
|
|
188
|
+
) -> dict[str, str]:
|
|
189
|
+
"""Deliver one revision of `subject`, deduping a repeated head.
|
|
190
|
+
|
|
191
|
+
`delivery` keys the revision: a second trigger for the same revision
|
|
192
|
+
reuses the conversation without a new turn, and a changed head becomes a
|
|
193
|
+
new delivery. `head` is the revision's commit, and it is the guard that
|
|
194
|
+
matters when two triggers name the same commit - a re-request after the
|
|
195
|
+
bot's own handoff, say - because a delivery key that only counts triggers
|
|
196
|
+
would send a second turn and publish a second review of identical code.
|
|
197
|
+
A conversation already running for this head is reported `in_progress`
|
|
198
|
+
and left alone, whatever the new delivery says. Callers with no revision
|
|
199
|
+
identity leave `head` empty and keep the delivery-key-only behavior.
|
|
200
|
+
"""
|
|
83
201
|
conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
|
|
84
202
|
state_key = f"{_CONVERSATION_KEY_PREFIX}{conversation_id}"
|
|
85
203
|
record = _kv_request(state_key, "GET") or {}
|
|
86
|
-
if self._workspace is None:
|
|
87
|
-
raise RuntimeError("AgentConversationDispatcher must be used as a context")
|
|
88
204
|
|
|
89
205
|
same_delivery = record.get("delivery") == delivery
|
|
206
|
+
same_head = bool(head) and record.get("head") == head
|
|
207
|
+
|
|
208
|
+
if self._cloud:
|
|
209
|
+
disposition, conversation_id = self._deliver_cloud(
|
|
210
|
+
record, conversation_id, subject, prompt, same_delivery, same_head
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
disposition = self._deliver_local(
|
|
214
|
+
conversation_id, prompt, same_delivery, same_head
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if disposition in ("deduplicated", "in_progress"):
|
|
218
|
+
return {
|
|
219
|
+
"disposition": disposition,
|
|
220
|
+
"conversation_id": str(conversation_id),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
new_record = {
|
|
224
|
+
"subject": subject,
|
|
225
|
+
"conversation_id": str(conversation_id),
|
|
226
|
+
"delivery": delivery,
|
|
227
|
+
"head": head or record.get("head") or "",
|
|
228
|
+
}
|
|
229
|
+
if disposition == "created" and self._cloud:
|
|
230
|
+
new_record["started_at"] = time.time()
|
|
231
|
+
_kv_request(state_key, "PUT", new_record)
|
|
232
|
+
return {
|
|
233
|
+
"disposition": disposition,
|
|
234
|
+
"conversation_id": str(conversation_id),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def _deliver_cloud(
|
|
238
|
+
self, record, conversation_id, subject, prompt, same_delivery, same_head
|
|
239
|
+
):
|
|
240
|
+
"""Deliver through the OpenHands API; return the disposition and the id.
|
|
241
|
+
|
|
242
|
+
The id is the subject's stable one until its conversation can no longer
|
|
243
|
+
take a turn - its start never completed, or its sandbox is gone - and a
|
|
244
|
+
fresh id from then on, because the API does not start an id twice. The
|
|
245
|
+
KV record carries whichever is current.
|
|
246
|
+
"""
|
|
247
|
+
current_id = record.get("conversation_id") or conversation_id
|
|
248
|
+
conversation = self._cloud.get(current_id)
|
|
249
|
+
|
|
250
|
+
if conversation is None:
|
|
251
|
+
started = time.time() - float(record.get("started_at") or 0)
|
|
252
|
+
if started < _CLOUD_START_GRACE_SECONDS:
|
|
253
|
+
# The API is still bringing the subject's conversation up and
|
|
254
|
+
# does not list it yet. Starting another would run two for one
|
|
255
|
+
# subject, so even a new revision waits: the record is left as
|
|
256
|
+
# it is and the next trigger delivers it.
|
|
257
|
+
return "in_progress", current_id
|
|
258
|
+
elif conversation.get("sandbox_status") in ("ERROR", "MISSING"):
|
|
259
|
+
if same_delivery:
|
|
260
|
+
return "deduplicated", current_id
|
|
261
|
+
conversation = None
|
|
262
|
+
|
|
263
|
+
if conversation is None:
|
|
264
|
+
new_id = uuid4() if record else conversation_id
|
|
265
|
+
self._cloud.start(new_id, self.profile_id, subject, prompt)
|
|
266
|
+
return "created", new_id
|
|
267
|
+
|
|
268
|
+
status = conversation.get("execution_status")
|
|
269
|
+
if status == "running" and (same_delivery or same_head):
|
|
270
|
+
return "in_progress", current_id
|
|
271
|
+
if same_delivery:
|
|
272
|
+
if status in ("idle", "paused"):
|
|
273
|
+
self._cloud.run(conversation)
|
|
274
|
+
return "resumed", current_id
|
|
275
|
+
return "deduplicated", current_id
|
|
276
|
+
self._cloud.send(conversation, prompt)
|
|
277
|
+
return "resumed", current_id
|
|
278
|
+
|
|
279
|
+
def _deliver_local(self, conversation_id, prompt, same_delivery, same_head) -> str:
|
|
280
|
+
if self._workspace is None:
|
|
281
|
+
raise RuntimeError("AgentConversationDispatcher must be used as a context")
|
|
90
282
|
|
|
91
283
|
try:
|
|
92
284
|
conversation = RemoteConversation.attach(
|
|
@@ -112,14 +304,18 @@ class AgentConversationDispatcher:
|
|
|
112
304
|
disposition = "created"
|
|
113
305
|
try:
|
|
114
306
|
if disposition == "resumed":
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
307
|
+
running = (
|
|
308
|
+
conversation.state.execution_status
|
|
309
|
+
== ConversationExecutionStatus.RUNNING
|
|
310
|
+
)
|
|
311
|
+
if running and (same_delivery or same_head):
|
|
312
|
+
# A turn is already running for this very revision, so a new
|
|
313
|
+
# trigger is not new work: report the live conversation
|
|
314
|
+
# rather than starting a second pass beside it.
|
|
315
|
+
conversation.update_secrets(self._secrets)
|
|
316
|
+
disposition = "in_progress"
|
|
317
|
+
elif same_delivery:
|
|
318
|
+
if conversation.state.execution_status in (
|
|
123
319
|
ConversationExecutionStatus.IDLE,
|
|
124
320
|
ConversationExecutionStatus.PAUSED,
|
|
125
321
|
):
|
|
@@ -128,28 +324,12 @@ class AgentConversationDispatcher:
|
|
|
128
324
|
else:
|
|
129
325
|
disposition = "deduplicated"
|
|
130
326
|
else:
|
|
327
|
+
# A changed head, or a head a human clarified since, is new
|
|
328
|
+
# work on the same subject: reuse the conversation and send
|
|
329
|
+
# the revision as its next turn.
|
|
131
330
|
conversation.update_secrets(self._secrets)
|
|
132
331
|
conversation.send_message(prompt)
|
|
133
332
|
conversation.run(blocking=False)
|
|
134
333
|
finally:
|
|
135
334
|
conversation.close()
|
|
136
|
-
|
|
137
|
-
if disposition in ("deduplicated", "in_progress"):
|
|
138
|
-
return {
|
|
139
|
-
"disposition": disposition,
|
|
140
|
-
"conversation_id": str(conversation_id),
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
_kv_request(
|
|
144
|
-
state_key,
|
|
145
|
-
"PUT",
|
|
146
|
-
{
|
|
147
|
-
"subject": subject,
|
|
148
|
-
"conversation_id": str(conversation_id),
|
|
149
|
-
"delivery": delivery,
|
|
150
|
-
},
|
|
151
|
-
)
|
|
152
|
-
return {
|
|
153
|
-
"disposition": disposition,
|
|
154
|
-
"conversation_id": str(conversation_id),
|
|
155
|
-
}
|
|
335
|
+
return disposition
|
|
@@ -12,24 +12,62 @@ from urllib.parse import parse_qsl, urlencode, urlsplit
|
|
|
12
12
|
from urllib.request import Request, urlopen
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
# The secret name of the GitHub token an OpenHands Cloud or Enterprise user's
|
|
16
|
+
# connected GitHub integration provides: how this run's sandbox serves it, and
|
|
17
|
+
# how a conversation sees it in its environment.
|
|
18
|
+
CLOUD_GITHUB_TOKEN_SECRET = "github_token"
|
|
19
|
+
CLOUD_GITHUB_TOKEN_ENV = "GITHUB_TOKEN"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_cloud_run() -> bool:
|
|
23
|
+
"""Whether this run is on OpenHands Cloud or Enterprise.
|
|
24
|
+
|
|
25
|
+
The automation service hands a run the Agent Server URL only on a local
|
|
26
|
+
Agent Canvas; elsewhere the run talks to the OpenHands API instead.
|
|
27
|
+
"""
|
|
28
|
+
return bool(os.environ.get("OPENHANDS_CLOUD_API_URL")) and not os.environ.get(
|
|
29
|
+
"AGENT_SERVER_URL"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_cloud_secret(name: str) -> str | None:
|
|
34
|
+
"""Read one named secret of this run's sandbox from the OpenHands API."""
|
|
35
|
+
api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
|
|
36
|
+
request = Request(
|
|
37
|
+
f"{api}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}",
|
|
38
|
+
headers={"X-Session-API-Key": os.environ["SESSION_API_KEY"]},
|
|
39
|
+
)
|
|
40
|
+
try:
|
|
41
|
+
with urlopen(request, timeout=90) as response:
|
|
42
|
+
return response.read().decode().strip()
|
|
43
|
+
except HTTPError as exc:
|
|
44
|
+
if exc.code == 404:
|
|
45
|
+
return None
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
|
|
15
49
|
def _load_secret(name: str) -> str:
|
|
16
|
-
"""Read one named secret from the environment or configured Agent
|
|
50
|
+
"""Read one named secret from the environment, or from the configured Agent
|
|
51
|
+
Server on a local run and the OpenHands API on a cloud one."""
|
|
17
52
|
value = os.environ.get(name)
|
|
18
53
|
if value:
|
|
19
54
|
return value
|
|
20
55
|
|
|
21
|
-
|
|
56
|
+
if is_cloud_run():
|
|
57
|
+
value = _load_cloud_secret(name)
|
|
58
|
+
else:
|
|
59
|
+
from openhands.sdk.workspace import RemoteWorkspace
|
|
22
60
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
61
|
+
workspace = RemoteWorkspace(
|
|
62
|
+
host=os.environ["AGENT_SERVER_URL"],
|
|
63
|
+
api_key=os.environ["SESSION_API_KEY"],
|
|
64
|
+
working_dir=os.environ.get("WORKSPACE_BASE", "/workspace"),
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
secret = workspace.get_secrets([name]).get(name)
|
|
68
|
+
value = secret.get_value() if secret else None
|
|
69
|
+
finally:
|
|
70
|
+
workspace.reset_client()
|
|
33
71
|
if not value:
|
|
34
72
|
raise ValueError(f"The GitHub credential {name} is unavailable")
|
|
35
73
|
return value
|
|
@@ -95,11 +133,22 @@ class GitHubRepository:
|
|
|
95
133
|
"Expected the environment variable containing the GitHub token"
|
|
96
134
|
)
|
|
97
135
|
self.token_name = github_token_secret
|
|
98
|
-
|
|
136
|
+
try:
|
|
137
|
+
self.token = _load_secret(github_token_secret)
|
|
138
|
+
except ValueError:
|
|
139
|
+
if not is_cloud_run():
|
|
140
|
+
raise
|
|
141
|
+
# No secret is saved under that name, so use the user's connected
|
|
142
|
+
# GitHub integration, under the name a conversation sees it by.
|
|
143
|
+
self.token = _load_cloud_secret(CLOUD_GITHUB_TOKEN_SECRET)
|
|
144
|
+
if not self.token:
|
|
145
|
+
raise
|
|
146
|
+
self.token_name = CLOUD_GITHUB_TOKEN_ENV
|
|
99
147
|
self.conversation = conversation
|
|
100
148
|
self.conversation_id = str(conversation.id) if conversation else None
|
|
101
149
|
self.dispatcher = dispatcher
|
|
102
|
-
|
|
150
|
+
# A cloud run is not given a workspace base; its sandbox has /workspace.
|
|
151
|
+
self.workspace = Path(os.environ.get("WORKSPACE_BASE", "/workspace"))
|
|
103
152
|
self.project = self.workspace
|
|
104
153
|
self.evidence = self.workspace / "evidence"
|
|
105
154
|
self.evidence.mkdir(exist_ok=True)
|
|
@@ -111,10 +160,13 @@ class GitHubRepository:
|
|
|
111
160
|
|
|
112
161
|
@property
|
|
113
162
|
def github_instructions(self):
|
|
163
|
+
# A cloud conversation runs in a sandbox of its own, so this run's
|
|
164
|
+
# workspace path means nothing to it.
|
|
165
|
+
where = "your working directory" if is_cloud_run() else self.project
|
|
114
166
|
return (
|
|
115
167
|
f"Use `GH_TOKEN=${self.token_name} gh api` for GitHub requests. "
|
|
116
168
|
"Never print the credential value. "
|
|
117
|
-
f"Only {self.repository} is in scope. Work in {
|
|
169
|
+
f"Only {self.repository} is in scope. Work in {where}. "
|
|
118
170
|
"Do not modify the automation bundle or its configuration."
|
|
119
171
|
)
|
|
120
172
|
|
|
@@ -165,6 +217,122 @@ class GitHubRepository:
|
|
|
165
217
|
result.setdefault(item["context"], item["state"])
|
|
166
218
|
return result
|
|
167
219
|
|
|
220
|
+
def check_runs(self, sha):
|
|
221
|
+
"""Return every check run GitHub reported for one commit SHA.
|
|
222
|
+
|
|
223
|
+
Reading check runs needs no branch-protection or ruleset access, so this
|
|
224
|
+
is the head-eligibility signal the automation's own token can always
|
|
225
|
+
see. The endpoint answers with an object rather than a list, so it
|
|
226
|
+
paginates through ``gh`` instead of ``gh_pages``.
|
|
227
|
+
"""
|
|
228
|
+
runs = []
|
|
229
|
+
for page in range(1, 101):
|
|
230
|
+
data = self.gh(
|
|
231
|
+
"GET", f"/commits/{sha}/check-runs?per_page=100&page={page}"
|
|
232
|
+
)
|
|
233
|
+
batch = data.get("check_runs") or []
|
|
234
|
+
runs.extend(batch)
|
|
235
|
+
if len(runs) >= int(data.get("total_count") or 0) or not batch:
|
|
236
|
+
return runs
|
|
237
|
+
raise RuntimeError("GitHub check-run pagination exceeded limit")
|
|
238
|
+
|
|
239
|
+
def required_check_contexts(self, number):
|
|
240
|
+
"""Return the status contexts GitHub marks required on one pull request.
|
|
241
|
+
|
|
242
|
+
`isRequired` is the merge-policy source of truth and is PR-scoped, so it
|
|
243
|
+
is correct for a stacked PR whose symbolic base branch carries no branch
|
|
244
|
+
rules of its own. It is a field on each context in the head's status
|
|
245
|
+
check rollup and takes the pull request number, so a single GraphQL query
|
|
246
|
+
answers with the required check runs and any required commit statuses,
|
|
247
|
+
each already attributed to the exact head. Only contexts GitHub itself
|
|
248
|
+
reports as required are returned, so an optional workflow that fails
|
|
249
|
+
before creating any check run is not present.
|
|
250
|
+
"""
|
|
251
|
+
owner, name = self.repository.split("/", 1)
|
|
252
|
+
query = (
|
|
253
|
+
"query($owner:String!,$name:String!,$number:Int!){"
|
|
254
|
+
"repository(owner:$owner,name:$name){"
|
|
255
|
+
"pullRequest(number:$number){"
|
|
256
|
+
"commits(last:1){"
|
|
257
|
+
"nodes{"
|
|
258
|
+
"commit{"
|
|
259
|
+
"statusCheckRollup{"
|
|
260
|
+
"contexts(first:100){"
|
|
261
|
+
"pageInfo{hasNextPage}"
|
|
262
|
+
"nodes{"
|
|
263
|
+
"__typename"
|
|
264
|
+
" ... on CheckRun{name isRequired(pullRequestNumber:$number)}"
|
|
265
|
+
" ... on StatusContext{context isRequired(pullRequestNumber:$number)}"
|
|
266
|
+
"}"
|
|
267
|
+
"}"
|
|
268
|
+
"}"
|
|
269
|
+
"}"
|
|
270
|
+
"}"
|
|
271
|
+
"}"
|
|
272
|
+
"}"
|
|
273
|
+
"}"
|
|
274
|
+
"}"
|
|
275
|
+
)
|
|
276
|
+
data = self.api(
|
|
277
|
+
"POST",
|
|
278
|
+
"/graphql",
|
|
279
|
+
body={
|
|
280
|
+
"query": query,
|
|
281
|
+
"variables": {"owner": owner, "name": name, "number": number},
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
if data.get("errors"):
|
|
285
|
+
raise RuntimeError(
|
|
286
|
+
f"GitHub required-check query failed: {data['errors'][0].get('message')}"
|
|
287
|
+
)
|
|
288
|
+
nodes = (
|
|
289
|
+
(((data.get("data") or {}).get("repository") or {}).get("pullRequest") or {})
|
|
290
|
+
.get("commits", {})
|
|
291
|
+
.get("nodes")
|
|
292
|
+
) or []
|
|
293
|
+
contexts = (
|
|
294
|
+
((nodes[0].get("commit") or {}).get("statusCheckRollup") or {}).get(
|
|
295
|
+
"contexts", {}
|
|
296
|
+
)
|
|
297
|
+
if nodes
|
|
298
|
+
else {}
|
|
299
|
+
)
|
|
300
|
+
if (contexts.get("pageInfo") or {}).get("hasNextPage"):
|
|
301
|
+
raise RuntimeError(
|
|
302
|
+
"GitHub required-check rollup exceeds one page; the required "
|
|
303
|
+
"set may be incomplete"
|
|
304
|
+
)
|
|
305
|
+
return [
|
|
306
|
+
{
|
|
307
|
+
"name": node.get("name") or node.get("context"),
|
|
308
|
+
"kind": node.get("__typename"),
|
|
309
|
+
}
|
|
310
|
+
for node in (contexts.get("nodes") or [])
|
|
311
|
+
if node.get("isRequired") and (node.get("name") or node.get("context"))
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
def workflow_runs(self, sha):
|
|
315
|
+
"""Return every Actions workflow run GitHub reported for one commit SHA.
|
|
316
|
+
|
|
317
|
+
A workflow that fails before any job starts - a workflow-level error, or
|
|
318
|
+
a `pull_request` run whose jobs never materialize - still records a
|
|
319
|
+
failed check suite, but it contributes no check runs, so the commit's
|
|
320
|
+
check-run rollup and `gh pr checks` both report success. Reading the
|
|
321
|
+
workflow runs directly is the only way the gate can see that failure.
|
|
322
|
+
Like the check-run endpoint this answers with an object, so it paginates
|
|
323
|
+
manually.
|
|
324
|
+
"""
|
|
325
|
+
runs = []
|
|
326
|
+
for page in range(1, 101):
|
|
327
|
+
data = self.gh(
|
|
328
|
+
"GET", f"/actions/runs?head_sha={sha}&per_page=100&page={page}"
|
|
329
|
+
)
|
|
330
|
+
batch = data.get("workflow_runs") or []
|
|
331
|
+
runs.extend(batch)
|
|
332
|
+
if len(runs) >= int(data.get("total_count") or 0) or not batch:
|
|
333
|
+
return runs
|
|
334
|
+
raise RuntimeError("GitHub workflow-run pagination exceeded limit")
|
|
335
|
+
|
|
168
336
|
def completed_dependency(self, number):
|
|
169
337
|
if number in self._completed_dependencies:
|
|
170
338
|
return self._completed_dependencies[number]
|
|
@@ -51,6 +51,12 @@ REVIEW_STYLE_INSTRUCTIONS = ""
|
|
|
51
51
|
# than relying on the spawned agent's skill activation. Set to "" to disable.
|
|
52
52
|
REPO_REVIEW_GUIDE_PATH = ".agents/skills/custom-codereview-guide.md"
|
|
53
53
|
DEFAULT_OPENHANDS_URL = "http://localhost:8000"
|
|
54
|
+
# The most new review conversations one scheduled scan may start, counted across
|
|
55
|
+
# every configured repository rather than per repository. The scheduled scan
|
|
56
|
+
# drains outstanding reviewer requests in oldest-request order, so a small bound
|
|
57
|
+
# keeps a first scan over a large backlog from starting an agent for every
|
|
58
|
+
# eligible pull request at once.
|
|
59
|
+
MAX_NEW_PER_RUN = 2
|
|
54
60
|
|
|
55
61
|
# A review that ends with this marker is a scope stop: the reviewer found the
|
|
56
62
|
# change out of scope, or needing a product/architecture decision, before the
|
|
@@ -69,6 +75,7 @@ _CONFIG_TYPES: dict[str, type] = {
|
|
|
69
75
|
"review_tone": str,
|
|
70
76
|
"review_style_instructions": str,
|
|
71
77
|
"repo_review_guide_path": str,
|
|
78
|
+
"max_new_per_run": int,
|
|
72
79
|
"openhands_url": str,
|
|
73
80
|
}
|
|
74
81
|
|
|
@@ -95,7 +102,11 @@ def load_config(directory: Path | None = None) -> dict:
|
|
|
95
102
|
if key not in raw:
|
|
96
103
|
continue
|
|
97
104
|
value = raw[key]
|
|
98
|
-
|
|
105
|
+
# bool is an int in Python, so an unguarded int check would accept
|
|
106
|
+
# `"max_new_per_run": true` and then start `True` conversations.
|
|
107
|
+
if not isinstance(value, expected) or (
|
|
108
|
+
expected is int and isinstance(value, bool)
|
|
109
|
+
):
|
|
99
110
|
raise SystemExit(
|
|
100
111
|
f"{CONFIG_FILENAME}: {key} must be {expected.__name__}, "
|
|
101
112
|
f"got {type(value).__name__}"
|
|
@@ -106,6 +117,10 @@ def load_config(directory: Path | None = None) -> dict:
|
|
|
106
117
|
raise SystemExit(
|
|
107
118
|
f'{CONFIG_FILENAME}: repos must be a non-empty list of "owner/repo" strings'
|
|
108
119
|
)
|
|
120
|
+
if key == "max_new_per_run" and value < 1:
|
|
121
|
+
raise SystemExit(
|
|
122
|
+
f"{CONFIG_FILENAME}: max_new_per_run must be at least 1"
|
|
123
|
+
)
|
|
109
124
|
config[key] = value
|
|
110
125
|
return config
|
|
111
126
|
|
|
@@ -150,6 +165,7 @@ TRIGGER_LABEL = _CONFIG.get("trigger_label", TRIGGER_LABEL)
|
|
|
150
165
|
REVIEW_TONE = _CONFIG.get("review_tone", REVIEW_TONE)
|
|
151
166
|
REVIEW_STYLE_INSTRUCTIONS = _CONFIG.get("review_style_instructions", REVIEW_STYLE_INSTRUCTIONS)
|
|
152
167
|
REPO_REVIEW_GUIDE_PATH = _CONFIG.get("repo_review_guide_path", REPO_REVIEW_GUIDE_PATH)
|
|
168
|
+
MAX_NEW_PER_RUN = _CONFIG.get("max_new_per_run", MAX_NEW_PER_RUN)
|
|
153
169
|
DEFAULT_OPENHANDS_URL = _CONFIG.get("openhands_url", DEFAULT_OPENHANDS_URL)
|
|
154
170
|
|
|
155
171
|
DONE_DEBOUNCE = 15
|