@openhands/extensions 0.23.0 → 0.25.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 (75) hide show
  1. package/.agents/skills/custom-codereview-guide.md +14 -12
  2. package/.claude-plugin/marketplace.json +20 -0
  3. package/.codex-plugin/marketplace.json +20 -0
  4. package/.github/workflows/check-extensions.yml +6 -6
  5. package/.github/workflows/deprecation-check.yml +1 -1
  6. package/.github/workflows/issue-readiness-check.yml +37 -106
  7. package/.github/workflows/npm-publish.yml +2 -2
  8. package/.github/workflows/pr-description-check.yml +1 -1
  9. package/.github/workflows/pypi-publish.yml +3 -3
  10. package/.github/workflows/tests.yml +2 -2
  11. package/.plugin/marketplace.json +20 -0
  12. package/.release-please-manifest.json +1 -1
  13. package/AGENTS.md +10 -1
  14. package/README.md +4 -2
  15. package/automations/bundle-index.js +11 -7
  16. package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
  17. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
  18. package/automations/catalog-index.js +2 -0
  19. package/integrations/catalog/granola.json +38 -0
  20. package/integrations/catalog-index.js +16 -14
  21. package/marketplaces/openhands-extensions.json +20 -0
  22. package/package.json +1 -1
  23. package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
  24. package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
  25. package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
  26. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +116 -0
  27. package/pyproject.toml +2 -2
  28. package/python/openhands_extensions/_version.py +1 -1
  29. package/skills/add-skill/SKILL.md +5 -2
  30. package/skills/canvas-extension-api/.claude-plugin/plugin.json +19 -0
  31. package/skills/canvas-extension-api/.codex-plugin/plugin.json +19 -0
  32. package/skills/canvas-extension-api/.plugin/plugin.json +19 -0
  33. package/skills/canvas-extension-api/README.md +33 -0
  34. package/skills/canvas-extension-api/SKILL.md +254 -0
  35. package/skills/canvas-extension-api/references/acceptance-checklist.md +20 -0
  36. package/skills/canvas-extension-api/references/backend-safety.md +9 -0
  37. package/skills/canvas-extension-api/references/connections.md +126 -0
  38. package/skills/canvas-extension-api/references/packaging-recipes.md +43 -0
  39. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -0
  40. package/skills/canvas-extension-api/references/testing-and-installation.md +286 -0
  41. package/skills/canvas-extension-api/references/v1-contract.md +250 -0
  42. package/skills/canvas-extension-api/scripts/validate-extension.mjs +92 -0
  43. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +60 -0
  44. package/skills/github/scripts/github_client.py +116 -0
  45. package/skills/github-agents-md-maintainer/scripts/github_client.py +116 -0
  46. package/skills/github-delivery-watchdog/scripts/github_client.py +116 -0
  47. package/skills/github-issue-to-pr/scripts/github_client.py +116 -0
  48. package/skills/github-issue-triage/scripts/github_client.py +116 -0
  49. package/skills/github-pr-review/SKILL.md +12 -2
  50. package/skills/github-pr-reviewer/README.md +41 -1
  51. package/skills/github-pr-reviewer/SKILL.md +180 -5
  52. package/skills/github-pr-reviewer/references/state-schema.md +15 -0
  53. package/skills/github-pr-reviewer/scripts/github_client.py +116 -0
  54. package/skills/github-pr-reviewer/scripts/main.py +57 -12
  55. package/skills/github-pr-reviewer/scripts/worker.py +832 -61
  56. package/skills/github-pr-reviewer/tests/test_main.py +69 -0
  57. package/skills/github-repo-monitor/scripts/github_client.py +116 -0
  58. package/skills/github-repo-monitor/scripts/main.py +32 -1
  59. package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
  60. package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
  61. package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
  62. package/skills/github-stale-ci-pr-closer/README.md +9 -0
  63. package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
  64. package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
  65. package/skills/github-stale-ci-pr-closer/scripts/worker.py +335 -0
  66. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +213 -0
  67. package/skills/index.js +22 -5
  68. package/skills/openhands-sdk/SKILL.md +1 -0
  69. package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
  70. package/tests/test_github_automation_foundation.py +158 -0
  71. package/tests/test_github_repo_monitor.py +107 -0
  72. package/tests/test_github_reviewer_delivery.py +2236 -4
  73. package/.github/scripts/check_issue_readiness.py +0 -272
  74. package/.github/scripts/post-readiness-comment.mjs +0 -184
  75. package/tests/test_check_issue_readiness.py +0 -135
@@ -538,6 +538,75 @@ class TestRepoReviewGuide(unittest.TestCase):
538
538
  self.assertIn("Do not add speculative or out-of-scope notes", prompt)
539
539
  self.assertIn("forbids the configured bot from approving its own PR", prompt)
540
540
 
541
+ def test_prompt_requires_refreshing_mutable_github_state(self):
542
+ """A resumed conversation must not trust an earlier turn's observations."""
543
+ prompt = main._build_review_prompt(
544
+ "owner/repo",
545
+ self._pr(),
546
+ "0123456789abcdef",
547
+ {"id": "1", "created_at": "t"},
548
+ )
549
+
550
+ assert "CURRENT STATE" in prompt
551
+ for surface in (
552
+ "still matches the Head SHA",
553
+ "current PR title and body",
554
+ "current review comments and threads",
555
+ "current review requests",
556
+ "GitHub Actions check results",
557
+ "body and labels of every linked issue",
558
+ ):
559
+ self.assertIn(surface, prompt)
560
+ # The readiness case from the report: a linked issue gaining a label.
561
+ self.assertIn("ready-for-dev", prompt)
562
+
563
+ def test_prompt_forbids_repeating_an_earlier_observation(self):
564
+ prompt = main._build_review_prompt(
565
+ "owner/repo",
566
+ self._pr(),
567
+ "0123456789abcdef",
568
+ {"id": "1", "created_at": "t"},
569
+ )
570
+
571
+ self.assertIn("fresh review", prompt)
572
+ self.assertIn("re-established now", prompt)
573
+ self.assertIn(
574
+ "never repeat an earlier finding, verdict, or "
575
+ "label/priority claim",
576
+ prompt,
577
+ )
578
+
579
+ def test_prompt_refreshes_state_before_the_scope_gate_and_keeps_analysis(self):
580
+ prompt = main._build_review_prompt(
581
+ "owner/repo",
582
+ self._pr(),
583
+ "0123456789abcdef",
584
+ {"id": "1", "created_at": "t"},
585
+ )
586
+
587
+ # The refresh precedes the verdict-shaping steps, while the repository
588
+ # analysis the conversation already did stays part of the workflow.
589
+ self.assertLess(prompt.index("CURRENT STATE"), prompt.index("SCOPE GATE"))
590
+ self.assertLess(prompt.index("CURRENT STATE"), prompt.index("MUST read"))
591
+ self.assertIn("useful background", prompt)
592
+ self.assertIn("AGENTS.md", prompt)
593
+
594
+ def test_prompt_scope_gate_stops_before_the_technical_review(self):
595
+ prompt = main._build_review_prompt(
596
+ "owner/repo",
597
+ self._pr(),
598
+ "0123456789abcdef",
599
+ {"id": "1", "created_at": "t"},
600
+ )
601
+
602
+ gate = prompt.index("SCOPE GATE")
603
+ self.assertLess(gate, prompt.index("Inspect the PR discussion"))
604
+ self.assertLess(gate, prompt.index("Ground every finding"))
605
+ self.assertIn("before inspecting changed files, reading the diff, or running", prompt)
606
+ self.assertIn("scope categories and ownership boundaries", prompt)
607
+ self.assertIn("continue the technical review unchanged", prompt)
608
+ self.assertIn("move repositories, close, or receive a maintainer decision", prompt)
609
+
541
610
 
542
611
  class TestNormalizeRepo(unittest.TestCase):
543
612
  """A repository is written down in more than one way, and every API path in
@@ -165,6 +165,122 @@ class GitHubRepository:
165
165
  result.setdefault(item["context"], item["state"])
166
166
  return result
167
167
 
168
+ def check_runs(self, sha):
169
+ """Return every check run GitHub reported for one commit SHA.
170
+
171
+ Reading check runs needs no branch-protection or ruleset access, so this
172
+ is the head-eligibility signal the automation's own token can always
173
+ see. The endpoint answers with an object rather than a list, so it
174
+ paginates through ``gh`` instead of ``gh_pages``.
175
+ """
176
+ runs = []
177
+ for page in range(1, 101):
178
+ data = self.gh(
179
+ "GET", f"/commits/{sha}/check-runs?per_page=100&page={page}"
180
+ )
181
+ batch = data.get("check_runs") or []
182
+ runs.extend(batch)
183
+ if len(runs) >= int(data.get("total_count") or 0) or not batch:
184
+ return runs
185
+ raise RuntimeError("GitHub check-run pagination exceeded limit")
186
+
187
+ def required_check_contexts(self, number):
188
+ """Return the status contexts GitHub marks required on one pull request.
189
+
190
+ `isRequired` is the merge-policy source of truth and is PR-scoped, so it
191
+ is correct for a stacked PR whose symbolic base branch carries no branch
192
+ rules of its own. It is a field on each context in the head's status
193
+ check rollup and takes the pull request number, so a single GraphQL query
194
+ answers with the required check runs and any required commit statuses,
195
+ each already attributed to the exact head. Only contexts GitHub itself
196
+ reports as required are returned, so an optional workflow that fails
197
+ before creating any check run is not present.
198
+ """
199
+ owner, name = self.repository.split("/", 1)
200
+ query = (
201
+ "query($owner:String!,$name:String!,$number:Int!){"
202
+ "repository(owner:$owner,name:$name){"
203
+ "pullRequest(number:$number){"
204
+ "commits(last:1){"
205
+ "nodes{"
206
+ "commit{"
207
+ "statusCheckRollup{"
208
+ "contexts(first:100){"
209
+ "pageInfo{hasNextPage}"
210
+ "nodes{"
211
+ "__typename"
212
+ " ... on CheckRun{name isRequired(pullRequestNumber:$number)}"
213
+ " ... on StatusContext{context isRequired(pullRequestNumber:$number)}"
214
+ "}"
215
+ "}"
216
+ "}"
217
+ "}"
218
+ "}"
219
+ "}"
220
+ "}"
221
+ "}"
222
+ "}"
223
+ )
224
+ data = self.api(
225
+ "POST",
226
+ "/graphql",
227
+ body={
228
+ "query": query,
229
+ "variables": {"owner": owner, "name": name, "number": number},
230
+ },
231
+ )
232
+ if data.get("errors"):
233
+ raise RuntimeError(
234
+ f"GitHub required-check query failed: {data['errors'][0].get('message')}"
235
+ )
236
+ nodes = (
237
+ (((data.get("data") or {}).get("repository") or {}).get("pullRequest") or {})
238
+ .get("commits", {})
239
+ .get("nodes")
240
+ ) or []
241
+ contexts = (
242
+ ((nodes[0].get("commit") or {}).get("statusCheckRollup") or {}).get(
243
+ "contexts", {}
244
+ )
245
+ if nodes
246
+ else {}
247
+ )
248
+ if (contexts.get("pageInfo") or {}).get("hasNextPage"):
249
+ raise RuntimeError(
250
+ "GitHub required-check rollup exceeds one page; the required "
251
+ "set may be incomplete"
252
+ )
253
+ return [
254
+ {
255
+ "name": node.get("name") or node.get("context"),
256
+ "kind": node.get("__typename"),
257
+ }
258
+ for node in (contexts.get("nodes") or [])
259
+ if node.get("isRequired") and (node.get("name") or node.get("context"))
260
+ ]
261
+
262
+ def workflow_runs(self, sha):
263
+ """Return every Actions workflow run GitHub reported for one commit SHA.
264
+
265
+ A workflow that fails before any job starts - a workflow-level error, or
266
+ a `pull_request` run whose jobs never materialize - still records a
267
+ failed check suite, but it contributes no check runs, so the commit's
268
+ check-run rollup and `gh pr checks` both report success. Reading the
269
+ workflow runs directly is the only way the gate can see that failure.
270
+ Like the check-run endpoint this answers with an object, so it paginates
271
+ manually.
272
+ """
273
+ runs = []
274
+ for page in range(1, 101):
275
+ data = self.gh(
276
+ "GET", f"/actions/runs?head_sha={sha}&per_page=100&page={page}"
277
+ )
278
+ batch = data.get("workflow_runs") or []
279
+ runs.extend(batch)
280
+ if len(runs) >= int(data.get("total_count") or 0) or not batch:
281
+ return runs
282
+ raise RuntimeError("GitHub workflow-run pagination exceeded limit")
283
+
168
284
  def completed_dependency(self, number):
169
285
  if number in self._completed_dependencies:
170
286
  return self._completed_dependencies[number]
@@ -33,6 +33,7 @@ from pathlib import Path
33
33
  import time
34
34
  import urllib.error
35
35
  import urllib.request
36
+ from urllib.parse import quote
36
37
  from datetime import datetime, timedelta, timezone
37
38
 
38
39
  from github_client import github_request as _github_request
@@ -394,11 +395,41 @@ def _fetch_settings(agent_url: str, api_key: str) -> dict:
394
395
  raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc
395
396
 
396
397
 
398
+ def _fetch_profile_llm(agent_url: str, api_key: str, profile: str) -> dict | None:
399
+ """Fetch one LLM profile's config, or None when it no longer exists.
400
+
401
+ Uses X-Expose-Secrets: plaintext so the config carries a real api_key,
402
+ mirroring _fetch_settings. Only 404 falls back; other errors raise.
403
+ """
404
+ req = urllib.request.Request(
405
+ f"{agent_url}/api/profiles/{quote(profile, safe='')}",
406
+ headers={"X-Session-API-Key": api_key, "X-Expose-Secrets": "plaintext"},
407
+ )
408
+ try:
409
+ with urllib.request.urlopen(req) as r:
410
+ return json.loads(r.read())["config"]
411
+ except urllib.error.HTTPError as exc:
412
+ if exc.code == 404:
413
+ print(f"profile {profile!r} not found; falling back to active profile")
414
+ return None
415
+ raise
416
+
417
+
397
418
  def _get_agent_dict(agent_url: str, api_key: str) -> dict:
398
- """Fetch configured agent settings for conversation creation."""
419
+ """Fetch configured agent settings for conversation creation.
420
+
421
+ When AUTOMATION_MODEL names an LLM profile, resolve that profile instead
422
+ of the active one so the automation runs on its configured model.
423
+ """
399
424
  data = _fetch_settings(agent_url, api_key)
400
425
  agent_settings = data.get("agent_settings", {})
401
426
  llm = agent_settings.get("llm", {})
427
+ profile = os.environ.get("AUTOMATION_MODEL")
428
+ if profile:
429
+ profile_llm = _fetch_profile_llm(agent_url, api_key, profile)
430
+ if profile_llm is not None:
431
+ llm = profile_llm
432
+ llm["usage_id"] = f"profile:{profile}"
402
433
  # settings["agent_settings"]["agent"] reflects the full-app agent registry
403
434
  # (e.g. "CodeActAgent", "BrowsingAgent"). The automation SDK is a separate
404
435
  # runtime whose only valid kind is "Agent" — never forward that value.
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "github-stale-ci-pr-closer",
3
+ "version": "1.0.0",
4
+ "description": "Warn and close abandoned pull requests whose required CI remains failing.",
5
+ "author": {
6
+ "name": "OpenHands"
7
+ },
8
+ "license": "MIT"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "github-stale-ci-pr-closer",
3
+ "version": "1.0.0",
4
+ "description": "Warn and close abandoned pull requests whose required CI remains failing.",
5
+ "author": {
6
+ "name": "OpenHands"
7
+ },
8
+ "license": "MIT"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "github-stale-ci-pr-closer",
3
+ "version": "1.0.0",
4
+ "description": "Warn and close abandoned pull requests whose required CI remains failing.",
5
+ "author": {
6
+ "name": "OpenHands"
7
+ },
8
+ "license": "MIT"
9
+ }
@@ -0,0 +1,9 @@
1
+ # GitHub stale CI pull request closer
2
+
3
+ This extension runs a deterministic scheduled maintenance pass over configured
4
+ GitHub repositories. It warns after seven days of continuously failing required
5
+ CI and closes after another seven days without author follow-up. Optional checks
6
+ do not affect the decision, and no LLM slot is used.
7
+
8
+ Conversation and review automation state are unaffected. Warning and closure
9
+ comments carry stable markers so retries remain idempotent.
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: github-stale-ci-pr-closer
3
+ description: Warn and close abandoned pull requests whose required CI remains failing.
4
+ triggers:
5
+ - /github-stale-ci-pr-closer
6
+ ---
7
+
8
+ # GitHub stale CI pull request closer
9
+
10
+ This deterministic scheduled automation scans every open, non-draft pull
11
+ request in its configured repositories. It reads the required status checks
12
+ from the GitHub rules that apply to the pull request's base branch.
13
+
14
+ - After required CI remains failing for seven days, it posts one marked warning.
15
+ - After seven more days, it closes the pull request only when required CI still
16
+ fails and the author has neither pushed nor commented since the warning.
17
+ - Passing or pending CI, a draft conversion, closure, merge, or author follow-up
18
+ cancels the pending close. Continued failure starts a fresh warning window.
19
+
20
+ The automation creates no agent or conversation. Configure a repository-scoped
21
+ fine-grained PAT with Metadata: Read, Actions: Read, Commit statuses: Read,
22
+ Issues: Read and Write, and Pull requests: Read and Write. The token also needs
23
+ read access to repository rules. Package `scripts/worker.py` as `worker.py` and
24
+ the shared `skills/github/scripts/github_client.py` as `github_client.py`.
@@ -0,0 +1,8 @@
1
+ ---
2
+ # auto-generated by sync_extensions.py
3
+ description: Warn and close abandoned pull requests whose required CI remains failing.
4
+ ---
5
+
6
+ Read and follow the complete instructions in the SKILL.md file located in this skill's directory.
7
+
8
+ $ARGUMENTS
@@ -0,0 +1,335 @@
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 UTC, datetime, timedelta
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
+ KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
20
+ KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
21
+
22
+
23
+ def _timestamp(value):
24
+ return datetime.fromisoformat(value).timestamp()
25
+
26
+
27
+ def _state_key(repo):
28
+ payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
29
+ automation_id = payload.get("automation_id", "default")
30
+ return f"github-stale-ci-pr-closer:{automation_id}:{repo.replace('/', '__')}"
31
+
32
+
33
+ def _state_path(repo):
34
+ workspace = Path(
35
+ os.environ.get("WORKSPACE_BASE", Path.home() / ".openhands/workspaces")
36
+ )
37
+ root = (
38
+ workspace.resolve().parent.parent
39
+ if os.environ.get("WORKSPACE_BASE")
40
+ else workspace
41
+ )
42
+ directory = root / "automation-state"
43
+ directory.mkdir(parents=True, exist_ok=True)
44
+ return directory / (_state_key(repo) + ".json")
45
+
46
+
47
+ def _kv_request(key, method, value=None):
48
+ if not KV_TOKEN or not KV_BASE:
49
+ return None
50
+ data = json.dumps({"value": value}).encode() if value is not None else None
51
+ request = Request(
52
+ f"{KV_BASE}/v1/kv/{quote(key, safe='')}",
53
+ data=data,
54
+ method=method,
55
+ headers={
56
+ "Authorization": f"Bearer {KV_TOKEN}",
57
+ "Content-Type": "application/json",
58
+ },
59
+ )
60
+ try:
61
+ with urlopen(request) as response:
62
+ payload = json.loads(response.read() or b"{}")
63
+ return payload.get("value")
64
+ except HTTPError as exc:
65
+ if method == "GET" and exc.code == 404:
66
+ return None
67
+ raise
68
+
69
+
70
+ def load_state(repo):
71
+ if KV_TOKEN and KV_BASE:
72
+ return _kv_request(_state_key(repo), "GET") or {"version": 1, "prs": {}}
73
+ path = _state_path(repo)
74
+ if not path.exists():
75
+ return {"version": 1, "prs": {}}
76
+ return json.loads(path.read_text())
77
+
78
+
79
+ def save_state(repo, state):
80
+ state["version"] = 1
81
+ if KV_TOKEN and KV_BASE:
82
+ _kv_request(_state_key(repo), "PUT", state)
83
+ return
84
+ path = _state_path(repo)
85
+ temporary = path.with_suffix(".tmp")
86
+ temporary.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
87
+ os.replace(temporary, path)
88
+
89
+
90
+ def complete_run(status="COMPLETED", error=None):
91
+ """Report this detached entrypoint's terminal state to Automations."""
92
+ url = os.environ.get("AUTOMATION_CALLBACK_URL")
93
+ if not url:
94
+ return
95
+ body = {"status": status, "run_id": os.environ.get("AUTOMATION_RUN_ID", "")}
96
+ if error:
97
+ body["error"] = error
98
+ request = Request(
99
+ url,
100
+ data=json.dumps(body).encode(),
101
+ headers={
102
+ "Authorization": "Bearer "
103
+ + os.environ.get("AUTOMATION_CALLBACK_API_KEY", ""),
104
+ "Content-Type": "application/json",
105
+ },
106
+ )
107
+ with urlopen(request):
108
+ pass
109
+
110
+
111
+ class StaleCIPullRequestCloser(GitHubRepository):
112
+ name = "github-stale-ci-pr-closer"
113
+
114
+ def required_checks(self, branch):
115
+ cached = getattr(self, "_required_checks", {})
116
+ if branch in cached:
117
+ return cached[branch]
118
+ try:
119
+ rules = self.gh("GET", f"/rules/branches/{quote(branch, safe='')}")
120
+ except HTTPError as exc:
121
+ if exc.code == 404:
122
+ return []
123
+ raise
124
+ required = []
125
+ for rule in rules:
126
+ if rule.get("type") != "required_status_checks":
127
+ continue
128
+ required.extend(
129
+ rule.get("parameters", {}).get("required_status_checks", [])
130
+ )
131
+ cached[branch] = required
132
+ self._required_checks = cached
133
+ return required
134
+
135
+ def required_ci_state(self, sha, required):
136
+ 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}")
149
+ latest_runs = {}
150
+ for run in sorted(runs, key=lambda item: item.get("id", 0), reverse=True):
151
+ latest_runs.setdefault(
152
+ (run.get("name"), (run.get("app") or {}).get("id")), run
153
+ )
154
+ statuses = {}
155
+ for status in self.gh_pages(f"/commits/{sha}/statuses"):
156
+ statuses.setdefault(status.get("context"), status)
157
+
158
+ states = []
159
+ for item in required:
160
+ context = item["context"]
161
+ integration = item.get("integration_id") or None
162
+ run = latest_runs.get((context, integration))
163
+ if run is None and integration is None:
164
+ matches = [
165
+ value for (name, _), value in latest_runs.items() if name == context
166
+ ]
167
+ run = max(matches, key=lambda value: value.get("id", 0), default=None)
168
+ if run is not None:
169
+ if run.get("status") != "completed" or run.get("conclusion") is None:
170
+ states.append("pending")
171
+ elif run.get("conclusion") in PASSING_CONCLUSIONS:
172
+ states.append("passing")
173
+ else:
174
+ states.append("failing")
175
+ continue
176
+ status = statuses.get(context) if integration is None else None
177
+ if status is None or status.get("state") in {"pending", "expected"}:
178
+ states.append("pending")
179
+ elif status.get("state") == "success":
180
+ states.append("passing")
181
+ else:
182
+ states.append("failing")
183
+ if "pending" in states:
184
+ return "pending"
185
+ if "failing" in states:
186
+ return "failing"
187
+ return "passing"
188
+
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()
199
+ for page in range(1, 11):
200
+ result = self.api(
201
+ "GET",
202
+ "/search/issues",
203
+ params={"q": query, "per_page": 100, "page": page},
204
+ )
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")
212
+
213
+ def post_comment(self, number, body):
214
+ return self.gh("POST", f"/issues/{number}/comments", {"body": body})
215
+
216
+ @staticmethod
217
+ def author_followed_up(pr, comments, after):
218
+ author = (pr.get("user") or {}).get("login", "").lower()
219
+ return any(
220
+ (comment.get("user") or {}).get("login", "").lower() == author
221
+ and _timestamp(comment["created_at"]) > after
222
+ for comment in comments
223
+ )
224
+
225
+ def reconcile(self, pr, records, now):
226
+ number = pr["number"]
227
+ key = str(number)
228
+ if pr.get("draft"):
229
+ records.pop(key, None)
230
+ return "draft"
231
+ head = pr["head"]["sha"]
232
+ required = self.required_checks(pr["base"]["ref"])
233
+ ci_state = self.required_ci_state(head, required)
234
+ if ci_state != "failing":
235
+ records.pop(key, None)
236
+ return ci_state
237
+
238
+ record = records.get(key)
239
+ if not record or record.get("head_sha") != head:
240
+ record = {"head_sha": head, "first_failed_at": now}
241
+ records[key] = record
242
+ warning = record.get("warning")
243
+ if not warning:
244
+ if now - record["first_failed_at"] < WARN_AFTER_SECONDS:
245
+ return "observing"
246
+ comments = self.comments(number)
247
+ head_marker = f"<!-- openhands-stale-ci-warning head={head} -->"
248
+ existing = [
249
+ comment
250
+ for comment in comments
251
+ if head_marker in (comment.get("body") or "")
252
+ ]
253
+ existing.sort(
254
+ key=lambda comment: comment.get("created_at", ""), reverse=True
255
+ )
256
+ if existing:
257
+ warning_at = _timestamp(existing[0]["created_at"])
258
+ if warning_at >= record[
259
+ "first_failed_at"
260
+ ] and not self.author_followed_up(pr, comments, warning_at):
261
+ warning = {
262
+ "at": warning_at,
263
+ "head_sha": head,
264
+ "comment_id": existing[0].get("id"),
265
+ }
266
+ record["warning"] = warning
267
+ if not warning:
268
+ comment = self.post_comment(
269
+ number,
270
+ f"{WARNING_MARKER}\n{head_marker}\nRequired CI has remained failing for "
271
+ "seven days. Please repair it or comment with an update. If the author "
272
+ "does not push or comment and required CI remains failing for another "
273
+ "seven days, this pull request will be closed automatically.",
274
+ )
275
+ record["warning"] = {
276
+ "at": now,
277
+ "head_sha": head,
278
+ "comment_id": comment.get("id"),
279
+ }
280
+ return "warned"
281
+
282
+ comments = self.comments(number)
283
+ if self.author_followed_up(pr, comments, warning["at"]):
284
+ records[key] = {"head_sha": head, "first_failed_at": now}
285
+ return "followed-up"
286
+ if now - warning["at"] < CLOSE_AFTER_SECONDS:
287
+ return "waiting"
288
+
289
+ if not any(CLOSE_MARKER in (comment.get("body") or "") for comment in comments):
290
+ self.post_comment(
291
+ number,
292
+ f"{CLOSE_MARKER}\nClosing because required CI is still failing seven days "
293
+ "after the automated warning and the author has not pushed or commented. "
294
+ "The pull request can be reopened when work resumes.",
295
+ )
296
+ self.gh("PATCH", f"/pulls/{number}", {"state": "closed"})
297
+ records.pop(key, None)
298
+ return "closed"
299
+
300
+ def run(self):
301
+ state = load_state(self.repository)
302
+ records = state.setdefault("prs", {})
303
+ open_prs = self.gh_pages("/pulls?state=open")
304
+ open_by_number = {pr["number"]: pr for pr in open_prs}
305
+ open_numbers = {str(number) for number in open_by_number}
306
+ for key in set(records) - open_numbers:
307
+ records.pop(key, None)
308
+ now = time.time()
309
+ candidates = self.stale_failure_numbers(now)
310
+ candidates.update(int(number) for number in records)
311
+ for number in sorted(candidates, reverse=True):
312
+ pr = open_by_number.get(number)
313
+ if pr is None:
314
+ continue
315
+ outcome = self.reconcile(pr, records, now)
316
+ print(
317
+ json.dumps(
318
+ {
319
+ "repository": self.repository,
320
+ "pr": pr["number"],
321
+ "outcome": outcome,
322
+ }
323
+ )
324
+ )
325
+ save_state(self.repository, state)
326
+
327
+
328
+ if __name__ == "__main__":
329
+ try:
330
+ run_repositories(StaleCIPullRequestCloser)
331
+ except Exception as exc:
332
+ complete_run("FAILED", str(exc))
333
+ raise
334
+ else:
335
+ complete_run()