@openhands/extensions 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.claude-plugin/marketplace.json +30 -0
  2. package/.codex-plugin/marketplace.json +30 -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 +30 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +10 -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/bundle-index.js +18 -14
  22. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  23. package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
  24. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
  25. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  26. package/automations/catalog/news-digest/manifest.json +2 -2
  27. package/automations/catalog-index.js +2 -0
  28. package/index.d.ts +2 -0
  29. package/index.js +1 -0
  30. package/marketplaces/openhands-extensions.json +30 -0
  31. package/package.json +9 -1
  32. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  33. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  34. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  35. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  36. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  37. package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
  38. package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
  39. package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
  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 +183 -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 +183 -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 +183 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +183 -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 +183 -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 +183 -15
  72. package/skills/github-pr-review/SKILL.md +12 -2
  73. package/skills/github-pr-reviewer/README.md +36 -0
  74. package/skills/github-pr-reviewer/SKILL.md +147 -4
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
  77. package/skills/github-pr-reviewer/scripts/main.py +17 -1
  78. package/skills/github-pr-reviewer/scripts/worker.py +842 -57
  79. package/skills/github-repo-monitor/scripts/github_client.py +183 -15
  80. package/skills/github-repo-monitor/scripts/main.py +32 -1
  81. package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
  82. package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
  83. package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
  84. package/skills/github-stale-ci-pr-closer/README.md +9 -0
  85. package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
  86. package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
  87. package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
  88. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
  89. package/skills/gitlab-issue-to-mr/README.md +12 -0
  90. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  91. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  92. package/skills/incident-retrospective/SKILL.md +5 -5
  93. package/skills/index.js +34 -16
  94. package/skills/jira-issue-to-pr/README.md +12 -4
  95. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  96. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  97. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  98. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  99. package/skills/linear-triage/SKILL.md +5 -5
  100. package/skills/news-digest/README.md +6 -0
  101. package/skills/news-digest/SKILL.md +19 -9
  102. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  103. package/skills/news-digest/scripts/main.py +133 -15
  104. package/skills/openhands-api/README.md +1 -1
  105. package/skills/openhands-api/SKILL.md +3 -3
  106. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  107. package/skills/openhands-automation/SKILL.md +66 -4
  108. package/skills/openhands-automation/references/custom-automation.md +21 -7
  109. package/skills/openhands-sdk/SKILL.md +1 -0
  110. package/skills/research-brief/SKILL.md +5 -5
  111. package/skills/slack-standup-digest/SKILL.md +5 -5
  112. package/skills/ticket-to-code-change/SKILL.md +6 -3
  113. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  114. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  115. package/skills/use-jev/.plugin/plugin.json +8 -0
  116. package/skills/use-jev/README.md +66 -0
  117. package/skills/use-jev/SKILL.md +66 -0
  118. package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
  119. package/tests/test_agent_conversation_dispatch.py +212 -0
  120. package/tests/test_apps_catalog.py +26 -0
  121. package/tests/test_automation_setup.py +44 -0
  122. package/tests/test_check_pr_description.py +89 -6
  123. package/tests/test_github_automation_foundation.py +158 -0
  124. package/tests/test_github_repo_monitor.py +107 -0
  125. package/tests/test_github_reviewer_delivery.py +2247 -6
  126. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  127. package/tests/test_jira_issue_to_pr.py +312 -0
  128. package/tests/test_jira_setup_webhook.py +309 -0
  129. package/tests/test_openhands_api_python.py +37 -0
  130. package/tests/test_skill_plugin_loading.py +25 -15
  131. package/tests/test_sync_extensions.py +102 -0
  132. package/tests/test_vscode_app.py +33 -0
  133. package/tests/vscode-app.test.mjs +43 -0
@@ -0,0 +1,508 @@
1
+ """Warn and close abandoned pull requests with persistently failing required CI."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from urllib.error import HTTPError
9
+ from urllib.parse import quote
10
+ from urllib.request import Request, urlopen
11
+
12
+ from github_client import GitHubRepository, run_repositories
13
+
14
+ WARN_AFTER_SECONDS = 7 * 24 * 60 * 60
15
+ CLOSE_AFTER_SECONDS = 7 * 24 * 60 * 60
16
+ WARNING_MARKER = "<!-- openhands-stale-ci-warning -->"
17
+ CLOSE_MARKER = "<!-- openhands-stale-ci-close -->"
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
+ """
51
+ KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
52
+ KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
53
+
54
+
55
+ def _timestamp(value):
56
+ return datetime.fromisoformat(value).timestamp()
57
+
58
+
59
+ def _state_key(repo):
60
+ payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
61
+ automation_id = payload.get("automation_id", "default")
62
+ return f"github-stale-ci-pr-closer:{automation_id}:{repo.replace('/', '__')}"
63
+
64
+
65
+ def _state_path(repo):
66
+ workspace = Path(
67
+ os.environ.get("WORKSPACE_BASE", Path.home() / ".openhands/workspaces")
68
+ )
69
+ root = (
70
+ workspace.resolve().parent.parent
71
+ if os.environ.get("WORKSPACE_BASE")
72
+ else workspace
73
+ )
74
+ directory = root / "automation-state"
75
+ directory.mkdir(parents=True, exist_ok=True)
76
+ return directory / (_state_key(repo) + ".json")
77
+
78
+
79
+ def _kv_request(key, method, value=None):
80
+ if not KV_TOKEN or not KV_BASE:
81
+ return None
82
+ data = json.dumps({"value": value}).encode() if value is not None else None
83
+ request = Request(
84
+ f"{KV_BASE}/v1/kv/{quote(key, safe='')}",
85
+ data=data,
86
+ method=method,
87
+ headers={
88
+ "Authorization": f"Bearer {KV_TOKEN}",
89
+ "Content-Type": "application/json",
90
+ },
91
+ )
92
+ try:
93
+ with urlopen(request) as response:
94
+ payload = json.loads(response.read() or b"{}")
95
+ return payload.get("value")
96
+ except HTTPError as exc:
97
+ if method == "GET" and exc.code == 404:
98
+ return None
99
+ raise
100
+
101
+
102
+ def load_state(repo):
103
+ if KV_TOKEN and KV_BASE:
104
+ return _kv_request(_state_key(repo), "GET") or {"version": 1, "prs": {}}
105
+ path = _state_path(repo)
106
+ if not path.exists():
107
+ return {"version": 1, "prs": {}}
108
+ return json.loads(path.read_text())
109
+
110
+
111
+ def save_state(repo, state):
112
+ state["version"] = 1
113
+ if KV_TOKEN and KV_BASE:
114
+ _kv_request(_state_key(repo), "PUT", state)
115
+ return
116
+ path = _state_path(repo)
117
+ temporary = path.with_suffix(".tmp")
118
+ temporary.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
119
+ os.replace(temporary, path)
120
+
121
+
122
+ def complete_run(status="COMPLETED", error=None):
123
+ """Report this detached entrypoint's terminal state to Automations."""
124
+ url = os.environ.get("AUTOMATION_CALLBACK_URL")
125
+ if not url:
126
+ return
127
+ body = {"status": status, "run_id": os.environ.get("AUTOMATION_RUN_ID", "")}
128
+ if error:
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
+ )
135
+ request = Request(
136
+ url,
137
+ data=json.dumps(body).encode(),
138
+ headers={
139
+ "Authorization": f"Bearer {api_key}",
140
+ "Content-Type": "application/json",
141
+ },
142
+ )
143
+ with urlopen(request):
144
+ pass
145
+
146
+
147
+ class StaleCIPullRequestCloser(GitHubRepository):
148
+ name = "github-stale-ci-pr-closer"
149
+
150
+ def required_checks(self, branch):
151
+ cached = getattr(self, "_required_checks", {})
152
+ if branch in cached:
153
+ return cached[branch]
154
+ try:
155
+ rules = self.gh("GET", f"/rules/branches/{quote(branch, safe='')}")
156
+ except HTTPError as exc:
157
+ if exc.code == 404:
158
+ return []
159
+ raise
160
+ required = []
161
+ for rule in rules:
162
+ if rule.get("type") != "required_status_checks":
163
+ continue
164
+ required.extend(
165
+ rule.get("parameters", {}).get("required_status_checks", [])
166
+ )
167
+ cached[branch] = required
168
+ self._required_checks = cached
169
+ return required
170
+
171
+ @staticmethod
172
+ def _required_ci_result(runs, statuses, required):
173
+ if not required:
174
+ return "unconfigured", None
175
+ latest_runs = {}
176
+ for run in sorted(runs, key=lambda item: item.get("id", 0), reverse=True):
177
+ latest_runs.setdefault(
178
+ (run.get("name"), (run.get("app") or {}).get("id")), run
179
+ )
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)
187
+
188
+ states = []
189
+ failures = []
190
+ for item in required:
191
+ context = item["context"]
192
+ integration = item.get("integration_id") or None
193
+ run = latest_runs.get((context, integration))
194
+ if run is None and integration is None:
195
+ matches = [
196
+ value for (name, _), value in latest_runs.items() if name == context
197
+ ]
198
+ run = max(matches, key=lambda value: value.get("id", 0), default=None)
199
+ if run is not None:
200
+ if run.get("status") != "completed" or run.get("conclusion") is None:
201
+ states.append("pending")
202
+ elif run.get("conclusion") in PASSING_CONCLUSIONS:
203
+ states.append("passing")
204
+ else:
205
+ states.append("failing")
206
+ completed = run.get("completed_at") or run.get("started_at")
207
+ if completed:
208
+ failures.append(_timestamp(completed))
209
+ continue
210
+ status = latest_statuses.get(context) if integration is None else None
211
+ if status is None or status.get("state") in {"pending", "expected"}:
212
+ states.append("pending")
213
+ elif status.get("state") == "success":
214
+ states.append("passing")
215
+ else:
216
+ states.append("failing")
217
+ completed = status.get("updated_at") or status.get("created_at")
218
+ if completed:
219
+ failures.append(_timestamp(completed))
220
+ if "pending" in states:
221
+ return "pending", None
222
+ if "failing" in states:
223
+ return "failing", max(failures) if failures else None
224
+ return "passing", None
225
+
226
+ def required_ci_result(self, sha, required):
227
+ if not required:
228
+ return "unconfigured", None
229
+ runs = []
230
+ for page in range(1, 11):
231
+ batch = self.gh(
232
+ "GET", f"/commits/{sha}/check-runs?per_page=100&page={page}"
233
+ )
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")
343
+
344
+ def post_comment(self, number, body):
345
+ return self.gh("POST", f"/issues/{number}/comments", {"body": body})
346
+
347
+ @staticmethod
348
+ def author_followed_up(pr, comments, after):
349
+ author = (pr.get("user") or {}).get("login", "").lower()
350
+ return any(
351
+ (comment.get("user") or {}).get("login", "").lower() == author
352
+ and _timestamp(comment["created_at"]) > after
353
+ for comment in comments
354
+ )
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
+
378
+ def reconcile(self, pr, records, now):
379
+ number = pr["number"]
380
+ key = str(number)
381
+ if pr.get("draft"):
382
+ records.pop(key, None)
383
+ return "draft"
384
+ head = pr["head"]["sha"]
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)
389
+ if ci_state != "failing":
390
+ records.pop(key, None)
391
+ return ci_state
392
+
393
+ record = records.get(key)
394
+ if not record or record.get("head_sha") != head:
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
+ }
400
+ records[key] = record
401
+
402
+ warning = record.get("warning")
403
+ if not warning and not record.get("reset_at"):
404
+ self._backfill_first_failed_at(pr, record)
405
+ if not warning:
406
+ if now - record["first_failed_at"] < WARN_AFTER_SECONDS:
407
+ return "observing"
408
+ comments = self.comments(number)
409
+ head_marker = f"<!-- openhands-stale-ci-warning head={head} -->"
410
+ existing = [
411
+ comment
412
+ for comment in comments
413
+ if head_marker in (comment.get("body") or "")
414
+ ]
415
+ existing.sort(
416
+ key=lambda comment: comment.get("created_at", ""), reverse=True
417
+ )
418
+ if existing:
419
+ warning_at = _timestamp(existing[0]["created_at"])
420
+ if warning_at >= record[
421
+ "first_failed_at"
422
+ ] and not self.author_followed_up(pr, comments, warning_at):
423
+ warning = {
424
+ "at": warning_at,
425
+ "head_sha": head,
426
+ "comment_id": existing[0].get("id"),
427
+ }
428
+ record["warning"] = warning
429
+ if not warning:
430
+ comment = self.post_comment(
431
+ number,
432
+ f"{WARNING_MARKER}\n{head_marker}\nRequired CI has remained failing for "
433
+ "seven days. Please repair it or comment with an update. If the author "
434
+ "does not push or comment and required CI remains failing for another "
435
+ "seven days, this pull request will be closed automatically.",
436
+ )
437
+ record["warning"] = {
438
+ "at": now,
439
+ "head_sha": head,
440
+ "comment_id": comment.get("id"),
441
+ }
442
+ return "warned"
443
+
444
+ comments = self.comments(number)
445
+ if self.author_followed_up(pr, comments, warning["at"]):
446
+ records[key] = {
447
+ "head_sha": head,
448
+ "first_failed_at": now,
449
+ "reset_at": now,
450
+ }
451
+ return "followed-up"
452
+ if now - warning["at"] < CLOSE_AFTER_SECONDS:
453
+ return "waiting"
454
+
455
+ if not any(CLOSE_MARKER in (comment.get("body") or "") for comment in comments):
456
+ self.post_comment(
457
+ number,
458
+ f"{CLOSE_MARKER}\nClosing because required CI is still failing seven days "
459
+ "after the automated warning and the author has not pushed or commented. "
460
+ "The pull request can be reopened when work resumes.",
461
+ )
462
+ self.gh("PATCH", f"/pulls/{number}", {"state": "closed"})
463
+ records.pop(key, None)
464
+ return "closed"
465
+
466
+ def run(self):
467
+ state = load_state(self.repository)
468
+ records = state.setdefault("prs", {})
469
+ open_prs = self.open_pull_requests()
470
+ open_by_number = {pr["number"]: pr for pr in open_prs}
471
+ open_numbers = {str(number) for number in open_by_number}
472
+ for key in set(records) - open_numbers:
473
+ records.pop(key, None)
474
+ now = time.time()
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
+ }
483
+ candidates.update(int(number) for number in records)
484
+ for number in sorted(candidates, reverse=True):
485
+ pr = open_by_number.get(number)
486
+ if pr is None:
487
+ continue
488
+ outcome = self.reconcile(pr, records, now)
489
+ print(
490
+ json.dumps(
491
+ {
492
+ "repository": self.repository,
493
+ "pr": pr["number"],
494
+ "outcome": outcome,
495
+ }
496
+ )
497
+ )
498
+ save_state(self.repository, state)
499
+
500
+
501
+ if __name__ == "__main__":
502
+ try:
503
+ run_repositories(StaleCIPullRequestCloser)
504
+ except Exception as exc:
505
+ complete_run("FAILED", str(exc))
506
+ raise
507
+ else:
508
+ complete_run()