@openhands/extensions 0.25.0 → 0.27.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 (123) hide show
  1. package/.claude-plugin/marketplace.json +24 -0
  2. package/.codex-plugin/marketplace.json +24 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +24 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +9 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/README.md +7 -6
  22. package/automations/bundle-index.js +15 -15
  23. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  24. package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
  25. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
  26. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  27. package/automations/catalog/news-digest/manifest.json +2 -2
  28. package/automations/index.d.ts +9 -1
  29. package/automations/interface.json +9 -0
  30. package/automations/interface.schema.json +37 -2
  31. package/index.d.ts +2 -0
  32. package/index.js +1 -0
  33. package/marketplaces/openhands-extensions.json +24 -0
  34. package/package.json +9 -1
  35. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  36. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  37. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  38. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  39. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  40. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  41. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  42. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  43. package/plugins/runtime-api-configs/README.md +59 -0
  44. package/plugins/runtime-api-configs/SKILL.md +240 -0
  45. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  46. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  47. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  48. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +67 -15
  49. package/pyproject.toml +1 -1
  50. package/python/openhands_extensions/_version.py +1 -1
  51. package/scripts/check_plugin_resources.py +116 -0
  52. package/scripts/sync_extensions.py +32 -5
  53. package/skills/canvas-extension-api/SKILL.md +34 -3
  54. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  55. package/skills/canvas-extension-api/references/connections.md +24 -0
  56. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  57. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  58. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  59. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  60. package/skills/github/scripts/agent_conversation.py +223 -43
  61. package/skills/github/scripts/github_client.py +67 -15
  62. package/skills/github-agents-md-maintainer/README.md +7 -0
  63. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  64. package/skills/github-agents-md-maintainer/scripts/github_client.py +67 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
  67. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  68. package/skills/github-issue-to-pr/scripts/github_client.py +67 -15
  69. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  70. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  71. package/skills/github-issue-triage/scripts/github_client.py +67 -15
  72. package/skills/github-pr-reviewer/README.md +5 -4
  73. package/skills/github-pr-reviewer/SKILL.md +8 -17
  74. package/skills/github-pr-reviewer/references/state-schema.md +0 -15
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
  77. package/skills/github-pr-reviewer/scripts/worker.py +131 -108
  78. package/skills/github-repo-monitor/scripts/github_client.py +67 -15
  79. package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
  80. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
  81. package/skills/gitlab-issue-to-mr/README.md +12 -0
  82. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  83. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  84. package/skills/incident-retrospective/SKILL.md +5 -5
  85. package/skills/index.js +23 -14
  86. package/skills/jira-issue-to-pr/README.md +12 -4
  87. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  88. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  89. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  90. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  91. package/skills/linear-triage/SKILL.md +5 -5
  92. package/skills/news-digest/README.md +6 -0
  93. package/skills/news-digest/SKILL.md +19 -9
  94. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  95. package/skills/news-digest/scripts/main.py +133 -15
  96. package/skills/openhands-api/README.md +1 -1
  97. package/skills/openhands-api/SKILL.md +3 -3
  98. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  99. package/skills/openhands-automation/SKILL.md +66 -4
  100. package/skills/openhands-automation/references/custom-automation.md +21 -7
  101. package/skills/research-brief/SKILL.md +5 -5
  102. package/skills/slack-standup-digest/SKILL.md +5 -5
  103. package/skills/ticket-to-code-change/SKILL.md +6 -3
  104. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  105. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  106. package/skills/use-jev/.plugin/plugin.json +8 -0
  107. package/skills/use-jev/README.md +66 -0
  108. package/skills/use-jev/SKILL.md +66 -0
  109. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  110. package/tests/test_agent_conversation_dispatch.py +212 -0
  111. package/tests/test_apps_catalog.py +26 -0
  112. package/tests/test_automation_setup.py +44 -0
  113. package/tests/test_check_pr_description.py +89 -6
  114. package/tests/test_github_reviewer_delivery.py +254 -118
  115. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  116. package/tests/test_interface_manifest.py +23 -1
  117. package/tests/test_jira_issue_to_pr.py +312 -0
  118. package/tests/test_jira_setup_webhook.py +309 -0
  119. package/tests/test_openhands_api_python.py +37 -0
  120. package/tests/test_skill_plugin_loading.py +25 -15
  121. package/tests/test_sync_extensions.py +102 -0
  122. package/tests/test_vscode_app.py +33 -0
  123. package/tests/vscode-app.test.mjs +43 -0
@@ -3,7 +3,7 @@
3
3
  import json
4
4
  import os
5
5
  import time
6
- from datetime import UTC, datetime, timedelta
6
+ from datetime import datetime
7
7
  from pathlib import Path
8
8
  from urllib.error import HTTPError
9
9
  from urllib.parse import quote
@@ -16,6 +16,38 @@ CLOSE_AFTER_SECONDS = 7 * 24 * 60 * 60
16
16
  WARNING_MARKER = "<!-- openhands-stale-ci-warning -->"
17
17
  CLOSE_MARKER = "<!-- openhands-stale-ci-close -->"
18
18
  PASSING_CONCLUSIONS = {"success", "neutral", "skipped"}
19
+ PULL_REQUESTS_QUERY = """
20
+ query($owner: String!, $name: String!, $cursor: String) {
21
+ repository(owner: $owner, name: $name) {
22
+ pullRequests(first: 40, after: $cursor, states: OPEN) {
23
+ pageInfo { hasNextPage endCursor }
24
+ nodes {
25
+ number isDraft baseRefName headRefOid
26
+ author { login }
27
+ commits(last: 1) {
28
+ nodes {
29
+ commit {
30
+ statusCheckRollup {
31
+ contexts(first: 100) {
32
+ totalCount
33
+ nodes {
34
+ __typename
35
+ ... on CheckRun {
36
+ databaseId name status conclusion startedAt completedAt
37
+ checkSuite { app { databaseId } }
38
+ }
39
+ ... on StatusContext { context state createdAt }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ """
19
51
  KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
20
52
  KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
21
53
 
@@ -95,12 +127,16 @@ def complete_run(status="COMPLETED", error=None):
95
127
  body = {"status": status, "run_id": os.environ.get("AUTOMATION_RUN_ID", "")}
96
128
  if error:
97
129
  body["error"] = error
130
+ # A run on OpenHands Cloud or Enterprise is not given a callback key; it
131
+ # authenticates with its API key.
132
+ api_key = os.environ.get("AUTOMATION_CALLBACK_API_KEY") or os.environ.get(
133
+ "OPENHANDS_API_KEY", ""
134
+ )
98
135
  request = Request(
99
136
  url,
100
137
  data=json.dumps(body).encode(),
101
138
  headers={
102
- "Authorization": "Bearer "
103
- + os.environ.get("AUTOMATION_CALLBACK_API_KEY", ""),
139
+ "Authorization": f"Bearer {api_key}",
104
140
  "Content-Type": "application/json",
105
141
  },
106
142
  )
@@ -132,30 +168,25 @@ class StaleCIPullRequestCloser(GitHubRepository):
132
168
  self._required_checks = cached
133
169
  return required
134
170
 
135
- def required_ci_state(self, sha, required):
171
+ @staticmethod
172
+ def _required_ci_result(runs, statuses, required):
136
173
  if not required:
137
- return "unconfigured"
138
- runs = []
139
- for page in range(1, 11):
140
- batch = self.gh(
141
- "GET", f"/commits/{sha}/check-runs?per_page=100&page={page}"
142
- )
143
- page_runs = batch.get("check_runs", [])
144
- runs.extend(page_runs)
145
- if len(page_runs) < 100:
146
- break
147
- else:
148
- raise RuntimeError(f"More than 1,000 check runs found for {sha}")
174
+ return "unconfigured", None
149
175
  latest_runs = {}
150
176
  for run in sorted(runs, key=lambda item: item.get("id", 0), reverse=True):
151
177
  latest_runs.setdefault(
152
178
  (run.get("name"), (run.get("app") or {}).get("id")), run
153
179
  )
154
- statuses = {}
155
- for status in self.gh_pages(f"/commits/{sha}/statuses"):
156
- statuses.setdefault(status.get("context"), status)
180
+ latest_statuses = {}
181
+ for status in sorted(
182
+ statuses,
183
+ key=lambda item: item.get("updated_at") or item.get("created_at") or "",
184
+ reverse=True,
185
+ ):
186
+ latest_statuses.setdefault(status.get("context"), status)
157
187
 
158
188
  states = []
189
+ failures = []
159
190
  for item in required:
160
191
  context = item["context"]
161
192
  integration = item.get("integration_id") or None
@@ -172,43 +203,143 @@ class StaleCIPullRequestCloser(GitHubRepository):
172
203
  states.append("passing")
173
204
  else:
174
205
  states.append("failing")
206
+ completed = run.get("completed_at") or run.get("started_at")
207
+ if completed:
208
+ failures.append(_timestamp(completed))
175
209
  continue
176
- status = statuses.get(context) if integration is None else None
210
+ status = latest_statuses.get(context) if integration is None else None
177
211
  if status is None or status.get("state") in {"pending", "expected"}:
178
212
  states.append("pending")
179
213
  elif status.get("state") == "success":
180
214
  states.append("passing")
181
215
  else:
182
216
  states.append("failing")
217
+ completed = status.get("updated_at") or status.get("created_at")
218
+ if completed:
219
+ failures.append(_timestamp(completed))
183
220
  if "pending" in states:
184
- return "pending"
221
+ return "pending", None
185
222
  if "failing" in states:
186
- return "failing"
187
- return "passing"
223
+ return "failing", max(failures) if failures else None
224
+ return "passing", None
188
225
 
189
- def comments(self, number):
190
- return self.gh_pages(f"/issues/{number}/comments")
191
-
192
- def stale_failure_numbers(self, now):
193
- cutoff = (datetime.fromtimestamp(now, UTC) - timedelta(days=7)).date()
194
- query = (
195
- f"repo:{self.repository} is:pr is:open draft:false status:failure "
196
- f"updated:<{cutoff.isoformat()}"
197
- )
198
- numbers = set()
226
+ def required_ci_result(self, sha, required):
227
+ if not required:
228
+ return "unconfigured", None
229
+ runs = []
199
230
  for page in range(1, 11):
200
- result = self.api(
201
- "GET",
202
- "/search/issues",
203
- params={"q": query, "per_page": 100, "page": page},
231
+ batch = self.gh(
232
+ "GET", f"/commits/{sha}/check-runs?per_page=100&page={page}"
204
233
  )
205
- if result.get("incomplete_results"):
206
- raise RuntimeError("GitHub returned incomplete stale CI search results")
207
- items = result.get("items", [])
208
- numbers.update(item["number"] for item in items)
209
- if len(items) < 100:
210
- return numbers
211
- raise RuntimeError("GitHub stale CI search exceeded 1,000 pull requests")
234
+ page_runs = batch.get("check_runs", [])
235
+ runs.extend(page_runs)
236
+ if len(page_runs) < 100:
237
+ break
238
+ else:
239
+ raise RuntimeError(f"More than 1,000 check runs found for {sha}")
240
+ statuses = self.gh_pages(f"/commits/{sha}/statuses")
241
+ return self._required_ci_result(runs, statuses, required)
242
+
243
+ def required_ci_state(self, sha, required):
244
+ return self.required_ci_result(sha, required)[0]
245
+
246
+ @staticmethod
247
+ def _graphql_ci_result(contexts, required):
248
+ runs = []
249
+ statuses = []
250
+ for context in contexts:
251
+ if context.get("__typename") == "CheckRun":
252
+ app = ((context.get("checkSuite") or {}).get("app") or {}).get(
253
+ "databaseId"
254
+ )
255
+ runs.append(
256
+ {
257
+ "id": context.get("databaseId"),
258
+ "name": context.get("name"),
259
+ "app": {"id": app},
260
+ "status": (context.get("status") or "").lower(),
261
+ "conclusion": (context.get("conclusion") or "").lower() or None,
262
+ "started_at": context.get("startedAt"),
263
+ "completed_at": context.get("completedAt"),
264
+ }
265
+ )
266
+ elif context.get("__typename") == "StatusContext":
267
+ statuses.append(
268
+ {
269
+ "context": context.get("context"),
270
+ "state": (context.get("state") or "").lower(),
271
+ "created_at": context.get("createdAt"),
272
+ }
273
+ )
274
+ return StaleCIPullRequestCloser._required_ci_result(runs, statuses, required)
275
+
276
+ def open_pull_requests(self):
277
+ """Read every open PR and its latest check rollup with explicit pagination."""
278
+ owner, name = self.repository.split("/", 1)
279
+ cursor = None
280
+ seen_cursors = set()
281
+ pull_requests = []
282
+ while True:
283
+ for attempt in range(4):
284
+ try:
285
+ result = self.api(
286
+ "POST",
287
+ "/graphql",
288
+ body={
289
+ "query": PULL_REQUESTS_QUERY,
290
+ "variables": {
291
+ "owner": owner,
292
+ "name": name,
293
+ "cursor": cursor,
294
+ },
295
+ },
296
+ )
297
+ break
298
+ except HTTPError as exc:
299
+ if exc.code < 500 or attempt == 3:
300
+ raise
301
+ time.sleep(2**attempt)
302
+ if result.get("errors"):
303
+ raise RuntimeError("GitHub GraphQL pull request query failed")
304
+ connection = result["data"]["repository"]["pullRequests"]
305
+ for node in connection["nodes"]:
306
+ commits = node.get("commits", {}).get("nodes", [])
307
+ rollup = (
308
+ ((commits[-1].get("commit") or {}).get("statusCheckRollup") or {})
309
+ if commits
310
+ else {}
311
+ )
312
+ contexts = rollup.get("contexts") or {"nodes": [], "totalCount": 0}
313
+ required = self.required_checks(node["baseRefName"])
314
+ if contexts.get("totalCount", 0) > len(contexts.get("nodes", [])):
315
+ state, failed_at = self.required_ci_result(
316
+ node["headRefOid"], required
317
+ )
318
+ else:
319
+ state, failed_at = self._graphql_ci_result(
320
+ contexts.get("nodes", []), required
321
+ )
322
+ pull_requests.append(
323
+ {
324
+ "number": node["number"],
325
+ "draft": node["isDraft"],
326
+ "head": {"sha": node["headRefOid"]},
327
+ "base": {"ref": node["baseRefName"]},
328
+ "user": {"login": (node.get("author") or {}).get("login", "")},
329
+ "_ci_state": state,
330
+ "_failed_at": failed_at,
331
+ }
332
+ )
333
+ page = connection["pageInfo"]
334
+ if not page["hasNextPage"]:
335
+ return pull_requests
336
+ cursor = page["endCursor"]
337
+ if not cursor or cursor in seen_cursors:
338
+ raise RuntimeError("GitHub returned an invalid pull request cursor")
339
+ seen_cursors.add(cursor)
340
+
341
+ def comments(self, number):
342
+ return self.gh_pages(f"/issues/{number}/comments")
212
343
 
213
344
  def post_comment(self, number, body):
214
345
  return self.gh("POST", f"/issues/{number}/comments", {"body": body})
@@ -222,6 +353,28 @@ class StaleCIPullRequestCloser(GitHubRepository):
222
353
  for comment in comments
223
354
  )
224
355
 
356
+ def _backfill_first_failed_at(self, pr, record):
357
+ """Age a record from the required-check failure, unless it is a reset.
358
+
359
+ Versions before this one stored first *observation* time, so a record
360
+ whose CI had already been failing for longer than we had been watching
361
+ under-counted the window; re-age it to the newest required-check
362
+ failure. Versions before this one also cleared `warning` on author
363
+ follow-up to open a fresh window without leaving a marker, so such a
364
+ reset is indistinguishable from a first sighting by record shape alone
365
+ and is recognized from the comment history instead: the author of the
366
+ pull request commented after the newest required-check failure. Aging
367
+ that record would collapse the fresh window the follow-up just opened,
368
+ so it is left alone.
369
+ """
370
+ failed_at = pr.get("_failed_at")
371
+ if failed_at is None:
372
+ return
373
+ if self.author_followed_up(pr, self.comments(pr["number"]), failed_at):
374
+ record["reset_at"] = record["first_failed_at"]
375
+ return
376
+ record["first_failed_at"] = min(record["first_failed_at"], failed_at)
377
+
225
378
  def reconcile(self, pr, records, now):
226
379
  number = pr["number"]
227
380
  key = str(number)
@@ -229,17 +382,26 @@ class StaleCIPullRequestCloser(GitHubRepository):
229
382
  records.pop(key, None)
230
383
  return "draft"
231
384
  head = pr["head"]["sha"]
232
- required = self.required_checks(pr["base"]["ref"])
233
- ci_state = self.required_ci_state(head, required)
385
+ ci_state = pr.get("_ci_state")
386
+ if ci_state is None:
387
+ required = self.required_checks(pr["base"]["ref"])
388
+ ci_state = self.required_ci_state(head, required)
234
389
  if ci_state != "failing":
235
390
  records.pop(key, None)
236
391
  return ci_state
237
392
 
238
393
  record = records.get(key)
239
394
  if not record or record.get("head_sha") != head:
240
- record = {"head_sha": head, "first_failed_at": now}
395
+ failed_at = pr.get("_failed_at")
396
+ record = {
397
+ "head_sha": head,
398
+ "first_failed_at": now if failed_at is None else failed_at,
399
+ }
241
400
  records[key] = record
401
+
242
402
  warning = record.get("warning")
403
+ if not warning and not record.get("reset_at"):
404
+ self._backfill_first_failed_at(pr, record)
243
405
  if not warning:
244
406
  if now - record["first_failed_at"] < WARN_AFTER_SECONDS:
245
407
  return "observing"
@@ -281,7 +443,11 @@ class StaleCIPullRequestCloser(GitHubRepository):
281
443
 
282
444
  comments = self.comments(number)
283
445
  if self.author_followed_up(pr, comments, warning["at"]):
284
- records[key] = {"head_sha": head, "first_failed_at": now}
446
+ records[key] = {
447
+ "head_sha": head,
448
+ "first_failed_at": now,
449
+ "reset_at": now,
450
+ }
285
451
  return "followed-up"
286
452
  if now - warning["at"] < CLOSE_AFTER_SECONDS:
287
453
  return "waiting"
@@ -300,13 +466,20 @@ class StaleCIPullRequestCloser(GitHubRepository):
300
466
  def run(self):
301
467
  state = load_state(self.repository)
302
468
  records = state.setdefault("prs", {})
303
- open_prs = self.gh_pages("/pulls?state=open")
469
+ open_prs = self.open_pull_requests()
304
470
  open_by_number = {pr["number"]: pr for pr in open_prs}
305
471
  open_numbers = {str(number) for number in open_by_number}
306
472
  for key in set(records) - open_numbers:
307
473
  records.pop(key, None)
308
474
  now = time.time()
309
- candidates = self.stale_failure_numbers(now)
475
+ candidates = {
476
+ pr["number"]
477
+ for pr in open_prs
478
+ if not pr["draft"]
479
+ and pr["_ci_state"] == "failing"
480
+ and pr["_failed_at"] is not None
481
+ and now - pr["_failed_at"] >= WARN_AFTER_SECONDS
482
+ }
310
483
  candidates.update(int(number) for number in records)
311
484
  for number in sorted(candidates, reverse=True):
312
485
  pr = open_by_number.get(number)
@@ -3,6 +3,7 @@ import unittest
3
3
  from os import environ
4
4
  from pathlib import Path
5
5
  from unittest.mock import Mock, patch
6
+ from urllib.error import HTTPError
6
7
 
7
8
  sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
8
9
  sys.path.insert(0, str(Path(__file__).parents[2] / "github" / "scripts"))
@@ -125,6 +126,49 @@ class TestReconcile(unittest.TestCase):
125
126
  self.records["7"], {"head_sha": "def", "first_failed_at": 14 * DAY}
126
127
  )
127
128
 
129
+ def test_old_check_failure_warns_despite_recent_pr_activity(self):
130
+ self.pr["updated_at"] = "1970-01-08T00:00:00Z"
131
+ self.pr["_ci_state"] = "failing"
132
+ self.pr["_failed_at"] = 0
133
+ self.assertEqual(self.reconcile(7 * DAY), "warned")
134
+
135
+ def test_backfills_legacy_observation_time_from_check_failure(self):
136
+ self.records["7"] = {"head_sha": "abc", "first_failed_at": 6 * DAY}
137
+ self.pr["_ci_state"] = "failing"
138
+ self.pr["_failed_at"] = 0
139
+ self.assertEqual(self.reconcile(7 * DAY), "warned")
140
+
141
+ def test_backfill_leaves_a_legacy_follow_up_reset_alone(self):
142
+ # A pre-marker version cleared `warning` on author follow-up to open a
143
+ # fresh window. Re-aging that record to the check failure would collapse
144
+ # the new window and warn inside it.
145
+ self.records["7"] = {"head_sha": "abc", "first_failed_at": 13 * DAY}
146
+ self.pr["_ci_state"] = "failing"
147
+ self.pr["_failed_at"] = 0
148
+ self.closer.comments.return_value = [
149
+ {
150
+ "user": {"login": "author"},
151
+ "created_at": "1970-01-14T00:00:00Z",
152
+ "body": "working",
153
+ }
154
+ ]
155
+ self.assertEqual(self.reconcile(14 * DAY), "observing")
156
+ self.assertEqual(self.records["7"]["first_failed_at"], 13 * DAY)
157
+ self.closer.post_comment.assert_not_called()
158
+
159
+ def test_legacy_follow_up_reset_still_warns_after_a_full_fresh_window(self):
160
+ self.records["7"] = {"head_sha": "abc", "first_failed_at": 13 * DAY}
161
+ self.pr["_ci_state"] = "failing"
162
+ self.pr["_failed_at"] = 0
163
+ self.closer.comments.return_value = [
164
+ {
165
+ "user": {"login": "author"},
166
+ "created_at": "1970-01-14T00:00:00Z",
167
+ "body": "working",
168
+ }
169
+ ]
170
+ self.assertEqual(self.reconcile(20 * DAY), "warned")
171
+
128
172
  def test_draft_cancels_the_lifecycle(self):
129
173
  self.reconcile(0)
130
174
  self.pr["draft"] = True
@@ -185,28 +229,124 @@ class TestRequiredCI(unittest.TestCase):
185
229
  ]
186
230
  self.assertEqual(self.state(runs, required), "pending")
187
231
 
232
+ def test_newest_required_failure_sets_the_age(self):
233
+ contexts = [
234
+ {
235
+ "__typename": "CheckRun",
236
+ "databaseId": 2,
237
+ "name": "a",
238
+ "status": "COMPLETED",
239
+ "conclusion": "FAILURE",
240
+ "completedAt": "1970-01-01T00:00:00Z",
241
+ "checkSuite": {"app": {"databaseId": 1}},
242
+ },
243
+ {
244
+ "__typename": "CheckRun",
245
+ "databaseId": 3,
246
+ "name": "b",
247
+ "status": "COMPLETED",
248
+ "conclusion": "FAILURE",
249
+ "completedAt": "1970-01-03T00:00:00Z",
250
+ "checkSuite": {"app": {"databaseId": 1}},
251
+ },
252
+ ]
253
+ state, failed_at = self.closer._graphql_ci_result(
254
+ contexts,
255
+ [
256
+ {"context": "a", "integration_id": 1},
257
+ {"context": "b", "integration_id": 1},
258
+ ],
259
+ )
260
+ self.assertEqual(state, "failing")
261
+ self.assertEqual(failed_at, 2 * DAY)
262
+
188
263
 
189
- class TestCandidateSearch(unittest.TestCase):
264
+ class TestPullRequestPagination(unittest.TestCase):
190
265
  def setUp(self):
191
266
  self.closer = object.__new__(worker.StaleCIPullRequestCloser)
192
267
  self.closer.repository = "OpenHands/OpenHands"
193
268
 
194
- def test_searches_only_inactive_failing_pull_requests(self):
195
- self.closer.api = Mock(
196
- return_value={
197
- "incomplete_results": False,
198
- "items": [{"number": 2}, {"number": 3}],
269
+ @staticmethod
270
+ def page(number, has_next, cursor):
271
+ return {
272
+ "data": {
273
+ "repository": {
274
+ "pullRequests": {
275
+ "pageInfo": {
276
+ "hasNextPage": has_next,
277
+ "endCursor": cursor,
278
+ },
279
+ "nodes": [
280
+ {
281
+ "number": number,
282
+ "isDraft": False,
283
+ "baseRefName": "main",
284
+ "headRefOid": f"sha-{number}",
285
+ "author": {"login": "author"},
286
+ "commits": {
287
+ "nodes": [
288
+ {
289
+ "commit": {
290
+ "statusCheckRollup": {
291
+ "contexts": {
292
+ "totalCount": 0,
293
+ "nodes": [],
294
+ }
295
+ }
296
+ }
297
+ }
298
+ ]
299
+ },
300
+ }
301
+ ],
302
+ }
303
+ }
199
304
  }
305
+ }
306
+
307
+ def test_paginates_every_open_pull_request(self):
308
+ self.closer.required_checks = Mock(return_value=[])
309
+ self.closer.api = Mock(
310
+ side_effect=[self.page(2, True, "next"), self.page(3, False, None)]
200
311
  )
201
- self.assertEqual(self.closer.stale_failure_numbers(14 * DAY), {2, 3})
202
- params = self.closer.api.call_args.kwargs["params"]
203
- self.assertIn("is:pr is:open draft:false status:failure", params["q"])
204
- self.assertIn("updated:<1970-01-08", params["q"])
205
-
206
- def test_rejects_incomplete_search_results(self):
207
- self.closer.api = Mock(return_value={"incomplete_results": True, "items": []})
208
- with self.assertRaisesRegex(RuntimeError, "incomplete"):
209
- self.closer.stale_failure_numbers(14 * DAY)
312
+ self.assertEqual(
313
+ [pr["number"] for pr in self.closer.open_pull_requests()], [2, 3]
314
+ )
315
+ second_variables = self.closer.api.call_args_list[1].kwargs["body"]["variables"]
316
+ self.assertEqual(second_variables["cursor"], "next")
317
+
318
+ def test_falls_back_to_rest_when_check_contexts_are_paginated(self):
319
+ page = self.page(2, False, None)
320
+ contexts = page["data"]["repository"]["pullRequests"]["nodes"][0]["commits"][
321
+ "nodes"
322
+ ][0]["commit"]["statusCheckRollup"]["contexts"]
323
+ contexts["totalCount"] = 101
324
+ self.closer.required_checks = Mock(return_value=[{"context": "test"}])
325
+ self.closer.required_ci_result = Mock(return_value=("failing", 0))
326
+ self.closer.api = Mock(return_value=page)
327
+
328
+ pull_request = self.closer.open_pull_requests()[0]
329
+
330
+ self.assertEqual(pull_request["_ci_state"], "failing")
331
+ self.assertEqual(pull_request["_failed_at"], 0)
332
+ self.closer.required_ci_result.assert_called_once_with(
333
+ "sha-2", [{"context": "test"}]
334
+ )
335
+
336
+ @patch("worker.time.sleep")
337
+ def test_retries_a_transient_graphql_page_failure(self, sleep):
338
+ self.closer.required_checks = Mock(return_value=[])
339
+ self.closer.api = Mock(
340
+ side_effect=[
341
+ HTTPError(
342
+ "https://api.github.com/graphql", 502, "bad gateway", {}, None
343
+ ),
344
+ self.page(2, False, None),
345
+ ]
346
+ )
347
+
348
+ self.assertEqual(self.closer.open_pull_requests()[0]["number"], 2)
349
+ sleep.assert_called_once_with(1)
210
350
 
211
351
 
212
352
  if __name__ == "__main__":
@@ -53,6 +53,18 @@ conversation never loses the work. `origin` carries no credential, so each GitLa
53
53
  command has to name `GITLAB_TOKEN`, which the SDK injects only into a command
54
54
  that mentions it and masks in the output.
55
55
 
56
+ On OpenHands Cloud and Enterprise the conversation is started through the
57
+ OpenHands API in a sandbox of its own, which changes three things:
58
+
59
+ - The agent clones the project itself; the script keeps no clone.
60
+ - The conversation runs with the user's own settings, so the secret allow-list
61
+ above does not apply: it holds the user's secrets and MCP servers.
62
+ - A conversation that ends without a merge request is reported on the issue. The
63
+ script cannot finish the work, because it has no copy of it.
64
+
65
+ A user with no `GITLAB_TOKEN` secret is served the token of their connected
66
+ GitLab integration instead.
67
+
56
68
  ## Two setup paths
57
69
 
58
70
  The `/issue-to-mr:setup` conversation substitutes the constants at the top of
@@ -252,17 +252,24 @@ Fix any syntax errors before proceeding.
252
252
 
253
253
  ### Step 10 - Package and upload
254
254
 
255
- Determine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`
256
- block in your system context:
257
- - **OPENHANDS_HOST**: the Automation backend `url_from_agent`
258
- - **Auth**: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`
255
+ Set `OPENHANDS_HOST` and `AUTH_HEADER` for the commands below:
256
+ - **OpenHands Cloud or Enterprise** - a `<HOST>` value is in your system prompt:
257
+ use it as `OPENHANDS_HOST`, with `AUTH_HEADER="Authorization: Bearer $OPENHANDS_API_KEY"`
258
+ - **Local Agent Canvas** - an **Automation backend** is listed in the
259
+ `<RUNTIME_SERVICES>` block of your system context: use its `url_from_agent` as
260
+ `OPENHANDS_HOST`, with `AUTH_HEADER="X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY"`
261
+
262
+ On OpenHands Cloud or Enterprise each run starts in a fresh sandbox, so the
263
+ automation keeps its state in the KV store: check that `kvStore` is listed under
264
+ `features` in `GET ${OPENHANDS_HOST}/api/automation/v1/capabilities` before
265
+ deploying.
259
266
 
260
267
  ```bash
261
268
  tar -czf /tmp/issue-to-mr.tar.gz -C /tmp/issue-to-mr-build .
262
269
 
263
270
  TARBALL_PATH=$(curl -s -X POST \
264
271
  "${OPENHANDS_HOST}/api/automation/v1/uploads?name=gitlab-issue-to-mr" \
265
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
272
+ -H "$AUTH_HEADER" \
266
273
  -H "Content-Type: application/gzip" \
267
274
  --data-binary @/tmp/issue-to-mr.tar.gz \
268
275
  | python3 -c "import json,sys; print(json.load(sys.stdin)['tarball_path'])")
@@ -274,7 +281,7 @@ echo "Uploaded: $TARBALL_PATH"
274
281
 
275
282
  ```bash
276
283
  curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1" \
277
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
284
+ -H "$AUTH_HEADER" \
278
285
  -H "Content-Type: application/json" \
279
286
  -d "{
280
287
  \"name\": \"GitLab Issue to MR: {project_summary} label {trigger_label}\",