@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.
- package/.claude-plugin/marketplace.json +24 -0
- package/.codex-plugin/marketplace.json +24 -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 +24 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +9 -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/README.md +7 -6
- package/automations/bundle-index.js +15 -15
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/automations/index.d.ts +9 -1
- package/automations/interface.json +9 -0
- package/automations/interface.schema.json +37 -2
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +24 -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/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 +67 -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 +67 -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 +67 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +67 -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 +67 -15
- package/skills/github-pr-reviewer/README.md +5 -4
- package/skills/github-pr-reviewer/SKILL.md +8 -17
- package/skills/github-pr-reviewer/references/state-schema.md +0 -15
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
- package/skills/github-pr-reviewer/scripts/worker.py +131 -108
- package/skills/github-repo-monitor/scripts/github_client.py +67 -15
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
- 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 +23 -14
- 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/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 +9 -9
- 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_reviewer_delivery.py +254 -118
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_interface_manifest.py +23 -1
- 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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import time
|
|
6
|
-
from datetime import
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
for status in
|
|
156
|
-
statuses
|
|
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 =
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
233
|
-
ci_state
|
|
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
|
-
|
|
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] = {
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
"
|
|
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(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
self.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
- **
|
|
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 "
|
|
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 "
|
|
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}\",
|