@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.
- package/.claude-plugin/marketplace.json +30 -0
- package/.codex-plugin/marketplace.json +30 -0
- package/.github/pull_request_template.md +17 -2
- package/.github/scripts/check_pr_description.py +64 -5
- package/.github/scripts/markdown_sections.py +95 -0
- package/.github/workflows/build-vscode-app.yml +30 -0
- package/.github/workflows/check-extensions.yml +3 -0
- package/.github/workflows/tests.yml +26 -0
- package/.plugin/marketplace.json +30 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +10 -3
- package/apps/README.md +20 -0
- package/apps/catalog.json +10 -0
- package/apps/index.d.ts +11 -0
- package/apps/index.js +5 -0
- package/apps/vscode/README.md +13 -0
- package/apps/vscode/canvas-extension.json +19 -0
- package/apps/vscode/extension.js +114 -0
- package/apps/vscode/scripts/package.mjs +84 -0
- package/automations/bundle-index.js +18 -14
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/automations/catalog-index.js +2 -0
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +30 -0
- package/package.json +9 -1
- package/plugins/openhands/skills/openhands-api/README.md +1 -1
- package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
- package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
- package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
- package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/README.md +59 -0
- package/plugins/runtime-api-configs/SKILL.md +240 -0
- package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
- package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/check_plugin_resources.py +116 -0
- package/scripts/sync_extensions.py +32 -5
- package/skills/canvas-extension-api/SKILL.md +34 -3
- package/skills/canvas-extension-api/references/backend-safety.md +14 -0
- package/skills/canvas-extension-api/references/connections.md +24 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
- package/skills/canvas-extension-api/references/v1-contract.md +53 -3
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
- package/skills/github/scripts/agent_conversation.py +223 -43
- package/skills/github/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/README.md +7 -0
- package/skills/github-agents-md-maintainer/SKILL.md +13 -6
- package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/worker.py +10 -1
- package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-triage/scripts/github_client.py +183 -15
- package/skills/github-pr-review/SKILL.md +12 -2
- package/skills/github-pr-reviewer/README.md +36 -0
- package/skills/github-pr-reviewer/SKILL.md +147 -4
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
- package/skills/github-pr-reviewer/scripts/main.py +17 -1
- package/skills/github-pr-reviewer/scripts/worker.py +842 -57
- package/skills/github-repo-monitor/scripts/github_client.py +183 -15
- package/skills/github-repo-monitor/scripts/main.py +32 -1
- package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/README.md +9 -0
- package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
- package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
- package/skills/gitlab-issue-to-mr/README.md +12 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
- package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
- package/skills/incident-retrospective/SKILL.md +5 -5
- package/skills/index.js +34 -16
- package/skills/jira-issue-to-pr/README.md +12 -4
- package/skills/jira-issue-to-pr/SKILL.md +129 -18
- package/skills/jira-issue-to-pr/references/setup.md +8 -0
- package/skills/jira-issue-to-pr/scripts/main.py +181 -54
- package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
- package/skills/linear-triage/SKILL.md +5 -5
- package/skills/news-digest/README.md +6 -0
- package/skills/news-digest/SKILL.md +19 -9
- package/skills/news-digest/commands/news-digest-setup.md +1 -1
- package/skills/news-digest/scripts/main.py +133 -15
- package/skills/openhands-api/README.md +1 -1
- package/skills/openhands-api/SKILL.md +3 -3
- package/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/skills/openhands-automation/SKILL.md +66 -4
- package/skills/openhands-automation/references/custom-automation.md +21 -7
- package/skills/openhands-sdk/SKILL.md +1 -0
- package/skills/research-brief/SKILL.md +5 -5
- package/skills/slack-standup-digest/SKILL.md +5 -5
- package/skills/ticket-to-code-change/SKILL.md +6 -3
- package/skills/use-jev/.claude-plugin/plugin.json +8 -0
- package/skills/use-jev/.codex-plugin/plugin.json +8 -0
- package/skills/use-jev/.plugin/plugin.json +8 -0
- package/skills/use-jev/README.md +66 -0
- package/skills/use-jev/SKILL.md +66 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
- package/tests/test_agent_conversation_dispatch.py +212 -0
- package/tests/test_apps_catalog.py +26 -0
- package/tests/test_automation_setup.py +44 -0
- package/tests/test_check_pr_description.py +89 -6
- package/tests/test_github_automation_foundation.py +158 -0
- package/tests/test_github_repo_monitor.py +107 -0
- package/tests/test_github_reviewer_delivery.py +2247 -6
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_jira_issue_to_pr.py +312 -0
- package/tests/test_jira_setup_webhook.py +309 -0
- package/tests/test_openhands_api_python.py +37 -0
- package/tests/test_skill_plugin_loading.py +25 -15
- package/tests/test_sync_extensions.py +102 -0
- package/tests/test_vscode_app.py +33 -0
- 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()
|