@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.
- package/.agents/skills/custom-codereview-guide.md +14 -12
- package/.claude-plugin/marketplace.json +20 -0
- package/.codex-plugin/marketplace.json +20 -0
- package/.github/workflows/check-extensions.yml +6 -6
- package/.github/workflows/deprecation-check.yml +1 -1
- package/.github/workflows/issue-readiness-check.yml +37 -106
- package/.github/workflows/npm-publish.yml +2 -2
- package/.github/workflows/pr-description-check.yml +1 -1
- package/.github/workflows/pypi-publish.yml +3 -3
- package/.github/workflows/tests.yml +2 -2
- package/.plugin/marketplace.json +20 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +10 -1
- package/README.md +4 -2
- package/automations/bundle-index.js +11 -7
- 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-index.js +2 -0
- package/integrations/catalog/granola.json +38 -0
- package/integrations/catalog-index.js +16 -14
- package/marketplaces/openhands-extensions.json +20 -0
- package/package.json +1 -1
- 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/vulnerability-remediation/skills/github/scripts/github_client.py +116 -0
- package/pyproject.toml +2 -2
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/add-skill/SKILL.md +5 -2
- package/skills/canvas-extension-api/.claude-plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/.codex-plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/.plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/README.md +33 -0
- package/skills/canvas-extension-api/SKILL.md +254 -0
- package/skills/canvas-extension-api/references/acceptance-checklist.md +20 -0
- package/skills/canvas-extension-api/references/backend-safety.md +9 -0
- package/skills/canvas-extension-api/references/connections.md +126 -0
- package/skills/canvas-extension-api/references/packaging-recipes.md +43 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -0
- package/skills/canvas-extension-api/references/testing-and-installation.md +286 -0
- package/skills/canvas-extension-api/references/v1-contract.md +250 -0
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +92 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +60 -0
- package/skills/github/scripts/github_client.py +116 -0
- package/skills/github-agents-md-maintainer/scripts/github_client.py +116 -0
- package/skills/github-delivery-watchdog/scripts/github_client.py +116 -0
- package/skills/github-issue-to-pr/scripts/github_client.py +116 -0
- package/skills/github-issue-triage/scripts/github_client.py +116 -0
- package/skills/github-pr-review/SKILL.md +12 -2
- package/skills/github-pr-reviewer/README.md +41 -1
- package/skills/github-pr-reviewer/SKILL.md +180 -5
- package/skills/github-pr-reviewer/references/state-schema.md +15 -0
- package/skills/github-pr-reviewer/scripts/github_client.py +116 -0
- package/skills/github-pr-reviewer/scripts/main.py +57 -12
- package/skills/github-pr-reviewer/scripts/worker.py +832 -61
- package/skills/github-pr-reviewer/tests/test_main.py +69 -0
- package/skills/github-repo-monitor/scripts/github_client.py +116 -0
- 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 +335 -0
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +213 -0
- package/skills/index.js +22 -5
- package/skills/openhands-sdk/SKILL.md +1 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
- 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 +2236 -4
- package/.github/scripts/check_issue_readiness.py +0 -272
- package/.github/scripts/post-readiness-comment.mjs +0 -184
- 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
|
+
# 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,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()
|