@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.
Files changed (89) hide show
  1. package/.agents/skills/custom-codereview-guide.md +44 -1
  2. package/.claude-plugin/marketplace.json +7 -5
  3. package/.codex-plugin/marketplace.json +7 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/tests.yml +1 -1
  7. package/.plugin/marketplace.json +7 -5
  8. package/.release-please-manifest.json +1 -1
  9. package/AGENTS.md +7 -2
  10. package/README.md +3 -3
  11. package/apps/vscode/README.md +8 -6
  12. package/apps/vscode/canvas-extension.json +11 -3
  13. package/apps/vscode/extension.js +60 -12
  14. package/apps/vscode/scripts/package.mjs +20 -74
  15. package/automations/bundle-index.js +9 -7
  16. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  17. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  18. package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
  19. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  20. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  21. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  22. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  23. package/integrations/catalog/atlassian-rovo.json +4 -3
  24. package/integrations/catalog/bitbucket.json +16 -1
  25. package/integrations/catalog/github.json +1 -0
  26. package/integrations/catalog/gitlab.json +1 -1
  27. package/marketplaces/openhands-extensions.json +7 -5
  28. package/package.json +1 -1
  29. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +76 -8
  30. package/plugins/pr-review/README.md +1 -0
  31. package/plugins/pr-review/scripts/prompt.py +44 -11
  32. package/plugins/runtime-api-configs/SKILL.md +1 -1
  33. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  34. package/pyproject.toml +1 -1
  35. package/python/openhands_extensions/_version.py +1 -1
  36. package/skills/canvas-extension-api/SKILL.md +4 -1
  37. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  38. package/skills/canvas-extension-api/references/native-design.md +250 -0
  39. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  40. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  41. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  42. package/skills/github/scripts/agent_conversation.py +224 -8
  43. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  44. package/skills/github-issue-triage/README.md +73 -14
  45. package/skills/github-issue-triage/SKILL.md +26 -46
  46. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  47. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  48. package/skills/github-issue-triage/scripts/worker.py +153 -103
  49. package/skills/github-pr-reviewer/README.md +103 -1
  50. package/skills/github-pr-reviewer/SKILL.md +35 -8
  51. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  52. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  53. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  54. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  55. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  56. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  57. package/skills/gitlab-issue-to-mr/README.md +10 -3
  58. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  59. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  60. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  61. package/skills/index.js +10 -10
  62. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  63. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  64. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  65. package/skills/jira-issue-to-pr/README.md +18 -11
  66. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  67. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  68. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  69. package/skills/openhands-automation/references/custom-automation.md +76 -8
  70. package/skills/slack-channel-monitor/README.md +6 -1
  71. package/skills/slack-channel-monitor/SKILL.md +6 -3
  72. package/skills/slack-channel-monitor/scripts/main.py +36 -4
  73. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  74. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  75. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  76. package/skills/ticket-to-code-change/SKILL.md +5 -3
  77. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  78. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  79. package/tests/test_agent_conversation_dispatch.py +173 -6
  80. package/tests/test_automation_llm_provenance.py +1209 -0
  81. package/tests/test_github_review_publication.py +422 -0
  82. package/tests/test_github_reviewer_delivery.py +174 -21
  83. package/tests/test_github_triage_delivery.py +411 -238
  84. package/tests/test_gitlab_issue_to_mr.py +284 -0
  85. package/tests/test_jira_issue_to_pr.py +200 -6
  86. package/tests/test_pr_review_prompt_injection.py +353 -0
  87. package/tests/test_slack_channel_monitor.py +32 -1
  88. package/tests/test_vscode_app.py +8 -2
  89. 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, subject: str, delivery: str, prompt: str, head: str = ""
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(new_id, self.profile_id, subject, prompt)
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="/workspace"),
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)], run=True
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()