@openhands/extensions 0.28.0 → 0.30.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/.agents/skills/custom-codereview-guide.md +44 -1
- package/.claude-plugin/marketplace.json +7 -5
- package/.codex-plugin/marketplace.json +7 -5
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/check-extensions.yml +1 -1
- package/.github/workflows/tests.yml +1 -1
- package/.plugin/marketplace.json +7 -5
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +7 -2
- package/README.md +3 -3
- package/apps/vscode/README.md +8 -6
- package/apps/vscode/canvas-extension.json +11 -3
- package/apps/vscode/extension.js +60 -12
- package/apps/vscode/scripts/package.mjs +20 -74
- package/automations/bundle-index.js +9 -7
- package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
- package/automations/catalog/github-issue-triage/manifest.json +4 -3
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
- package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
- package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
- package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
- package/integrations/catalog/atlassian-rovo.json +4 -3
- package/integrations/catalog/bitbucket.json +16 -1
- package/integrations/catalog/github.json +1 -0
- package/integrations/catalog/gitlab.json +1 -1
- package/marketplaces/openhands-extensions.json +7 -5
- package/package.json +1 -1
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +76 -8
- package/plugins/pr-review/README.md +1 -0
- package/plugins/pr-review/scripts/prompt.py +44 -11
- package/plugins/runtime-api-configs/SKILL.md +1 -1
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/canvas-extension-api/SKILL.md +4 -1
- package/skills/canvas-extension-api/references/backend-safety.md +3 -3
- package/skills/canvas-extension-api/references/native-design.md +250 -0
- package/skills/canvas-extension-api/references/v1-contract.md +7 -4
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
- package/skills/github/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/README.md +73 -14
- package/skills/github-issue-triage/SKILL.md +26 -46
- package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
- package/skills/github-issue-triage/scripts/worker.py +153 -103
- package/skills/github-pr-reviewer/README.md +103 -1
- package/skills/github-pr-reviewer/SKILL.md +35 -8
- package/skills/github-pr-reviewer/references/state-schema.md +8 -0
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
- package/skills/github-pr-reviewer/scripts/main.py +163 -39
- package/skills/github-pr-reviewer/scripts/publication.py +193 -0
- package/skills/github-pr-reviewer/scripts/worker.py +297 -8
- package/skills/github-pr-reviewer/tests/test_main.py +73 -5
- package/skills/gitlab-issue-to-mr/README.md +10 -3
- package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
- package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
- package/skills/index.js +10 -10
- package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/README.md +18 -11
- package/skills/jira-issue-to-pr/SKILL.md +72 -23
- package/skills/jira-issue-to-pr/references/setup.md +43 -12
- package/skills/jira-issue-to-pr/scripts/main.py +136 -26
- package/skills/openhands-automation/references/custom-automation.md +76 -8
- package/skills/slack-channel-monitor/README.md +6 -1
- package/skills/slack-channel-monitor/SKILL.md +6 -3
- package/skills/slack-channel-monitor/scripts/main.py +36 -4
- package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/SKILL.md +5 -3
- package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +173 -6
- package/tests/test_automation_llm_provenance.py +1209 -0
- package/tests/test_github_review_publication.py +422 -0
- package/tests/test_github_reviewer_delivery.py +174 -21
- package/tests/test_github_triage_delivery.py +411 -238
- package/tests/test_gitlab_issue_to_mr.py +284 -0
- package/tests/test_jira_issue_to_pr.py +200 -6
- package/tests/test_pr_review_prompt_injection.py +353 -0
- package/tests/test_slack_channel_monitor.py +32 -1
- package/tests/test_vscode_app.py +8 -2
- package/tests/vscode-app.test.mjs +8 -2
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import time
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from urllib.error import HTTPError
|
|
8
|
+
from urllib.parse import urlencode
|
|
7
9
|
from urllib.request import Request, urlopen
|
|
8
10
|
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
|
9
11
|
|
|
@@ -113,6 +115,10 @@ class CloudConversations:
|
|
|
113
115
|
},
|
|
114
116
|
)
|
|
115
117
|
|
|
118
|
+
def agent_profiles(self) -> list[dict]:
|
|
119
|
+
listing = self._request("GET", "/api/agent-profiles")
|
|
120
|
+
return (listing or {}).get("profiles") or []
|
|
121
|
+
|
|
116
122
|
def run(self, conversation: dict) -> None:
|
|
117
123
|
"""Start the next turn of an idle conversation on its own Agent Server."""
|
|
118
124
|
try:
|
|
@@ -152,6 +158,17 @@ def _user_message(text: str) -> dict:
|
|
|
152
158
|
return {"role": "user", "content": [{"type": "text", "text": text}]}
|
|
153
159
|
|
|
154
160
|
|
|
161
|
+
def _render(prompt, conversation_id) -> str:
|
|
162
|
+
"""The text of `prompt` for the conversation it is delivered to.
|
|
163
|
+
|
|
164
|
+
A caller that must name the receiving conversation in its own prompt (a
|
|
165
|
+
marker the deterministic side later looks for, say) passes a function of
|
|
166
|
+
the conversation id, because only delivery decides which conversation that
|
|
167
|
+
is.
|
|
168
|
+
"""
|
|
169
|
+
return prompt(str(conversation_id)) if callable(prompt) else prompt
|
|
170
|
+
|
|
171
|
+
|
|
155
172
|
class AgentConversationDispatcher:
|
|
156
173
|
"""Deliver one revision at a time to a stable conversation for each subject."""
|
|
157
174
|
|
|
@@ -189,7 +206,11 @@ class AgentConversationDispatcher:
|
|
|
189
206
|
return self._workspace.__exit__(*args)
|
|
190
207
|
|
|
191
208
|
def deliver(
|
|
192
|
-
self,
|
|
209
|
+
self,
|
|
210
|
+
subject: str,
|
|
211
|
+
delivery: str,
|
|
212
|
+
prompt: str | Callable[[str], str],
|
|
213
|
+
head: str = "",
|
|
193
214
|
) -> dict[str, str]:
|
|
194
215
|
"""Deliver one revision of `subject`, deduping a repeated head.
|
|
195
216
|
|
|
@@ -202,6 +223,7 @@ class AgentConversationDispatcher:
|
|
|
202
223
|
A conversation already running for this head is reported `in_progress`
|
|
203
224
|
and left alone, whatever the new delivery says. Callers with no revision
|
|
204
225
|
identity leave `head` empty and keep the delivery-key-only behavior.
|
|
226
|
+
`prompt` may be a function of the receiving conversation's id.
|
|
205
227
|
"""
|
|
206
228
|
conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
|
|
207
229
|
state_key = f"{_CONVERSATION_KEY_PREFIX}{conversation_id}"
|
|
@@ -252,6 +274,67 @@ class AgentConversationDispatcher:
|
|
|
252
274
|
"conversation_id": str(conversation_id),
|
|
253
275
|
}
|
|
254
276
|
|
|
277
|
+
def subject_conversation(self, subject: str) -> str | None:
|
|
278
|
+
"""The conversation this automation currently delivers `subject` to."""
|
|
279
|
+
conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
|
|
280
|
+
record = _kv_request(f"{_CONVERSATION_KEY_PREFIX}{conversation_id}", "GET")
|
|
281
|
+
return (record or {}).get("conversation_id") or None
|
|
282
|
+
|
|
283
|
+
def llm_provenance(self, conversation_id: str) -> tuple[str, str] | None:
|
|
284
|
+
"""The LLM profile name and model a conversation runs, or None if it is gone.
|
|
285
|
+
|
|
286
|
+
Both are read back from the conversation, never from the automation's
|
|
287
|
+
current settings: the server resolves the agent profile when it starts
|
|
288
|
+
the conversation, and a reused conversation keeps that LLM after the
|
|
289
|
+
profile is edited. The model is the conversation's own. The LLM profile
|
|
290
|
+
name comes from the launching agent profile only while that profile is
|
|
291
|
+
still at its launch revision, and is `unknown` otherwise.
|
|
292
|
+
"""
|
|
293
|
+
if self._cloud:
|
|
294
|
+
conversation = self._cloud.get(conversation_id)
|
|
295
|
+
if conversation is None:
|
|
296
|
+
return None
|
|
297
|
+
model = conversation.get("llm_model")
|
|
298
|
+
else:
|
|
299
|
+
try:
|
|
300
|
+
conversation = self._agent_server_get(
|
|
301
|
+
f"/api/conversations/{conversation_id}"
|
|
302
|
+
)
|
|
303
|
+
except HTTPError as exc:
|
|
304
|
+
if exc.code == 404:
|
|
305
|
+
return None
|
|
306
|
+
raise
|
|
307
|
+
agent = conversation.get("agent") or {}
|
|
308
|
+
model = conversation.get("current_model_id")
|
|
309
|
+
if model is None and agent.get("kind") != "ACPAgent":
|
|
310
|
+
model = (agent.get("llm") or {}).get("model")
|
|
311
|
+
launched = conversation.get("launched_agent_profile") or {}
|
|
312
|
+
profile = "unknown"
|
|
313
|
+
if launched.get("agent_profile_id"):
|
|
314
|
+
try:
|
|
315
|
+
if self._cloud:
|
|
316
|
+
profiles = self._cloud.agent_profiles()
|
|
317
|
+
else:
|
|
318
|
+
listing = self._agent_server_get("/api/agent-profiles")
|
|
319
|
+
profiles = (listing or {}).get("profiles") or []
|
|
320
|
+
except Exception as exc: # noqa: BLE001 - the model is still known
|
|
321
|
+
print(f"Could not read agent profiles: {type(exc).__name__}: {exc}")
|
|
322
|
+
profiles = []
|
|
323
|
+
for item in profiles:
|
|
324
|
+
if str(item.get("id")) == str(launched["agent_profile_id"]):
|
|
325
|
+
if item.get("revision") == launched.get("revision"):
|
|
326
|
+
profile = item.get("llm_profile_ref") or "unknown"
|
|
327
|
+
break
|
|
328
|
+
return profile, model or "unknown"
|
|
329
|
+
|
|
330
|
+
def _agent_server_get(self, path: str) -> dict:
|
|
331
|
+
request = Request(
|
|
332
|
+
f"{self.agent_url.rstrip('/')}{path}",
|
|
333
|
+
headers={"X-Session-API-Key": self.api_key},
|
|
334
|
+
)
|
|
335
|
+
with urlopen(request, timeout=90) as response:
|
|
336
|
+
return json.load(response)
|
|
337
|
+
|
|
255
338
|
def _deliver_cloud(
|
|
256
339
|
self,
|
|
257
340
|
record,
|
|
@@ -287,7 +370,9 @@ class AgentConversationDispatcher:
|
|
|
287
370
|
|
|
288
371
|
if conversation is None:
|
|
289
372
|
new_id = uuid4() if record else conversation_id
|
|
290
|
-
self._cloud.start(
|
|
373
|
+
self._cloud.start(
|
|
374
|
+
new_id, self.profile_id, subject, _render(prompt, new_id)
|
|
375
|
+
)
|
|
291
376
|
return "created", new_id
|
|
292
377
|
|
|
293
378
|
status = conversation.get("execution_status")
|
|
@@ -303,10 +388,10 @@ class AgentConversationDispatcher:
|
|
|
303
388
|
# The API reports an execution status only while the sandbox
|
|
304
389
|
# runs, so an errored conversation whose sandbox was already
|
|
305
390
|
# paused is not seen here and keeps reporting deduplicated.
|
|
306
|
-
self._cloud.send(conversation, prompt)
|
|
391
|
+
self._cloud.send(conversation, _render(prompt, current_id))
|
|
307
392
|
return "retried", current_id
|
|
308
393
|
return "deduplicated", current_id
|
|
309
|
-
self._cloud.send(conversation, prompt)
|
|
394
|
+
self._cloud.send(conversation, _render(prompt, current_id))
|
|
310
395
|
return "resumed", current_id
|
|
311
396
|
|
|
312
397
|
def _deliver_local(
|
|
@@ -326,12 +411,13 @@ class AgentConversationDispatcher:
|
|
|
326
411
|
conversation = RemoteConversation.create(
|
|
327
412
|
self._workspace,
|
|
328
413
|
StartConversationRequest(
|
|
329
|
-
workspace=LocalWorkspace(working_dir=
|
|
414
|
+
workspace=LocalWorkspace(working_dir=self._workspace.working_dir),
|
|
330
415
|
conversation_id=conversation_id,
|
|
331
416
|
agent_profile_id=self.profile_id,
|
|
332
417
|
secrets=self._secrets,
|
|
333
418
|
initial_message=SendMessageRequest(
|
|
334
|
-
content=[TextContent(text=prompt)],
|
|
419
|
+
content=[TextContent(text=_render(prompt, conversation_id))],
|
|
420
|
+
run=True,
|
|
335
421
|
),
|
|
336
422
|
),
|
|
337
423
|
visualizer=None,
|
|
@@ -365,7 +451,7 @@ class AgentConversationDispatcher:
|
|
|
365
451
|
# the work finished, so send the revision again rather
|
|
366
452
|
# than strand it.
|
|
367
453
|
conversation.update_secrets(self._secrets)
|
|
368
|
-
conversation.send_message(prompt)
|
|
454
|
+
conversation.send_message(_render(prompt, conversation_id))
|
|
369
455
|
conversation.run(blocking=False)
|
|
370
456
|
disposition = "retried"
|
|
371
457
|
else:
|
|
@@ -375,8 +461,138 @@ class AgentConversationDispatcher:
|
|
|
375
461
|
# work on the same subject: reuse the conversation and send
|
|
376
462
|
# the revision as its next turn.
|
|
377
463
|
conversation.update_secrets(self._secrets)
|
|
378
|
-
conversation.send_message(prompt)
|
|
464
|
+
conversation.send_message(_render(prompt, conversation_id))
|
|
379
465
|
conversation.run(blocking=False)
|
|
380
466
|
finally:
|
|
381
467
|
conversation.close()
|
|
382
468
|
return disposition
|
|
469
|
+
|
|
470
|
+
def review_snapshot(self, conversation_id):
|
|
471
|
+
if self._cloud:
|
|
472
|
+
conversation = self._cloud.get(conversation_id)
|
|
473
|
+
if conversation is None:
|
|
474
|
+
return "missing", ""
|
|
475
|
+
status = conversation.get("execution_status", "unknown")
|
|
476
|
+
if status != "finished":
|
|
477
|
+
return status, ""
|
|
478
|
+
result = self._cloud._request(
|
|
479
|
+
"GET",
|
|
480
|
+
f"{conversation['conversation_url']}/agent_final_response",
|
|
481
|
+
headers={"X-Session-API-Key": conversation["session_api_key"]},
|
|
482
|
+
)
|
|
483
|
+
return status, result.get("response", "")
|
|
484
|
+
headers = {"X-Session-API-Key": self.api_key}
|
|
485
|
+
base = f"{self.agent_url.rstrip('/')}/api/conversations/{conversation_id}"
|
|
486
|
+
try:
|
|
487
|
+
with urlopen(Request(base, headers=headers), timeout=90) as response:
|
|
488
|
+
status = json.load(response).get("execution_status", "unknown")
|
|
489
|
+
except HTTPError as exc:
|
|
490
|
+
if exc.code == 404:
|
|
491
|
+
return "missing", ""
|
|
492
|
+
raise
|
|
493
|
+
if status != "finished":
|
|
494
|
+
return status, ""
|
|
495
|
+
with urlopen(
|
|
496
|
+
Request(base + "/agent_final_response", headers=headers), timeout=90
|
|
497
|
+
) as response:
|
|
498
|
+
return status, json.load(response).get("response", "")
|
|
499
|
+
|
|
500
|
+
def start_review_work(self, conversation_id, subject, prompt):
|
|
501
|
+
if self._cloud:
|
|
502
|
+
self._cloud.start(UUID(conversation_id), self.profile_id, subject, prompt)
|
|
503
|
+
return
|
|
504
|
+
if self._workspace is None:
|
|
505
|
+
raise RuntimeError("Dispatcher must be used as a context")
|
|
506
|
+
conversation = RemoteConversation.create(
|
|
507
|
+
self._workspace,
|
|
508
|
+
StartConversationRequest(
|
|
509
|
+
workspace=LocalWorkspace(working_dir=self._workspace.working_dir),
|
|
510
|
+
conversation_id=UUID(conversation_id),
|
|
511
|
+
agent_profile_id=self.profile_id,
|
|
512
|
+
secrets=self._secrets,
|
|
513
|
+
initial_message=SendMessageRequest(
|
|
514
|
+
content=[TextContent(text=prompt)], run=True
|
|
515
|
+
),
|
|
516
|
+
),
|
|
517
|
+
visualizer=None,
|
|
518
|
+
)
|
|
519
|
+
conversation.close()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class ReviewCoordination:
|
|
523
|
+
"""CAS against the automation service's shared, user-authorized KV namespace."""
|
|
524
|
+
|
|
525
|
+
def __init__(self):
|
|
526
|
+
namespace = os.environ.get("REVIEW_COORDINATION_AUTOMATION_ID")
|
|
527
|
+
key = os.environ.get("REVIEW_COORDINATION_API_KEY")
|
|
528
|
+
if not namespace or not key:
|
|
529
|
+
raise RuntimeError(
|
|
530
|
+
"Structured reviews require shared coordination namespace and API key"
|
|
531
|
+
)
|
|
532
|
+
self.query = urlencode({"automation_id": str(UUID(namespace))})
|
|
533
|
+
self.base = os.environ["AUTOMATION_API_URL"].rstrip("/")
|
|
534
|
+
self.headers = {"X-Session-API-Key": key, "Content-Type": "application/json"}
|
|
535
|
+
|
|
536
|
+
def request(self, key, method="GET", value=None, condition=""):
|
|
537
|
+
request = Request(
|
|
538
|
+
f"{self.base}/v1/kv/{key}?{self.query}&{condition}",
|
|
539
|
+
data=None if value is None else json.dumps(value).encode(),
|
|
540
|
+
headers=self.headers,
|
|
541
|
+
method=method,
|
|
542
|
+
)
|
|
543
|
+
try:
|
|
544
|
+
with urlopen(request, timeout=90) as response:
|
|
545
|
+
return json.load(response)
|
|
546
|
+
except HTTPError as exc:
|
|
547
|
+
if method == "GET" and exc.code == 404:
|
|
548
|
+
return None
|
|
549
|
+
raise
|
|
550
|
+
|
|
551
|
+
def claim(self, subject, *, existing_only=False):
|
|
552
|
+
key = "review-work-" + str(uuid5(NAMESPACE_URL, subject))
|
|
553
|
+
for _ in range(5):
|
|
554
|
+
current = self.request(key, condition="meta=true")
|
|
555
|
+
if current is None and existing_only:
|
|
556
|
+
return None
|
|
557
|
+
value = (current or {}).get("value") or {}
|
|
558
|
+
if value.get("lease_until", 0) > time.time():
|
|
559
|
+
return None
|
|
560
|
+
value = {**value, "owner": str(uuid4()), "lease_until": time.time() + 180}
|
|
561
|
+
condition = f"if_version={current['version']}" if current else "nx=true"
|
|
562
|
+
try:
|
|
563
|
+
self.request(key, "PUT", value, condition)
|
|
564
|
+
return ReviewLease(self, key, value)
|
|
565
|
+
except HTTPError as exc:
|
|
566
|
+
if exc.code != 409:
|
|
567
|
+
raise
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class ReviewLease:
|
|
572
|
+
def __init__(self, store, key, value):
|
|
573
|
+
self.store, self.key, self.value = store, key, value
|
|
574
|
+
self.owner = value["owner"]
|
|
575
|
+
|
|
576
|
+
def save(self, **changes):
|
|
577
|
+
for _ in range(5):
|
|
578
|
+
current = self.store.request(self.key, condition="meta=true")
|
|
579
|
+
value = current["value"]
|
|
580
|
+
if (
|
|
581
|
+
value.get("owner") != self.owner
|
|
582
|
+
or value.get("lease_until", 0) <= time.time()
|
|
583
|
+
):
|
|
584
|
+
raise RuntimeError("Review lease lost; refusing side effects")
|
|
585
|
+
updated = {**self.value, **changes}
|
|
586
|
+
try:
|
|
587
|
+
self.store.request(
|
|
588
|
+
self.key, "PUT", updated, f"if_version={current['version']}"
|
|
589
|
+
)
|
|
590
|
+
self.value = updated
|
|
591
|
+
return
|
|
592
|
+
except HTTPError as exc:
|
|
593
|
+
if exc.code != 409:
|
|
594
|
+
raise
|
|
595
|
+
raise RuntimeError("Review coordination contention; retry next scan")
|
|
596
|
+
|
|
597
|
+
def release(self):
|
|
598
|
+
self.save(lease_until=0)
|
|
@@ -24,6 +24,7 @@ import tarfile
|
|
|
24
24
|
import time
|
|
25
25
|
import urllib.error
|
|
26
26
|
import urllib.request
|
|
27
|
+
import uuid
|
|
27
28
|
from collections.abc import Callable
|
|
28
29
|
from pathlib import Path, PurePosixPath
|
|
29
30
|
|
|
@@ -427,7 +428,7 @@ def _latest_trigger_label_event(token: str, repo: str, pr_number: int) -> dict |
|
|
|
427
428
|
return max(matching, key=lambda event: (event.get("created_at") or "", int(event.get("id") or 0)))
|
|
428
429
|
|
|
429
430
|
|
|
430
|
-
def _post_github_comment(token: str, repo: str, pr_number: int, body: str) ->
|
|
431
|
+
def _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> bool:
|
|
431
432
|
try:
|
|
432
433
|
_github_request(
|
|
433
434
|
token,
|
|
@@ -437,28 +438,28 @@ def _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> No
|
|
|
437
438
|
)
|
|
438
439
|
except Exception as exc:
|
|
439
440
|
print(f" Warning: failed to post comment on PR #{pr_number}: {exc}")
|
|
441
|
+
return False
|
|
442
|
+
return True
|
|
440
443
|
|
|
441
444
|
|
|
442
|
-
def
|
|
443
|
-
"""
|
|
445
|
+
def _published_reviews(token: str, repo: str, pr_number: int, head_sha: str) -> list[dict]:
|
|
446
|
+
"""This token's user's submitted reviews of this exact commit.
|
|
444
447
|
|
|
445
448
|
The agent is asked to report success, but a report is not evidence: reviews
|
|
446
449
|
have been reported as posted when none existed. GitHub is the source of
|
|
447
|
-
truth for whether the review landed.
|
|
450
|
+
truth for whether the review landed. A listing failure is raised rather
|
|
451
|
+
than read as "no review", so the caller retries instead of posting the
|
|
452
|
+
result a second time.
|
|
448
453
|
"""
|
|
449
454
|
if not head_sha or not _AUTH_LOGIN:
|
|
450
|
-
return
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
continue
|
|
459
|
-
if review.get("commit_id") == head_sha:
|
|
460
|
-
return True
|
|
461
|
-
return False
|
|
455
|
+
return []
|
|
456
|
+
return [
|
|
457
|
+
review
|
|
458
|
+
for review in _github_paginate(token, f"/repos/{repo}/pulls/{pr_number}/reviews")
|
|
459
|
+
if (review.get("user") or {}).get("login", "").lower() == _AUTH_LOGIN.lower()
|
|
460
|
+
and review.get("commit_id") == head_sha
|
|
461
|
+
and review.get("state") in {"COMMENTED", "APPROVED", "CHANGES_REQUESTED"}
|
|
462
|
+
]
|
|
462
463
|
|
|
463
464
|
|
|
464
465
|
# ── Repository checkout ───────────────────────────────────────────────────────
|
|
@@ -666,12 +667,17 @@ def create_conversation(
|
|
|
666
667
|
api_key: str,
|
|
667
668
|
initial_message: str,
|
|
668
669
|
workspace_dir: Path,
|
|
670
|
+
agent: dict | None = None,
|
|
671
|
+
conversation_id: str | None = None,
|
|
669
672
|
) -> str:
|
|
670
673
|
payload: dict = {
|
|
671
674
|
"workspace": {"working_dir": str(workspace_dir)},
|
|
672
|
-
"agent": _get_agent_dict(agent_url, api_key),
|
|
675
|
+
"agent": agent or _get_agent_dict(agent_url, api_key),
|
|
673
676
|
"initial_message": {"content": [{"text": initial_message}]},
|
|
674
677
|
}
|
|
678
|
+
if conversation_id:
|
|
679
|
+
# Chosen by the caller so the prompt can name it before it exists.
|
|
680
|
+
payload["conversation_id"] = conversation_id
|
|
675
681
|
secrets = _build_secrets_payload(agent_url, api_key)
|
|
676
682
|
if secrets:
|
|
677
683
|
payload["secrets"] = secrets
|
|
@@ -733,6 +739,52 @@ def _with_ai_disclosure(body: str) -> str:
|
|
|
733
739
|
return f"{body}\n\n{disclosure}" if body else disclosure
|
|
734
740
|
|
|
735
741
|
|
|
742
|
+
def _llm_provenance(profile: str, model: str) -> str:
|
|
743
|
+
return f"LLM profile: `{profile}` · Model: `{model}`"
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _without_llm_provenance(body: str) -> str:
|
|
747
|
+
"""The body with any provenance footer removed, for reading its verdict."""
|
|
748
|
+
return "\n".join(
|
|
749
|
+
line
|
|
750
|
+
for line in (body or "").splitlines()
|
|
751
|
+
if not re.fullmatch(r"LLM profile: .* · Model: .*", line.strip())
|
|
752
|
+
).strip()
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _with_llm_provenance(body: str, profile: str, model: str) -> str:
|
|
756
|
+
provenance = _llm_provenance(profile, model)
|
|
757
|
+
body = _without_llm_provenance(body)
|
|
758
|
+
return f"{body}\n\n{provenance}" if body else provenance
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# Two automations can share one bot account and review the same head, so the
|
|
762
|
+
# login, commit, and submission time cannot say which run published a review.
|
|
763
|
+
# The review agent copies this hidden marker, naming its own conversation, into
|
|
764
|
+
# the review body; the deterministic script then annotates only the reviews
|
|
765
|
+
# that carry exactly one marker, and only that conversation's own.
|
|
766
|
+
_REVIEW_RUN_MARKER_RE = re.compile(r"<!-- openhands-review-run: ([0-9A-Za-z-]+) -->")
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _review_run_marker(conversation_id: str) -> str:
|
|
770
|
+
return f"<!-- openhands-review-run: {conversation_id} -->"
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _own_reviews(reviews: list[dict], conversation_id: str) -> list[dict]:
|
|
774
|
+
"""The reviews whose only run marker names `conversation_id`.
|
|
775
|
+
|
|
776
|
+
A review with no marker, another run's marker, or several markers (an
|
|
777
|
+
agent quoting another review) is never attributed to this conversation.
|
|
778
|
+
"""
|
|
779
|
+
if not conversation_id:
|
|
780
|
+
return []
|
|
781
|
+
return [
|
|
782
|
+
review
|
|
783
|
+
for review in reviews
|
|
784
|
+
if _REVIEW_RUN_MARKER_RE.findall(review.get("body") or "") == [str(conversation_id)]
|
|
785
|
+
]
|
|
786
|
+
|
|
787
|
+
|
|
736
788
|
def _load_repo_review_guide(workspace_dir: Path) -> str | None:
|
|
737
789
|
"""Read the repo-specific review guide from the checked-out repository.
|
|
738
790
|
|
|
@@ -764,6 +816,7 @@ def _build_review_prompt(
|
|
|
764
816
|
workspace_instructions: str | None = None,
|
|
765
817
|
github_token_secret: str = "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
766
818
|
trigger_description: str | None = None,
|
|
819
|
+
conversation_id: str | None = None,
|
|
767
820
|
) -> str:
|
|
768
821
|
number = pr.get("number", "?")
|
|
769
822
|
title = pr.get("title", "(no title)")
|
|
@@ -792,6 +845,14 @@ def _build_review_prompt(
|
|
|
792
845
|
"The workspace is already the repository root at the exact Head SHA above. "
|
|
793
846
|
"Do not clone, fetch, check out, or delete the repository."
|
|
794
847
|
)
|
|
848
|
+
run_marker = (
|
|
849
|
+
" On the next line put this hidden marker, copied exactly: "
|
|
850
|
+
f"`{_review_run_marker(conversation_id)}`. It identifies this conversation's "
|
|
851
|
+
"review, and the automation uses it to append the LLM profile and model after "
|
|
852
|
+
"GitHub accepts the review, so do not write that footer yourself."
|
|
853
|
+
if conversation_id
|
|
854
|
+
else ""
|
|
855
|
+
)
|
|
795
856
|
|
|
796
857
|
return (
|
|
797
858
|
"You are an AI code reviewer. Review the GitHub pull request below and publish "
|
|
@@ -827,6 +888,14 @@ def _build_review_prompt(
|
|
|
827
888
|
"changed files), plus other relevant docs when present - e.g. `CONTRIBUTING.md`, "
|
|
828
889
|
"`CLAUDE.md`, `.cursorrules`, and any review or coding-guideline docs. Apply that "
|
|
829
890
|
"guidance to your review.\n"
|
|
891
|
+
" Read `.agents/skills/custom-codereview-guide.md` when present; apply only checks "
|
|
892
|
+
"relevant to this change. For cross-repo behavior, inspect the owning implementation at "
|
|
893
|
+
"the supported dependency version (check pins/deployment), not upstream main. software-"
|
|
894
|
+
"agent-sdk owns execution/events; automation owns scheduling/run state; extensions owns "
|
|
895
|
+
"reusable skills/API usage. Find where behavior first fails. Distinguish an upstream fix "
|
|
896
|
+
"from a needed consumer change; state dependency order without filing tickets or editing "
|
|
897
|
+
"other repos. Respect accepted defaults/non-goals. Do not repeat answered questions or "
|
|
898
|
+
"block on unrelated pre-existing defects.\n"
|
|
830
899
|
"4. SCOPE GATE - before inspecting changed files, reading the diff, or running any test, "
|
|
831
900
|
"use the repository guidance above (its scope categories and ownership boundaries) to decide "
|
|
832
901
|
"whether this change belongs in this repository and has the product/architecture direction "
|
|
@@ -877,8 +946,11 @@ def _build_review_prompt(
|
|
|
877
946
|
"If GitHub forbids the configured bot from approving its own PR, retry the clean review with "
|
|
878
947
|
"`event: COMMENT` and keep the approved verdict.\n"
|
|
879
948
|
"10. Begin the review body with this disclosure: "
|
|
880
|
-
"`_This review was posted by an AI agent (OpenHands)._
|
|
881
|
-
"11.
|
|
949
|
+
f"`_This review was posted by an AI agent (OpenHands)._`{run_marker}\n"
|
|
950
|
+
"11. Separate code correctness from merge readiness. Never end with APPROVED while "
|
|
951
|
+
"material findings or required checks/evidence remain unresolved. Missing evidence "
|
|
952
|
+
"is not itself a code defect. "
|
|
953
|
+
"End the review body with a verdict on its own line: either `✅ APPROVED` "
|
|
882
954
|
"or `🔄 CHANGES REQUESTED`.\n"
|
|
883
955
|
"12. If there are no material issues, still publish a review saying so, with the "
|
|
884
956
|
"disclosure and the verdict.\n"
|
|
@@ -933,8 +1005,24 @@ def _process_review_request(
|
|
|
933
1005
|
repo_review_guide = _load_repo_review_guide(workspace_dir)
|
|
934
1006
|
if repo_review_guide:
|
|
935
1007
|
print(f" Injected repo review guide for PR #{number}")
|
|
936
|
-
|
|
937
|
-
conv_id =
|
|
1008
|
+
agent = _get_agent_dict(agent_url, api_key)
|
|
1009
|
+
conv_id = str(uuid.uuid4())
|
|
1010
|
+
prompt = _build_review_prompt(
|
|
1011
|
+
repo,
|
|
1012
|
+
pr,
|
|
1013
|
+
head_sha,
|
|
1014
|
+
label_event,
|
|
1015
|
+
repo_review_guide,
|
|
1016
|
+
conversation_id=conv_id,
|
|
1017
|
+
)
|
|
1018
|
+
conv_id = create_conversation(
|
|
1019
|
+
agent_url,
|
|
1020
|
+
api_key,
|
|
1021
|
+
prompt,
|
|
1022
|
+
workspace_dir,
|
|
1023
|
+
agent=agent,
|
|
1024
|
+
conversation_id=conv_id,
|
|
1025
|
+
)
|
|
938
1026
|
except Exception as exc:
|
|
939
1027
|
# The claim is dropped so the next poll retries this label event. The
|
|
940
1028
|
# checkout goes with it rather than being left behind.
|
|
@@ -950,6 +1038,8 @@ def _process_review_request(
|
|
|
950
1038
|
"status": "active",
|
|
951
1039
|
"conversation_id": conv_id,
|
|
952
1040
|
"workspace_dir": str(workspace_dir),
|
|
1041
|
+
"llm_profile": "default",
|
|
1042
|
+
"llm_model": agent["llm"].get("model") or "unknown",
|
|
953
1043
|
"last_activity": time.time(),
|
|
954
1044
|
}
|
|
955
1045
|
)
|
|
@@ -1023,31 +1113,65 @@ def _check_conversation_completion(
|
|
|
1023
1113
|
except Exception:
|
|
1024
1114
|
final = ""
|
|
1025
1115
|
|
|
1116
|
+
# A record written before provenance was tracked cannot name either value.
|
|
1117
|
+
llm_profile = rec.get("llm_profile") or "unknown"
|
|
1118
|
+
llm_model = rec.get("llm_model") or "unknown"
|
|
1026
1119
|
if status in {"error", "stuck"}:
|
|
1027
|
-
_post_github_comment(
|
|
1120
|
+
posted = _post_github_comment(
|
|
1028
1121
|
github_token,
|
|
1029
1122
|
repo,
|
|
1030
1123
|
pr_number,
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1124
|
+
_with_llm_provenance(
|
|
1125
|
+
_with_ai_disclosure(
|
|
1126
|
+
f"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` "
|
|
1127
|
+
f"(status: `{status}`).\n\n{final}".strip()
|
|
1128
|
+
),
|
|
1129
|
+
llm_profile,
|
|
1130
|
+
llm_model,
|
|
1034
1131
|
),
|
|
1035
1132
|
)
|
|
1036
|
-
|
|
1037
|
-
|
|
1133
|
+
if not posted:
|
|
1134
|
+
return
|
|
1038
1135
|
else:
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1136
|
+
try:
|
|
1137
|
+
reviews = _published_reviews(github_token, repo, pr_number, reviewed_sha)
|
|
1138
|
+
own = _own_reviews(reviews, conv_id)
|
|
1139
|
+
for review in own:
|
|
1140
|
+
body = _with_llm_provenance(review.get("body") or "", llm_profile, llm_model)
|
|
1141
|
+
if body != review.get("body"):
|
|
1142
|
+
_github_request(
|
|
1143
|
+
github_token,
|
|
1144
|
+
"PUT",
|
|
1145
|
+
f"/repos/{repo}/pulls/{pr_number}/reviews/{review['id']}",
|
|
1146
|
+
body={"body": body},
|
|
1147
|
+
)
|
|
1148
|
+
except Exception as exc:
|
|
1149
|
+
print(f" Warning: could not verify or complete review provenance for PR #{pr_number}: {exc}")
|
|
1150
|
+
return
|
|
1151
|
+
if reviews:
|
|
1152
|
+
print(
|
|
1153
|
+
f" PR #{pr_number}: review confirmed on GitHub at {reviewed_sha[:12]}"
|
|
1154
|
+
+ ("" if own else "; none carries this conversation's marker, so none was annotated")
|
|
1155
|
+
)
|
|
1156
|
+
else:
|
|
1157
|
+
if final.strip() == "GITHUB_REVIEW_POSTED":
|
|
1158
|
+
final = ""
|
|
1159
|
+
posted = _post_github_comment(
|
|
1160
|
+
github_token,
|
|
1161
|
+
repo,
|
|
1162
|
+
pr_number,
|
|
1163
|
+
_with_llm_provenance(
|
|
1164
|
+
_with_ai_disclosure(
|
|
1165
|
+
final
|
|
1166
|
+
or f"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced."
|
|
1167
|
+
),
|
|
1168
|
+
llm_profile,
|
|
1169
|
+
llm_model,
|
|
1170
|
+
),
|
|
1171
|
+
)
|
|
1172
|
+
if not posted:
|
|
1173
|
+
return
|
|
1174
|
+
print(f" PR #{pr_number}: no review found on GitHub; posted the result as a comment")
|
|
1051
1175
|
|
|
1052
1176
|
rec["status"] = "closed"
|
|
1053
1177
|
rec["completed_at"] = time.time()
|