@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)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Pure triage contracts and optimistic, fail-closed GitHub publication."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import quote, unquote
|
|
9
|
+
|
|
10
|
+
from github_client import GitHubRepository
|
|
11
|
+
|
|
12
|
+
START = "<!-- openhands-ai-triage:start -->"
|
|
13
|
+
END = "<!-- openhands-ai-triage:end -->"
|
|
14
|
+
VERSION = 3
|
|
15
|
+
DISCLOSURE = "_Generated by an AI agent (OpenHands) on behalf of the automation owner._"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def split_body(body):
|
|
19
|
+
body = body or ""
|
|
20
|
+
if START not in body and END not in body:
|
|
21
|
+
return body, "", ""
|
|
22
|
+
if body.count(START) != 1 or body.count(END) != 1:
|
|
23
|
+
raise ValueError("Ambiguous managed markers; human repair required")
|
|
24
|
+
start, end = body.index(START), body.index(END)
|
|
25
|
+
if end < start:
|
|
26
|
+
raise ValueError("Reversed managed markers")
|
|
27
|
+
return body[:start], body[start : end + len(END)], body[end + len(END) :]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def author_body(body):
|
|
31
|
+
before, _, after = split_body(body)
|
|
32
|
+
return (before + after).rstrip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def owned_comment(comment, login):
|
|
36
|
+
return (
|
|
37
|
+
bool(login)
|
|
38
|
+
and (comment.get("user") or {}).get("login") == login
|
|
39
|
+
and bool(
|
|
40
|
+
re.search(r"<!-- triage-source:[a-f0-9]{64} -->", comment.get("body") or "")
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def readiness_comment(comment):
|
|
46
|
+
return (comment.get("user") or {}).get(
|
|
47
|
+
"login"
|
|
48
|
+
) == "github-actions[bot]" and "<!-- issue-readiness-check -->" in (
|
|
49
|
+
comment.get("body") or ""
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def readiness_transitions(repo, number, login):
|
|
54
|
+
return [
|
|
55
|
+
(e.get("id"), e.get("event"), e.get("created_at"))
|
|
56
|
+
for e in repo.gh_pages(f"/issues/{number}/timeline")
|
|
57
|
+
if e.get("event") in {"labeled", "unlabeled"}
|
|
58
|
+
and (e.get("label") or {}).get("name") == "ready-for-dev"
|
|
59
|
+
and (e.get("actor") or {}).get("login") != login
|
|
60
|
+
and (e.get("actor") or {}).get("login") != "github-actions[bot]"
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fingerprint(issue, comments, login, transitions=(), policy=None):
|
|
65
|
+
return hashlib.sha256(
|
|
66
|
+
json.dumps(
|
|
67
|
+
[
|
|
68
|
+
VERSION,
|
|
69
|
+
issue["title"],
|
|
70
|
+
author_body(issue.get("body")),
|
|
71
|
+
list(transitions),
|
|
72
|
+
policy,
|
|
73
|
+
[
|
|
74
|
+
(
|
|
75
|
+
c["id"],
|
|
76
|
+
c.get("updated_at"),
|
|
77
|
+
c.get("body"),
|
|
78
|
+
(c.get("user") or {}).get("login"),
|
|
79
|
+
)
|
|
80
|
+
for c in comments
|
|
81
|
+
if not owned_comment(c, login) and not readiness_comment(c)
|
|
82
|
+
],
|
|
83
|
+
],
|
|
84
|
+
sort_keys=True,
|
|
85
|
+
).encode()
|
|
86
|
+
).hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_recommendation(value):
|
|
90
|
+
if isinstance(value, str):
|
|
91
|
+
value = json.loads(value)
|
|
92
|
+
required = {"decision", "priority", "scope", "non_goals", "criteria", "questions"}
|
|
93
|
+
if not isinstance(value, dict) or set(value) != required:
|
|
94
|
+
raise ValueError("Recommendation must contain exactly the documented fields")
|
|
95
|
+
if value["decision"] not in ("ready", "decision_needed") or value[
|
|
96
|
+
"priority"
|
|
97
|
+
] not in ("high", "medium", "low"):
|
|
98
|
+
raise ValueError("Invalid decision or priority")
|
|
99
|
+
for key in ("scope", "non_goals"):
|
|
100
|
+
if not isinstance(value[key], str) or not value[key].strip():
|
|
101
|
+
raise ValueError(f"Missing {key}")
|
|
102
|
+
for key in ("criteria", "questions"):
|
|
103
|
+
if not isinstance(value[key], list) or any(
|
|
104
|
+
not isinstance(s, str) or not s.strip() for s in value[key]
|
|
105
|
+
):
|
|
106
|
+
raise ValueError(f"Invalid {key}")
|
|
107
|
+
if value["decision"] == "ready":
|
|
108
|
+
if not value["criteria"] or value["questions"]:
|
|
109
|
+
raise ValueError("Ready requires criteria and no open questions")
|
|
110
|
+
elif not value["questions"] or value["criteria"]:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"Decision needed requires questions and no provisional criteria"
|
|
113
|
+
)
|
|
114
|
+
if "<!--" in json.dumps(value) or len(json.dumps(value)) > 20000:
|
|
115
|
+
raise ValueError("Embedded markers or oversized recommendation")
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def render(body, recommendation, digest):
|
|
120
|
+
r = parse_recommendation(recommendation)
|
|
121
|
+
before, old, after = split_body(body)
|
|
122
|
+
marker = f"<!-- triage-source:{digest} -->"
|
|
123
|
+
if not re.fullmatch(r"[a-f0-9]{64}", digest):
|
|
124
|
+
raise ValueError("Invalid input digest")
|
|
125
|
+
if r["decision"] == "decision_needed":
|
|
126
|
+
return before + after, f"{DISCLOSURE}\n\n### Decision needed\n" + "\n".join(
|
|
127
|
+
f"- {q}" for q in r["questions"]
|
|
128
|
+
) + f"\n\n{marker}"
|
|
129
|
+
section = (
|
|
130
|
+
f"{START}\n## OpenHands AI triage\n{DISCLOSURE}\n\n"
|
|
131
|
+
f"### Scope\n{r['scope']}\n\n### Non-goals\n{r['non_goals']}\n\n"
|
|
132
|
+
"### Acceptance Criteria\n"
|
|
133
|
+
+ "\n".join(f"- [ ] {c}" for c in r["criteria"])
|
|
134
|
+
+ f"\n\n{marker}\n{END}"
|
|
135
|
+
)
|
|
136
|
+
return before + ("" if old else "\n\n") + section + after, None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def writer_policy_allows(repo, login):
|
|
140
|
+
"""Only deployment-audited writer-only policies are supported, never repo code."""
|
|
141
|
+
policy = repo.config.get("triage_readiness_policies", {}).get(repo.repository, {})
|
|
142
|
+
if policy.get("mode") != "authorized-writers" or not policy.get("files"):
|
|
143
|
+
return False
|
|
144
|
+
for path, sha in policy["files"].items():
|
|
145
|
+
if not re.fullmatch(r"[a-f0-9]{40}", sha):
|
|
146
|
+
return False
|
|
147
|
+
current = repo.gh("GET", f"/contents/{quote(path, safe='/')}")
|
|
148
|
+
if current.get("sha") != sha:
|
|
149
|
+
return False
|
|
150
|
+
permission = repo.gh("GET", f"/collaborators/{quote(login, safe='')}/permission")
|
|
151
|
+
return permission.get("permission") in {"write", "maintain", "admin"}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def publish(repo, number, expected, recommendation):
|
|
155
|
+
r = parse_recommendation(recommendation)
|
|
156
|
+
login = repo.api("GET", "/user")["login"]
|
|
157
|
+
path = f"/issues/{number}"
|
|
158
|
+
initial = repo.gh("GET", path)
|
|
159
|
+
comments = repo.gh_pages(path + "/comments")
|
|
160
|
+
if initial.get("state") != "open" or "pull_request" in initial:
|
|
161
|
+
raise ValueError("Issue no longer eligible")
|
|
162
|
+
if (
|
|
163
|
+
fingerprint(
|
|
164
|
+
initial,
|
|
165
|
+
comments,
|
|
166
|
+
login,
|
|
167
|
+
readiness_transitions(repo, number, login),
|
|
168
|
+
repo.config.get("triage_readiness_policies"),
|
|
169
|
+
)
|
|
170
|
+
!= expected
|
|
171
|
+
):
|
|
172
|
+
raise ValueError("Human inputs changed; recompute recommendation")
|
|
173
|
+
body, comment = render(initial.get("body"), r, expected)
|
|
174
|
+
receipt = f"<!-- triage-complete:{expected} -->"
|
|
175
|
+
body = body.replace(receipt, "")
|
|
176
|
+
labels = {label["name"] for label in initial.get("labels", [])}
|
|
177
|
+
writes = 0
|
|
178
|
+
|
|
179
|
+
def guarded(method, target, data=None):
|
|
180
|
+
nonlocal initial, writes
|
|
181
|
+
current = repo.gh("GET", path)
|
|
182
|
+
fresh_comments = repo.gh_pages(path + "/comments")
|
|
183
|
+
if (
|
|
184
|
+
current.get("state") != "open"
|
|
185
|
+
or {l["name"] for l in current.get("labels", [])} != labels
|
|
186
|
+
or current.get("body") != initial.get("body")
|
|
187
|
+
or fingerprint(
|
|
188
|
+
current,
|
|
189
|
+
fresh_comments,
|
|
190
|
+
login,
|
|
191
|
+
readiness_transitions(repo, number, login),
|
|
192
|
+
repo.config.get("triage_readiness_policies"),
|
|
193
|
+
)
|
|
194
|
+
!= expected
|
|
195
|
+
):
|
|
196
|
+
raise ValueError("Inputs changed before write; recompute recommendation")
|
|
197
|
+
result = repo.gh(method, target, data)
|
|
198
|
+
writes += 1
|
|
199
|
+
if target == path and method == "PATCH":
|
|
200
|
+
initial = {**initial, "body": data["body"]}
|
|
201
|
+
if target == path + "/labels" and method == "POST":
|
|
202
|
+
labels.update(data["labels"])
|
|
203
|
+
elif target.startswith(path + "/labels/") and method == "DELETE":
|
|
204
|
+
labels.discard(unquote(target.rsplit("/", 1)[1]))
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
# Revoke readiness before changing its supporting criteria.
|
|
208
|
+
labels = {label["name"] for label in initial.get("labels", [])}
|
|
209
|
+
allowed = r["decision"] == "ready" and writer_policy_allows(repo, login)
|
|
210
|
+
if "ready-for-dev" in labels and r["decision"] == "decision_needed":
|
|
211
|
+
guarded("DELETE", path + "/labels/ready-for-dev")
|
|
212
|
+
completed_body = (
|
|
213
|
+
body.replace(END, receipt + "\n" + END) if r["decision"] == "ready" else body
|
|
214
|
+
)
|
|
215
|
+
if body != (initial.get("body") or "") and completed_body != (
|
|
216
|
+
initial.get("body") or ""
|
|
217
|
+
):
|
|
218
|
+
guarded("PATCH", path, {"body": body})
|
|
219
|
+
own = [c for c in comments if owned_comment(c, login)]
|
|
220
|
+
if comment and not any(
|
|
221
|
+
c.get("body") in (comment, comment + "\n" + receipt) for c in own
|
|
222
|
+
):
|
|
223
|
+
if own:
|
|
224
|
+
guarded("PATCH", f"/issues/comments/{own[0]['id']}", {"body": comment})
|
|
225
|
+
own = own[1:]
|
|
226
|
+
else:
|
|
227
|
+
guarded("POST", path + "/comments", {"body": comment})
|
|
228
|
+
else:
|
|
229
|
+
own = [
|
|
230
|
+
c
|
|
231
|
+
for c in own
|
|
232
|
+
if c.get("body")
|
|
233
|
+
not in (comment, (comment + "\n" + receipt) if comment else None)
|
|
234
|
+
]
|
|
235
|
+
for c in own:
|
|
236
|
+
guarded("DELETE", f"/issues/comments/{c['id']}")
|
|
237
|
+
priority = "priority:" + r["priority"]
|
|
238
|
+
for other in sorted(
|
|
239
|
+
labels
|
|
240
|
+
& {"priority:normal", "priority:high", "priority:medium", "priority:low"}
|
|
241
|
+
- {priority}
|
|
242
|
+
):
|
|
243
|
+
guarded("DELETE", path + "/labels/" + quote(other, safe=""))
|
|
244
|
+
if priority not in labels:
|
|
245
|
+
guarded("POST", path + "/labels", {"labels": [priority]})
|
|
246
|
+
if allowed and "ready-for-dev" not in labels and writer_policy_allows(repo, login):
|
|
247
|
+
guarded("POST", path + "/labels", {"labels": ["ready-for-dev"]})
|
|
248
|
+
if r["decision"] == "ready":
|
|
249
|
+
if completed_body != (initial.get("body") or ""):
|
|
250
|
+
guarded("PATCH", path, {"body": completed_body})
|
|
251
|
+
elif comment:
|
|
252
|
+
current_comments = repo.gh_pages(path + "/comments")
|
|
253
|
+
target = next(
|
|
254
|
+
c
|
|
255
|
+
for c in current_comments
|
|
256
|
+
if owned_comment(c, login) and expected in c.get("body", "")
|
|
257
|
+
)
|
|
258
|
+
completed_comment = comment + "\n" + receipt
|
|
259
|
+
if target.get("body") != completed_comment:
|
|
260
|
+
guarded(
|
|
261
|
+
"PATCH", f"/issues/comments/{target['id']}", {"body": completed_comment}
|
|
262
|
+
)
|
|
263
|
+
return {"writes": writes, "readiness_policy_allowed": allowed}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main():
|
|
267
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
268
|
+
parser.add_argument("--config", required=True)
|
|
269
|
+
parser.add_argument("--repository", required=True)
|
|
270
|
+
parser.add_argument("--number", required=True, type=int)
|
|
271
|
+
parser.add_argument("--expected", required=True)
|
|
272
|
+
parser.add_argument("--recommendation", required=True)
|
|
273
|
+
parser.add_argument("--github-token-secret", required=True)
|
|
274
|
+
args = parser.parse_args()
|
|
275
|
+
config = json.loads(Path(args.config).read_text())
|
|
276
|
+
if args.repository not in (config.get("repos") or [config.get("repository")]):
|
|
277
|
+
raise ValueError("Repository not allowed by deployment")
|
|
278
|
+
repo = GitHubRepository(
|
|
279
|
+
args.config,
|
|
280
|
+
repository=args.repository,
|
|
281
|
+
github_token_secret=args.github_token_secret,
|
|
282
|
+
)
|
|
283
|
+
print(
|
|
284
|
+
json.dumps(
|
|
285
|
+
publish(
|
|
286
|
+
repo, args.number, args.expected, Path(args.recommendation).read_text()
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
main()
|