@openhands/extensions 0.25.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 +24 -0
- package/.codex-plugin/marketplace.json +24 -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 +24 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +9 -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 +15 -15
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +24 -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/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 +67 -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 +67 -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 +67 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +67 -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 +67 -15
- package/skills/github-pr-reviewer/README.md +5 -4
- package/skills/github-pr-reviewer/SKILL.md +8 -17
- package/skills/github-pr-reviewer/references/state-schema.md +0 -15
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
- package/skills/github-pr-reviewer/scripts/worker.py +131 -108
- package/skills/github-repo-monitor/scripts/github_client.py +67 -15
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
- 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 +23 -14
- 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/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 +9 -9
- 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_reviewer_delivery.py +254 -118
- 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
|
@@ -19,6 +19,11 @@ asks GitLab whether the merge request exists. If it does not - the agent gave up
|
|
|
19
19
|
errored, or its push failed - the script commits whatever was left, pushes, and
|
|
20
20
|
opens the merge request itself. Either way it comments on the issue and removes
|
|
21
21
|
the clone.
|
|
22
|
+
|
|
23
|
+
On OpenHands Cloud and Enterprise each conversation runs in a sandbox of its own
|
|
24
|
+
rather than in a clone this script keeps, so the agent clones the project itself
|
|
25
|
+
and a conversation that ends without a merge request is reported on the issue
|
|
26
|
+
instead of being finished here.
|
|
22
27
|
"""
|
|
23
28
|
|
|
24
29
|
import base64
|
|
@@ -31,6 +36,7 @@ import sys
|
|
|
31
36
|
import time
|
|
32
37
|
import urllib.error
|
|
33
38
|
import urllib.request
|
|
39
|
+
import uuid
|
|
34
40
|
from collections.abc import Callable
|
|
35
41
|
from pathlib import Path
|
|
36
42
|
from urllib.parse import quote, urlencode
|
|
@@ -222,17 +228,28 @@ MAX_MR_BODY_CHARS = 50000
|
|
|
222
228
|
DRAFT_TITLE_PREFIX = "Draft: "
|
|
223
229
|
|
|
224
230
|
|
|
231
|
+
# A local Agent Canvas run is handed the Agent Server URL. A run on OpenHands
|
|
232
|
+
# Cloud or Enterprise is handed the OpenHands API instead.
|
|
233
|
+
_CLOUD_API_URL = os.environ.get("OPENHANDS_CLOUD_API_URL", "").rstrip("/")
|
|
234
|
+
IS_CLOUD = bool(_CLOUD_API_URL) and not os.environ.get("AGENT_SERVER_URL")
|
|
235
|
+
# How a GitLab request carries the token. The token of a connected GitLab
|
|
236
|
+
# integration is an OAuth token, which GitLab only accepts as a bearer token; a
|
|
237
|
+
# personal access token is accepted either way.
|
|
238
|
+
_TOKEN_HEADER = "Authorization: Bearer" if IS_CLOUD else "PRIVATE-TOKEN:"
|
|
239
|
+
|
|
240
|
+
|
|
225
241
|
def _get_env_key() -> str:
|
|
226
242
|
return os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0") or ""
|
|
227
243
|
|
|
228
244
|
|
|
229
245
|
def get_secret(name: str) -> str:
|
|
230
|
-
url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
231
246
|
key = _get_env_key()
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
247
|
+
if IS_CLOUD:
|
|
248
|
+
# The OpenHands API serves the secrets of this run's sandbox.
|
|
249
|
+
url = f"{_CLOUD_API_URL}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}"
|
|
250
|
+
else:
|
|
251
|
+
url = f"{os.environ.get('AGENT_SERVER_URL', '').rstrip('/')}/api/settings/secrets/{name}"
|
|
252
|
+
req = urllib.request.Request(url, headers={"X-Session-API-Key": key})
|
|
236
253
|
with urllib.request.urlopen(req) as r:
|
|
237
254
|
return r.read().decode().strip()
|
|
238
255
|
|
|
@@ -250,12 +267,14 @@ def fire_callback(
|
|
|
250
267
|
body["error"] = error
|
|
251
268
|
if conversation_id:
|
|
252
269
|
body["conversation_id"] = conversation_id
|
|
270
|
+
# A cloud run is not given a callback key; it authenticates with its API key.
|
|
271
|
+
api_key = os.environ.get("AUTOMATION_CALLBACK_API_KEY") or os.environ.get("OPENHANDS_API_KEY", "")
|
|
253
272
|
req = urllib.request.Request(
|
|
254
273
|
url,
|
|
255
274
|
data=json.dumps(body).encode(),
|
|
256
275
|
headers={
|
|
257
276
|
"Content-Type": "application/json",
|
|
258
|
-
"Authorization": f"Bearer {
|
|
277
|
+
"Authorization": f"Bearer {api_key}",
|
|
259
278
|
},
|
|
260
279
|
)
|
|
261
280
|
try:
|
|
@@ -395,10 +414,13 @@ def _gitlab_request(
|
|
|
395
414
|
if params:
|
|
396
415
|
url = f"{url}?{urlencode(params)}"
|
|
397
416
|
headers = {
|
|
398
|
-
"PRIVATE-TOKEN": token,
|
|
399
417
|
"Accept": "application/json",
|
|
400
418
|
"Content-Type": "application/json",
|
|
401
419
|
}
|
|
420
|
+
if IS_CLOUD:
|
|
421
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
422
|
+
else:
|
|
423
|
+
headers["PRIVATE-TOKEN"] = token
|
|
402
424
|
data = json.dumps(body).encode() if body is not None else None
|
|
403
425
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
404
426
|
with urllib.request.urlopen(req) as r:
|
|
@@ -424,12 +446,15 @@ def _gitlab_paginate(token: str, path: str, params: dict | None = None) -> list:
|
|
|
424
446
|
|
|
425
447
|
|
|
426
448
|
def _resolve_gitlab_token() -> str:
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
449
|
+
# On a cloud run, a user with no GITLAB_TOKEN secret is served the token of
|
|
450
|
+
# their connected GitLab integration under the second name.
|
|
451
|
+
for name in ("GITLAB_TOKEN", "gitlab_token") if IS_CLOUD else ("GITLAB_TOKEN",):
|
|
452
|
+
try:
|
|
453
|
+
token = get_secret(name)
|
|
454
|
+
if token:
|
|
455
|
+
return token
|
|
456
|
+
except Exception:
|
|
457
|
+
pass
|
|
433
458
|
raise RuntimeError(
|
|
434
459
|
"GITLAB_TOKEN secret is not set. "
|
|
435
460
|
"Go to OpenHands Settings â Secrets and add your GitLab personal access token."
|
|
@@ -900,12 +925,95 @@ def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
|
|
|
900
925
|
return secrets
|
|
901
926
|
|
|
902
927
|
|
|
928
|
+
def _cloud_request(method: str, path: str, body: dict | None = None):
|
|
929
|
+
"""Call the OpenHands API with the API key this cloud run was given."""
|
|
930
|
+
req = urllib.request.Request(
|
|
931
|
+
f"{_CLOUD_API_URL}{path}",
|
|
932
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
933
|
+
headers={
|
|
934
|
+
"Authorization": f"Bearer {os.environ.get('OPENHANDS_API_KEY', '')}",
|
|
935
|
+
"Content-Type": "application/json",
|
|
936
|
+
},
|
|
937
|
+
method=method,
|
|
938
|
+
)
|
|
939
|
+
try:
|
|
940
|
+
with urllib.request.urlopen(req) as r:
|
|
941
|
+
raw = r.read()
|
|
942
|
+
return json.loads(raw) if raw.strip() else {}
|
|
943
|
+
except urllib.error.HTTPError as exc:
|
|
944
|
+
body_text = exc.read().decode()
|
|
945
|
+
raise RuntimeError(f"OpenHands API {method} {path} â {exc.code}: {body_text}") from exc
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _create_cloud_conversation(initial_message: str, title: str) -> str:
|
|
949
|
+
"""Start a conversation through the OpenHands API and return its id.
|
|
950
|
+
|
|
951
|
+
OpenHands runs it in a sandbox of its own, which outlives this run's, with
|
|
952
|
+
the user's own model, MCP servers, secrets and connected git provider.
|
|
953
|
+
"""
|
|
954
|
+
conversation_id = uuid.uuid4().hex
|
|
955
|
+
_cloud_request(
|
|
956
|
+
"POST",
|
|
957
|
+
"/api/v1/app-conversations",
|
|
958
|
+
{
|
|
959
|
+
"conversation_id": conversation_id,
|
|
960
|
+
"title": title,
|
|
961
|
+
"initial_message": {
|
|
962
|
+
"role": "user",
|
|
963
|
+
"content": [{"type": "text", "text": initial_message}],
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
)
|
|
967
|
+
return conversation_id
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _cloud_conversation_status(conv_id: str) -> str:
|
|
971
|
+
found = _cloud_request("GET", f"/api/v1/app-conversations?ids={conv_id}")
|
|
972
|
+
conversation = found[0] if found else None
|
|
973
|
+
if conversation is None:
|
|
974
|
+
# OpenHands is still bringing the conversation up.
|
|
975
|
+
return "starting"
|
|
976
|
+
if conversation.get("execution_status"):
|
|
977
|
+
return conversation["execution_status"]
|
|
978
|
+
# The status is only reported while the conversation's sandbox is running.
|
|
979
|
+
# Once it is paused, the last status the agent reported is in its events.
|
|
980
|
+
events = _cloud_request(
|
|
981
|
+
"GET",
|
|
982
|
+
f"/api/v1/conversation/{conv_id}/events/search"
|
|
983
|
+
"?kind__eq=ConversationStateUpdateEvent&sort_order=TIMESTAMP_DESC&limit=100",
|
|
984
|
+
)
|
|
985
|
+
for event in events.get("items", []):
|
|
986
|
+
if event.get("key") == "execution_status":
|
|
987
|
+
return event.get("value") or "unknown"
|
|
988
|
+
return "unknown"
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _cloud_conversation_final_response(conv_id: str) -> str:
|
|
992
|
+
"""The agent's closing message, read from the conversation's stored events."""
|
|
993
|
+
events = _cloud_request(
|
|
994
|
+
"GET",
|
|
995
|
+
f"/api/v1/conversation/{conv_id}/events/search?sort_order=TIMESTAMP_DESC&limit=100",
|
|
996
|
+
)
|
|
997
|
+
for event in events.get("items", []):
|
|
998
|
+
if event.get("source") != "agent":
|
|
999
|
+
continue
|
|
1000
|
+
if event.get("kind") == "ActionEvent" and event.get("tool_name") == "finish":
|
|
1001
|
+
return (event.get("action") or {}).get("message") or ""
|
|
1002
|
+
if event.get("kind") == "MessageEvent":
|
|
1003
|
+
content = (event.get("llm_message") or {}).get("content") or []
|
|
1004
|
+
return "".join(part.get("text", "") for part in content)
|
|
1005
|
+
return ""
|
|
1006
|
+
|
|
1007
|
+
|
|
903
1008
|
def create_conversation(
|
|
904
1009
|
agent_url: str,
|
|
905
1010
|
api_key: str,
|
|
906
1011
|
initial_message: str,
|
|
907
|
-
workspace_dir: Path,
|
|
1012
|
+
workspace_dir: Path | None,
|
|
1013
|
+
title: str = "",
|
|
908
1014
|
) -> str:
|
|
1015
|
+
if IS_CLOUD:
|
|
1016
|
+
return _create_cloud_conversation(initial_message, title)
|
|
909
1017
|
payload: dict = {
|
|
910
1018
|
"workspace": {"working_dir": str(workspace_dir)},
|
|
911
1019
|
"agent": _get_agent_dict(agent_url, api_key),
|
|
@@ -922,11 +1030,15 @@ def create_conversation(
|
|
|
922
1030
|
|
|
923
1031
|
|
|
924
1032
|
def conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:
|
|
1033
|
+
if IS_CLOUD:
|
|
1034
|
+
return _cloud_conversation_status(conv_id)
|
|
925
1035
|
result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}")
|
|
926
1036
|
return result.get("execution_status", "unknown")
|
|
927
1037
|
|
|
928
1038
|
|
|
929
1039
|
def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:
|
|
1040
|
+
if IS_CLOUD:
|
|
1041
|
+
return _cloud_conversation_final_response(conv_id)
|
|
930
1042
|
result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}/agent_final_response")
|
|
931
1043
|
return result.get("response", "")
|
|
932
1044
|
|
|
@@ -963,10 +1075,48 @@ def _build_implementation_prompt(
|
|
|
963
1075
|
draft_words = " as a draft" if DRAFT_MERGE_REQUEST else " ready for review"
|
|
964
1076
|
encoded = _project_id(project)
|
|
965
1077
|
mr_title = _merge_request_title(f"[#{iid}] {title}")
|
|
1078
|
+
remote = f"https://oauth2:$GITLAB_TOKEN@{_instance_url().split('://', 1)[1]}/{project}.git"
|
|
1079
|
+
|
|
1080
|
+
if IS_CLOUD:
|
|
1081
|
+
# The conversation runs in a sandbox of its own, so there is no clone to
|
|
1082
|
+
# hand it and none for this script to finish the job from afterwards.
|
|
1083
|
+
workplace = "a clone you make of the project"
|
|
1084
|
+
# OpenHands initializes a Git repository in a conversation's working
|
|
1085
|
+
# directory, so the project is cloned into a directory of its own.
|
|
1086
|
+
workspace = (
|
|
1087
|
+
f"- It holds no copy of the project. Clone `{base_branch}` into a new "
|
|
1088
|
+
"directory, work inside it, and open the branch the merge request comes "
|
|
1089
|
+
f"from: `git clone --depth 1 --branch {base_branch} \"{remote}\" repo "
|
|
1090
|
+
f"&& cd repo && git checkout -b {branch}`\n"
|
|
1091
|
+
"- Every command that talks to GitLab must name `GITLAB_TOKEN`, because "
|
|
1092
|
+
"the value is only put in the environment of a command that mentions it. "
|
|
1093
|
+
"Never echo it.\n"
|
|
1094
|
+
)
|
|
1095
|
+
on_failure = (
|
|
1096
|
+
"9. If pushing or opening the merge request fails, stop and say what "
|
|
1097
|
+
"failed. The automation checks GitLab for the merge request and reports "
|
|
1098
|
+
"on the issue when it is not there.\n"
|
|
1099
|
+
)
|
|
1100
|
+
else:
|
|
1101
|
+
workplace = "the project already checked out as your working directory"
|
|
1102
|
+
workspace = (
|
|
1103
|
+
f"- It is a clone of `{base_branch}` at `{base_sha}`, already on branch "
|
|
1104
|
+
f"`{branch}`. Do not clone or check out anything else: the code you need is "
|
|
1105
|
+
"already here, and the branch is the one the merge request comes from.\n"
|
|
1106
|
+
"- `origin` carries no credential. Every command that talks to GitLab must "
|
|
1107
|
+
"name `GITLAB_TOKEN`, because the value is only put in the environment of a "
|
|
1108
|
+
"command that mentions it. Never echo it.\n"
|
|
1109
|
+
)
|
|
1110
|
+
on_failure = (
|
|
1111
|
+
"9. If pushing or opening the merge request fails, stop and say so, leaving your "
|
|
1112
|
+
"work committed on the branch. The automation checks GitLab for the merge "
|
|
1113
|
+
"request and finishes the job itself when it is not there, so the work is never "
|
|
1114
|
+
"lost.\n"
|
|
1115
|
+
)
|
|
966
1116
|
|
|
967
1117
|
return (
|
|
968
1118
|
"You are an autonomous software engineer. Implement the GitLab issue below in "
|
|
969
|
-
"
|
|
1119
|
+
f"{workplace}.\n\n"
|
|
970
1120
|
f"Project : {project}\n"
|
|
971
1121
|
f"Issue : #{iid} - \"{title}\"\n"
|
|
972
1122
|
f"URL : {issue.get('web_url', '')}\n"
|
|
@@ -974,12 +1124,7 @@ def _build_implementation_prompt(
|
|
|
974
1124
|
f"Trigger : latest `{TRIGGER_LABEL}` label event {label_event.get('id', '?')} "
|
|
975
1125
|
f"at {label_event.get('created_at', '?')}\n\n"
|
|
976
1126
|
"Your workspace:\n"
|
|
977
|
-
f"
|
|
978
|
-
f"`{branch}`. Do not clone or check out anything else: the code you need is "
|
|
979
|
-
"already here, and the branch is the one the merge request comes from.\n"
|
|
980
|
-
"- `origin` carries no credential. Every command that talks to GitLab must "
|
|
981
|
-
"name `GITLAB_TOKEN`, because the value is only put in the environment of a "
|
|
982
|
-
"command that mentions it. Never echo it.\n"
|
|
1127
|
+
f"{workspace}"
|
|
983
1128
|
"- If GitLab tools from a connected MCP server are available to you, prefer "
|
|
984
1129
|
"them for reading the issue and for opening the merge request. The commands "
|
|
985
1130
|
"below are the fallback when they are not, and the git push is a git "
|
|
@@ -987,7 +1132,7 @@ def _build_implementation_prompt(
|
|
|
987
1132
|
"Required workflow:\n"
|
|
988
1133
|
"1. Read the issue first. Its title above is all you have been told; fetch the "
|
|
989
1134
|
"rest yourself:\n"
|
|
990
|
-
f" `curl -sH \"
|
|
1135
|
+
f" `curl -sH \"{_TOKEN_HEADER} $GITLAB_TOKEN\" "
|
|
991
1136
|
f"\"{GITLAB_API_URL}/projects/{encoded}/issues/{iid}\"` and the same path with "
|
|
992
1137
|
"`/notes` for the discussion. Never print the token.\n"
|
|
993
1138
|
"2. Follow what the issue points at as far as it matters: linked issues and "
|
|
@@ -1001,11 +1146,10 @@ def _build_implementation_prompt(
|
|
|
1001
1146
|
"6. Delete scratch files, build output, and virtualenvs the project does not "
|
|
1002
1147
|
f"already ignore, then commit everything on `{branch}`.\n"
|
|
1003
1148
|
"7. Push the branch:\n"
|
|
1004
|
-
f" `git push \"
|
|
1005
|
-
f"{project}.git\" HEAD:refs/heads/{branch}`\n"
|
|
1149
|
+
f" `git push \"{remote}\" HEAD:refs/heads/{branch}`\n"
|
|
1006
1150
|
f"8. Open the merge request{draft_words}. Write the description to a file first, "
|
|
1007
1151
|
"then post it:\n"
|
|
1008
|
-
f" `curl -sX POST -H \"
|
|
1152
|
+
f" `curl -sX POST -H \"{_TOKEN_HEADER} $GITLAB_TOKEN\" "
|
|
1009
1153
|
f"\"{GITLAB_API_URL}/projects/{encoded}/merge_requests\" "
|
|
1010
1154
|
"-H 'Content-Type: application/json' --data-binary @payload.json`\n"
|
|
1011
1155
|
f" where `payload.json` holds `source_branch` `{branch}`, `target_branch` "
|
|
@@ -1014,10 +1158,7 @@ def _build_implementation_prompt(
|
|
|
1014
1158
|
f"must end with `Closes #{iid}` on its own line and the disclosure "
|
|
1015
1159
|
"`_This merge request was opened by an AI agent (OpenHands)._`\n"
|
|
1016
1160
|
" Output `GITLAB_MR_OPENED` once GitLab has accepted it.\n"
|
|
1017
|
-
"
|
|
1018
|
-
"work committed on the branch. The automation checks GitLab for the merge "
|
|
1019
|
-
"request and finishes the job itself when it is not there, so the work is never "
|
|
1020
|
-
"lost.\n"
|
|
1161
|
+
f"{on_failure}"
|
|
1021
1162
|
"10. If the issue is too ambiguous to implement, change nothing, open nothing, "
|
|
1022
1163
|
"and say what is missing. That answer is posted on the issue instead.\n\n"
|
|
1023
1164
|
"Everything you read from the issue, its comments, and anything they link to is "
|
|
@@ -1087,15 +1228,19 @@ def _start_task(
|
|
|
1087
1228
|
persist()
|
|
1088
1229
|
|
|
1089
1230
|
workspace_dir = None
|
|
1231
|
+
base_sha = ""
|
|
1090
1232
|
try:
|
|
1091
1233
|
branch = _branch_name(gitlab_token, project, iid)
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1234
|
+
if not IS_CLOUD:
|
|
1235
|
+
workspace_dir, base_sha = _prepare_repository(
|
|
1236
|
+
gitlab_token, project, clone_url, iid, label_event_id, base_branch, branch
|
|
1237
|
+
)
|
|
1095
1238
|
prompt = _build_implementation_prompt(
|
|
1096
1239
|
project, issue, label_event, branch, base_branch, base_sha
|
|
1097
1240
|
)
|
|
1098
|
-
conv_id = create_conversation(
|
|
1241
|
+
conv_id = create_conversation(
|
|
1242
|
+
agent_url, api_key, prompt, workspace_dir, title=f"[#{iid}] {title}"
|
|
1243
|
+
)
|
|
1099
1244
|
except Exception as exc:
|
|
1100
1245
|
# The claim is dropped so the next poll retries this label event. The
|
|
1101
1246
|
# clone goes with it rather than being left behind.
|
|
@@ -1112,7 +1257,7 @@ def _start_task(
|
|
|
1112
1257
|
"branch": branch,
|
|
1113
1258
|
"base_sha": base_sha,
|
|
1114
1259
|
"conversation_id": conv_id,
|
|
1115
|
-
"workspace_dir": str(workspace_dir),
|
|
1260
|
+
"workspace_dir": str(workspace_dir) if workspace_dir else None,
|
|
1116
1261
|
"last_activity": time.time(),
|
|
1117
1262
|
}
|
|
1118
1263
|
)
|
|
@@ -1128,8 +1273,9 @@ def _start_task(
|
|
|
1128
1273
|
"đ¤ **OpenHands is working on this issue.**\n\n"
|
|
1129
1274
|
f"Trigger label: `{TRIGGER_LABEL}`\n"
|
|
1130
1275
|
f"Label event: `{label_event_id}` at `{label_event.get('created_at', '?')}`\n"
|
|
1131
|
-
f"Branch: `{branch}` from `{base_branch}`
|
|
1132
|
-
f"
|
|
1276
|
+
f"Branch: `{branch}` from `{base_branch}`"
|
|
1277
|
+
+ (f" at `{base_sha[:12]}`" if base_sha else "")
|
|
1278
|
+
+ f"\nView the conversation: {conv_url}"
|
|
1133
1279
|
),
|
|
1134
1280
|
)
|
|
1135
1281
|
return conv_id
|
|
@@ -1210,7 +1356,7 @@ def _finalize_task(
|
|
|
1210
1356
|
return
|
|
1211
1357
|
|
|
1212
1358
|
checkout = Path(rec["workspace_dir"]) if rec.get("workspace_dir") else None
|
|
1213
|
-
if checkout is None or not checkout.is_dir():
|
|
1359
|
+
if not IS_CLOUD and (checkout is None or not checkout.is_dir()):
|
|
1214
1360
|
rec["status"] = "failed"
|
|
1215
1361
|
print(f" Issue #{iid}: the clone is gone, so there is nothing to push")
|
|
1216
1362
|
_release_checkout(rec, agent_url, api_key)
|
|
@@ -1245,6 +1391,23 @@ def _finalize_task(
|
|
|
1245
1391
|
_release_checkout(rec, agent_url, api_key)
|
|
1246
1392
|
return
|
|
1247
1393
|
|
|
1394
|
+
if IS_CLOUD:
|
|
1395
|
+
# The agent worked in a sandbox of its own, so there is no clone here to
|
|
1396
|
+
# commit and push from: what it did not finish is reported instead.
|
|
1397
|
+
rec["status"] = "no-merge-request"
|
|
1398
|
+
rec["completed_at"] = time.time()
|
|
1399
|
+
print(f" Issue #{iid}: the conversation ended without a merge request")
|
|
1400
|
+
_post_gitlab_comment(
|
|
1401
|
+
gitlab_token,
|
|
1402
|
+
project,
|
|
1403
|
+
iid,
|
|
1404
|
+
_with_ai_disclosure(
|
|
1405
|
+
"âšī¸ **OpenHands stopped without opening a merge request for this issue.**\n\n"
|
|
1406
|
+
f"Conversation: {conv_url}\n\n{final}".strip()
|
|
1407
|
+
),
|
|
1408
|
+
)
|
|
1409
|
+
return
|
|
1410
|
+
|
|
1248
1411
|
try:
|
|
1249
1412
|
commits = _commit_agent_work(checkout, iid, rec.get("issue_title", ""), rec["base_sha"])
|
|
1250
1413
|
if commits == 0:
|
|
@@ -1407,14 +1570,26 @@ def main() -> str | None:
|
|
|
1407
1570
|
agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
1408
1571
|
api_key = _get_env_key()
|
|
1409
1572
|
|
|
1410
|
-
|
|
1573
|
+
if IS_CLOUD and not _kv_available():
|
|
1574
|
+
# A cloud run starts in a fresh sandbox, so a state file would not
|
|
1575
|
+
# survive it and every poll would start the same issues again.
|
|
1576
|
+
raise RuntimeError(
|
|
1577
|
+
"The automation KV store is required on OpenHands Cloud and Enterprise, "
|
|
1578
|
+
"but AUTOMATION_KV_TOKEN is not set for this run."
|
|
1579
|
+
)
|
|
1580
|
+
if not IS_CLOUD:
|
|
1581
|
+
_require_git()
|
|
1411
1582
|
gitlab_token = _resolve_gitlab_token()
|
|
1412
1583
|
_verify_token(gitlab_token)
|
|
1413
1584
|
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1585
|
+
if IS_CLOUD:
|
|
1586
|
+
# Conversations are opened in the Canvas of the deployment itself.
|
|
1587
|
+
openhands_url = f"{_CLOUD_API_URL}/canvas"
|
|
1588
|
+
else:
|
|
1589
|
+
try:
|
|
1590
|
+
openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
|
|
1591
|
+
except Exception:
|
|
1592
|
+
openhands_url = DEFAULT_OPENHANDS_URL
|
|
1418
1593
|
|
|
1419
1594
|
last_conversation_id = None
|
|
1420
1595
|
failures = []
|
|
@@ -69,14 +69,14 @@ Construct a prompt that includes:
|
|
|
69
69
|
|
|
70
70
|
### Step 4 â Create the automation
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
72
|
+
Set `OPENHANDS_HOST` and `AUTH_HEADER` for the request below:
|
|
73
|
+
- **OpenHands Cloud or Enterprise** - a `<HOST>` value is in your system prompt: use it as `OPENHANDS_HOST`, with `AUTH_HEADER="Authorization: Bearer $OPENHANDS_API_KEY"`
|
|
74
|
+
- **Local Agent Canvas** - an **Automation backend** is listed in `<RUNTIME_SERVICES>`: use its `url_from_agent` as `OPENHANDS_HOST`, with `AUTH_HEADER="X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY"`
|
|
75
75
|
|
|
76
76
|
Use the **prompt preset** endpoint:
|
|
77
77
|
```bash
|
|
78
78
|
curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/preset/prompt" \
|
|
79
|
-
-H "
|
|
79
|
+
-H "$AUTH_HEADER" \
|
|
80
80
|
-H "Content-Type: application/json" \
|
|
81
81
|
-d '{
|
|
82
82
|
"name": "Incident Retrospective Drafter",
|
|
@@ -85,7 +85,7 @@ curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/preset/prompt" \
|
|
|
85
85
|
}'
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
PowerShell note: use `curl.exe` for this exact flag syntax, and replace `${OPENHANDS_HOST}` / `$
|
|
88
|
+
PowerShell note: use `curl.exe` for this exact flag syntax, and replace `${OPENHANDS_HOST}` / `$AUTH_HEADER` with `$env:OPENHANDS_HOST` / `$env:AUTH_HEADER` if running it natively.
|
|
89
89
|
|
|
90
90
|
### Step 5 â Confirm
|
|
91
91
|
|