@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
|
@@ -166,12 +166,48 @@ def test_reviewer_submits_each_labeled_exact_head(tmp_path, monkeypatch):
|
|
|
166
166
|
assert "GIT_TERMINAL_PROMPT=0" in prompt
|
|
167
167
|
assert "Never paste JSON artifacts" in prompt
|
|
168
168
|
assert "stop immediately" in prompt
|
|
169
|
+
# The prompt carries the dedup rule as well as the worker enforcing it: the
|
|
170
|
+
# agent is the one that reads GitHub before publishing, so it must be told
|
|
171
|
+
# not to publish a second review of a head it already reviewed.
|
|
172
|
+
assert "do NOT publish a second review" in prompt
|
|
173
|
+
assert "currently underway for this head" in prompt
|
|
169
174
|
|
|
170
175
|
|
|
171
|
-
def
|
|
176
|
+
def test_reviewer_prompt_forbids_republishing_an_unchanged_reviewed_head(
|
|
177
|
+
tmp_path, monkeypatch
|
|
178
|
+
):
|
|
179
|
+
"""The reviewer prompt names the head and the no-clarification condition."""
|
|
180
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
181
|
+
pr = {
|
|
182
|
+
"number": 2,
|
|
183
|
+
"head": {"sha": "head-2"},
|
|
184
|
+
"labels": [{"name": "openhands-review"}],
|
|
185
|
+
}
|
|
186
|
+
run.gh_pages = lambda path: [pr]
|
|
187
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
188
|
+
monkeypatch.setattr(
|
|
189
|
+
module.workflow,
|
|
190
|
+
"_latest_trigger_label_event",
|
|
191
|
+
lambda *args: {"id": 7, "created_at": "now"},
|
|
192
|
+
)
|
|
193
|
+
submit = Mock(
|
|
194
|
+
return_value={"disposition": "created", "conversation_id": "conversation"}
|
|
195
|
+
)
|
|
196
|
+
run.dispatcher.deliver = submit
|
|
197
|
+
|
|
198
|
+
run.run()
|
|
199
|
+
|
|
200
|
+
prompt = submit.call_args.kwargs["prompt"]
|
|
201
|
+
assert "already published a review on `head-2`" in prompt
|
|
202
|
+
assert "no one else has commented since that review" in prompt
|
|
203
|
+
assert "Review the head again only when the head has moved" in prompt
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@pytest.mark.parametrize("draft", [False, True])
|
|
207
|
+
def test_reviewer_submits_requested_exact_head(tmp_path, monkeypatch, draft):
|
|
172
208
|
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
173
209
|
_event(monkeypatch)
|
|
174
|
-
pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
|
|
210
|
+
pr = {"number": 2, "head": {"sha": "head-2"}, "labels": [], "draft": draft}
|
|
175
211
|
request = {
|
|
176
212
|
"id": 42,
|
|
177
213
|
"event": "review_requested",
|
|
@@ -374,7 +410,16 @@ def test_reviewer_submitted_review_on_superseded_head_does_not_dispatch(
|
|
|
374
410
|
handoff.assert_not_called()
|
|
375
411
|
|
|
376
412
|
|
|
377
|
-
def
|
|
413
|
+
def test_reviewer_does_not_redeliver_a_label_for_an_already_reviewed_head(
|
|
414
|
+
tmp_path, monkeypatch
|
|
415
|
+
):
|
|
416
|
+
"""A re-applied label on an unchanged, already-reviewed head is not new work.
|
|
417
|
+
|
|
418
|
+
The label event predates the existing review, so `_finish_completed_review`
|
|
419
|
+
would not treat the review as this event's result and the old code dispatched
|
|
420
|
+
a second review of identical code. A review on the current head with nothing
|
|
421
|
+
posted after it is already the answer, whatever trigger arrives next.
|
|
422
|
+
"""
|
|
378
423
|
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
379
424
|
pr = {
|
|
380
425
|
"number": 2,
|
|
@@ -386,6 +431,42 @@ def test_reviewer_redelivers_when_review_predates_latest_label(tmp_path, monkeyp
|
|
|
386
431
|
if path.startswith("/pulls?")
|
|
387
432
|
else _reviews(submitted_at="2025-12-31T00:00:00Z")
|
|
388
433
|
)
|
|
434
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
|
|
435
|
+
run.dispatcher.deliver.return_value = {
|
|
436
|
+
"disposition": "continued",
|
|
437
|
+
"conversation_id": "conversation",
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
run.run()
|
|
441
|
+
|
|
442
|
+
run.dispatcher.deliver.assert_not_called()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def test_reviewer_redelivers_a_label_when_a_human_clarified_since(
|
|
446
|
+
tmp_path, monkeypatch
|
|
447
|
+
):
|
|
448
|
+
"""A comment from someone else after the review makes the head reviewable."""
|
|
449
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
450
|
+
pr = {
|
|
451
|
+
"number": 2,
|
|
452
|
+
"head": {"sha": "head-2"},
|
|
453
|
+
"labels": [{"name": "openhands-review"}],
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
def pages(path):
|
|
457
|
+
if path.startswith("/pulls?"):
|
|
458
|
+
return [pr]
|
|
459
|
+
if path.endswith("/comments"):
|
|
460
|
+
return [
|
|
461
|
+
{
|
|
462
|
+
"body": "I added the missing test you asked for.",
|
|
463
|
+
"created_at": "2026-01-02T00:00:00Z",
|
|
464
|
+
"user": {"login": "neubig"},
|
|
465
|
+
}
|
|
466
|
+
]
|
|
467
|
+
return _reviews(submitted_at="2025-12-31T00:00:00Z")
|
|
468
|
+
|
|
469
|
+
run.gh_pages = pages
|
|
389
470
|
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
390
471
|
run.dispatcher.deliver.return_value = {
|
|
391
472
|
"disposition": "continued",
|
|
@@ -396,6 +477,130 @@ def test_reviewer_redelivers_when_review_predates_latest_label(tmp_path, monkeyp
|
|
|
396
477
|
|
|
397
478
|
run.dispatcher.deliver.assert_called_once()
|
|
398
479
|
assert run.dispatcher.deliver.call_args.kwargs["delivery"] == "7:head-2"
|
|
480
|
+
assert run.dispatcher.deliver.call_args.kwargs["head"] == "head-2"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_reviewer_does_not_re_review_a_head_after_a_repeat_request(
|
|
484
|
+
tmp_path, monkeypatch
|
|
485
|
+
):
|
|
486
|
+
"""The extensions#698 sequence: repeat requests must not review twice.
|
|
487
|
+
|
|
488
|
+
A review landed on `head-2`; a second review request arrived for the same
|
|
489
|
+
head with no clarifying comment in between. The old trigger-keyed delivery
|
|
490
|
+
started another conversation and published a second review of identical
|
|
491
|
+
code (three APPROVED reviews on one head in production). The head is now
|
|
492
|
+
the guard: an unchanged head with nothing posted after the review is done.
|
|
493
|
+
"""
|
|
494
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
495
|
+
pr = _requested_pr(2, "head-2")
|
|
496
|
+
run.check_runs = lambda sha: _checks(("ci", "completed", "success"), sha=sha)
|
|
497
|
+
submitted = Mock(
|
|
498
|
+
return_value={"disposition": "created", "conversation_id": "first"}
|
|
499
|
+
)
|
|
500
|
+
run.dispatcher.deliver = submitted
|
|
501
|
+
|
|
502
|
+
# First request: no review exists yet, so the review is dispatched.
|
|
503
|
+
run.gh_pages = _request_pages(pr, _review_request_event(42), [])
|
|
504
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
505
|
+
_event(monkeypatch)
|
|
506
|
+
run.run()
|
|
507
|
+
assert submitted.call_count == 1
|
|
508
|
+
|
|
509
|
+
# Second request for the same head, and the review is now on GitHub with no
|
|
510
|
+
# comment after it: the repeat request starts nothing. The review predates
|
|
511
|
+
# this request (the #698 ordering), so `_finish_completed_review` alone would
|
|
512
|
+
# not recognize it as this request's result and the old code dispatched again.
|
|
513
|
+
reviewed = _reviews(
|
|
514
|
+
sha="head-2", submitted_at="2026-01-02T00:00:00Z"
|
|
515
|
+
)
|
|
516
|
+
later_request = {**_review_request_event(43), "created_at": "2026-01-03T00:00:00Z"}
|
|
517
|
+
|
|
518
|
+
def pages(path):
|
|
519
|
+
if path.startswith("/pulls?"):
|
|
520
|
+
return [pr]
|
|
521
|
+
if path.endswith("/events"):
|
|
522
|
+
return [later_request]
|
|
523
|
+
if path.endswith("/reviews"):
|
|
524
|
+
return reviewed
|
|
525
|
+
return []
|
|
526
|
+
|
|
527
|
+
run.gh_pages = pages
|
|
528
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
|
|
529
|
+
_event(monkeypatch)
|
|
530
|
+
run.run()
|
|
531
|
+
|
|
532
|
+
assert submitted.call_count == 1
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def test_reviewer_rereviews_a_head_after_a_dismissed_review(
|
|
536
|
+
tmp_path, monkeypatch
|
|
537
|
+
):
|
|
538
|
+
"""A dismissed review does not stand: the head is reviewable again.
|
|
539
|
+
|
|
540
|
+
GitHub keeps a dismissed review in /pulls/{n}/reviews with its commit_id and
|
|
541
|
+
state "DISMISSED". Counting it as a standing review would suppress every
|
|
542
|
+
later review of that head forever, because no other condition clears it. A
|
|
543
|
+
maintainer who dismisses the verdict and re-requests review is asking for a
|
|
544
|
+
fresh look, so the guard must skip dismissed reviews.
|
|
545
|
+
"""
|
|
546
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
547
|
+
pr = {
|
|
548
|
+
"number": 2,
|
|
549
|
+
"head": {"sha": "head-2"},
|
|
550
|
+
"labels": [{"name": "openhands-review"}],
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
def pages(path):
|
|
554
|
+
if path.startswith("/pulls?"):
|
|
555
|
+
return [pr]
|
|
556
|
+
reviews = _reviews(submitted_at="2025-12-31T00:00:00Z")
|
|
557
|
+
reviews[0]["state"] = "DISMISSED"
|
|
558
|
+
return reviews
|
|
559
|
+
|
|
560
|
+
run.gh_pages = pages
|
|
561
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
562
|
+
run.dispatcher.deliver.return_value = {
|
|
563
|
+
"disposition": "continued",
|
|
564
|
+
"conversation_id": "conversation",
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
run.run()
|
|
568
|
+
|
|
569
|
+
run.dispatcher.deliver.assert_called_once()
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def test_reviewer_clears_the_label_when_the_review_predates_the_trigger(
|
|
573
|
+
tmp_path, monkeypatch
|
|
574
|
+
):
|
|
575
|
+
"""The guard must clear the label, not leave a scan re-reading forever.
|
|
576
|
+
|
|
577
|
+
A review predating the re-applied label yields no `completed` entry in
|
|
578
|
+
`_finish_completed_review`, so that call returns before its label DELETE.
|
|
579
|
+
Without clearing the label here the label stays and every later scan re-reads
|
|
580
|
+
this head and re-logs `review-already-published` with no effect.
|
|
581
|
+
"""
|
|
582
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
583
|
+
pr = {
|
|
584
|
+
"number": 2,
|
|
585
|
+
"head": {"sha": "head-2"},
|
|
586
|
+
"labels": [{"name": "openhands-review"}],
|
|
587
|
+
}
|
|
588
|
+
run.gh_pages = lambda path: (
|
|
589
|
+
[pr]
|
|
590
|
+
if path.startswith("/pulls?")
|
|
591
|
+
else _reviews(submitted_at="2025-12-31T00:00:00Z")
|
|
592
|
+
)
|
|
593
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
|
|
594
|
+
|
|
595
|
+
run.run()
|
|
596
|
+
|
|
597
|
+
run.dispatcher.deliver.assert_not_called()
|
|
598
|
+
deleted = [
|
|
599
|
+
call.args[1]
|
|
600
|
+
for call in run.gh.call_args_list
|
|
601
|
+
if call.args[0] == "DELETE"
|
|
602
|
+
]
|
|
603
|
+
assert "/issues/2/labels/openhands-review" in deleted
|
|
399
604
|
|
|
400
605
|
|
|
401
606
|
def test_reviewer_does_not_trust_another_reviewers_verdict(tmp_path, monkeypatch):
|
|
@@ -1565,10 +1770,11 @@ def _request_pages(pr, request, comments):
|
|
|
1565
1770
|
)
|
|
1566
1771
|
|
|
1567
1772
|
|
|
1568
|
-
|
|
1773
|
+
@pytest.mark.parametrize("draft", [False, True])
|
|
1774
|
+
def test_reviewer_scheduled_scan_reviews_an_outstanding_request(tmp_path, monkeypatch, draft):
|
|
1569
1775
|
"""A request made before CI finished is reviewed once the head is green."""
|
|
1570
1776
|
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
1571
|
-
pr = _requested_pr()
|
|
1777
|
+
pr = _requested_pr(draft=draft)
|
|
1572
1778
|
state = {"runs": _checks(("slow-e2e", "queued", None), sha="head-2")}
|
|
1573
1779
|
run.check_runs = lambda sha: state["runs"]
|
|
1574
1780
|
run.gh_pages = _request_pages(pr, _review_request_event(), [])
|
|
@@ -1597,12 +1803,13 @@ def test_reviewer_scheduled_scan_reviews_an_outstanding_request(tmp_path, monkey
|
|
|
1597
1803
|
assert "latest review request for `all-hands-bot` event 42" in call["prompt"]
|
|
1598
1804
|
|
|
1599
1805
|
|
|
1806
|
+
@pytest.mark.parametrize("draft", [False, True])
|
|
1600
1807
|
def test_reviewer_repeated_scans_do_not_duplicate_a_requested_review(
|
|
1601
|
-
tmp_path, monkeypatch
|
|
1808
|
+
tmp_path, monkeypatch, draft
|
|
1602
1809
|
):
|
|
1603
1810
|
"""Two scans over one outstanding request produce one stable delivery key."""
|
|
1604
1811
|
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
1605
|
-
pr = _requested_pr()
|
|
1812
|
+
pr = _requested_pr(draft=draft)
|
|
1606
1813
|
run.check_runs = lambda sha: _checks(("ci", "completed", "success"), sha=sha)
|
|
1607
1814
|
run.dispatcher.deliver.return_value = {
|
|
1608
1815
|
"disposition": "created",
|
|
@@ -1621,11 +1828,12 @@ def test_reviewer_repeated_scans_do_not_duplicate_a_requested_review(
|
|
|
1621
1828
|
assert deliveries == {"42:head-2"}
|
|
1622
1829
|
|
|
1623
1830
|
|
|
1624
|
-
def
|
|
1831
|
+
def test_reviewer_scheduled_scan_ignores_a_draft_requested_from_someone_else(
|
|
1625
1832
|
tmp_path, monkeypatch
|
|
1626
1833
|
):
|
|
1627
1834
|
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
1628
1835
|
pr = _requested_pr(draft=True)
|
|
1836
|
+
pr["requested_reviewers"] = [{"login": "another-reviewer"}]
|
|
1629
1837
|
run.gh_pages = lambda path: [pr]
|
|
1630
1838
|
run.gh = Mock(return_value={"id": 99})
|
|
1631
1839
|
|
|
@@ -1789,17 +1997,21 @@ class _DedupeDispatcher:
|
|
|
1789
1997
|
reproduces only the contract the worker relies on: a delivery keyed by
|
|
1790
1998
|
(subject, delivery) starts one conversation and reports `created` once, then
|
|
1791
1999
|
reports `deduplicated` for the same key. That is what lets a repeat scan
|
|
1792
|
-
reuse a conversation without spending another intake slot.
|
|
2000
|
+
reuse a conversation without spending another intake slot. It also records
|
|
2001
|
+
the head the caller named, which the worker's own pre-dispatch guard uses to
|
|
2002
|
+
suppress a second review of an unchanged head.
|
|
1793
2003
|
"""
|
|
1794
2004
|
|
|
1795
2005
|
def __init__(self):
|
|
1796
2006
|
self.seen = {}
|
|
2007
|
+
self.heads = []
|
|
1797
2008
|
self.calls = []
|
|
1798
2009
|
self.prompts = []
|
|
1799
2010
|
|
|
1800
|
-
def deliver(self, *, subject, delivery, prompt):
|
|
2011
|
+
def deliver(self, *, subject, delivery, prompt, head=""):
|
|
1801
2012
|
self.calls.append((subject, delivery))
|
|
1802
2013
|
self.prompts.append(prompt)
|
|
2014
|
+
self.heads.append(head)
|
|
1803
2015
|
if self.seen.get(subject) == delivery:
|
|
1804
2016
|
return {"disposition": "deduplicated", "conversation_id": subject}
|
|
1805
2017
|
self.seen[subject] = delivery
|
|
@@ -2077,7 +2289,7 @@ def test_a_failing_dispatch_is_reported_without_consuming_a_slot_or_aborting(
|
|
|
2077
2289
|
101,
|
|
2078
2290
|
)
|
|
2079
2291
|
|
|
2080
|
-
def deliver(*, subject, delivery, prompt):
|
|
2292
|
+
def deliver(*, subject, delivery, prompt, head=""):
|
|
2081
2293
|
if subject == "101:pr:5":
|
|
2082
2294
|
raise RuntimeError("agent server unavailable")
|
|
2083
2295
|
one.dispatcher.seen[subject] = delivery
|
|
@@ -2474,32 +2686,10 @@ def test_unrequested_scan_marks_a_self_authored_pr_for_the_comment_verdict(
|
|
|
2474
2686
|
|
|
2475
2687
|
|
|
2476
2688
|
# --------------------------------------------------------------------------- #
|
|
2477
|
-
#
|
|
2478
|
-
# the unrequested backlog, remembers its position in the Automation KV store, and
|
|
2479
|
-
# resumes there on the next scan. Explicit requests are never subject to it.
|
|
2689
|
+
# Comprehensive scheduled scan: inspect the backlog, cap only review launches.
|
|
2480
2690
|
# --------------------------------------------------------------------------- #
|
|
2481
2691
|
|
|
2482
2692
|
|
|
2483
|
-
def _kv_store(module, monkeypatch):
|
|
2484
|
-
"""Back the scan cursor with an in-memory stand-in for the Automation KV store.
|
|
2485
|
-
|
|
2486
|
-
The real store is an HTTP service; this reproduces the contract the cursor
|
|
2487
|
-
relies on - a per-key `GET` that misses with None and a `PUT` that replaces
|
|
2488
|
-
the value - so the shipped `ScanCursor` persists and reloads through it.
|
|
2489
|
-
"""
|
|
2490
|
-
values = {}
|
|
2491
|
-
monkeypatch.setattr(module.workflow, "_kv_available", lambda: True)
|
|
2492
|
-
monkeypatch.setattr(
|
|
2493
|
-
module.workflow, "_kv_get", lambda key: values.get(key)
|
|
2494
|
-
)
|
|
2495
|
-
|
|
2496
|
-
def put(key, value):
|
|
2497
|
-
values[key] = value
|
|
2498
|
-
|
|
2499
|
-
monkeypatch.setattr(module.workflow, "_kv_set", put)
|
|
2500
|
-
return values
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
2693
|
def _many_unrequested(count, *, checks=()):
|
|
2504
2694
|
"""`count` open, non-draft PRs nobody requested, numbered 1..count."""
|
|
2505
2695
|
return [
|
|
@@ -2518,74 +2708,37 @@ def _examined(run):
|
|
|
2518
2708
|
return [int(subject.split(":")[-1]) for subject, _ in run.dispatcher.calls]
|
|
2519
2709
|
|
|
2520
2710
|
|
|
2521
|
-
def
|
|
2522
|
-
tmp_path, monkeypatch
|
|
2523
|
-
):
|
|
2524
|
-
"""Each scan covers the next slice, so the whole backlog is reached in turn."""
|
|
2525
|
-
module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
|
|
2526
|
-
monkeypatch.setattr(module, "SCAN_WINDOW", 2)
|
|
2527
|
-
_kv_store(module, monkeypatch)
|
|
2528
|
-
_wire_scan(one, _many_unrequested(5), 101)
|
|
2529
|
-
|
|
2530
|
-
_run_scan(module, [one])
|
|
2531
|
-
assert _examined(one) == [1, 2]
|
|
2532
|
-
|
|
2533
|
-
_run_scan(module, [one])
|
|
2534
|
-
assert _examined(one)[-2:] == [3, 4]
|
|
2535
|
-
|
|
2536
|
-
_run_scan(module, [one])
|
|
2537
|
-
assert _examined(one)[-1:] == [5]
|
|
2538
|
-
|
|
2539
|
-
# Past the end the window wraps, so the backlog keeps rotating.
|
|
2540
|
-
_run_scan(module, [one])
|
|
2541
|
-
assert _examined(one)[-2:] == [1, 2]
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
def test_the_scan_position_is_persisted_per_repository_in_the_kv_store(
|
|
2711
|
+
def test_scan_skips_blocked_heads_until_it_fills_the_review_quota(
|
|
2545
2712
|
tmp_path, monkeypatch
|
|
2546
2713
|
):
|
|
2547
|
-
"""The
|
|
2714
|
+
"""The quota limits launches, not how many backlog entries are inspected.
|
|
2548
2715
|
|
|
2549
|
-
|
|
2550
|
-
|
|
2716
|
+
Ten blocked heads at the front of the backlog must not consume the ten-review
|
|
2717
|
+
launch quota or prevent the ten green heads behind them from being reviewed.
|
|
2551
2718
|
"""
|
|
2552
|
-
module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
monkeypatch.setattr(module, "SCAN_WINDOW", 1)
|
|
2572
|
-
values = _kv_store(module, monkeypatch)
|
|
2573
|
-
# Only PR #1 holds a request; the rest are unrequested and the window sits on
|
|
2574
|
-
# the last of them, so the request must still be examined.
|
|
2575
|
-
_wire_scan(
|
|
2576
|
-
one,
|
|
2577
|
-
[_eligible_pr(1, "head-1", 100, "2026-01-01T00:00:00Z")]
|
|
2578
|
-
+ _many_unrequested(4)[1:],
|
|
2579
|
-
101,
|
|
2580
|
-
)
|
|
2581
|
-
values["review-scan:owner__one"] = {"cursor": 2}
|
|
2719
|
+
module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=10)
|
|
2720
|
+
blocked = [
|
|
2721
|
+
_unrequested_pr(
|
|
2722
|
+
number,
|
|
2723
|
+
f"head-{number}",
|
|
2724
|
+
created_at=f"2026-01-{number:02d}T00:00:00Z",
|
|
2725
|
+
checks=(("ci", "completed", "failure"),),
|
|
2726
|
+
)
|
|
2727
|
+
for number in range(1, 11)
|
|
2728
|
+
]
|
|
2729
|
+
green = [
|
|
2730
|
+
_unrequested_pr(
|
|
2731
|
+
number,
|
|
2732
|
+
f"head-{number}",
|
|
2733
|
+
created_at=f"2026-01-{number:02d}T00:00:00Z",
|
|
2734
|
+
)
|
|
2735
|
+
for number in range(11, 21)
|
|
2736
|
+
]
|
|
2737
|
+
_wire_scan(one, blocked + green, 101)
|
|
2582
2738
|
|
|
2583
2739
|
_run_scan(module, [one])
|
|
2584
2740
|
|
|
2585
|
-
assert one
|
|
2586
|
-
"101:pr:1": "100:head-1",
|
|
2587
|
-
"101:pr:4": "scan:owner/one:4:head-4",
|
|
2588
|
-
}
|
|
2741
|
+
assert _examined(one) == list(range(11, 21))
|
|
2589
2742
|
|
|
2590
2743
|
|
|
2591
2744
|
def test_unrequested_red_and_pending_prs_post_no_gate_comments(
|
|
@@ -2598,7 +2751,6 @@ def test_unrequested_red_and_pending_prs_post_no_gate_comments(
|
|
|
2598
2751
|
managed explanation.
|
|
2599
2752
|
"""
|
|
2600
2753
|
module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
|
|
2601
|
-
monkeypatch.setattr(module, "SCAN_WINDOW", 20)
|
|
2602
2754
|
_wire_scan(
|
|
2603
2755
|
one,
|
|
2604
2756
|
[
|
|
@@ -2623,36 +2775,20 @@ def test_unrequested_red_and_pending_prs_post_no_gate_comments(
|
|
|
2623
2775
|
assert one.dispatcher.seen == {"101:pr:7": "scan:owner/one:7:head-7"}
|
|
2624
2776
|
|
|
2625
2777
|
|
|
2626
|
-
def
|
|
2778
|
+
def test_scan_reads_every_pull_request_but_starts_only_the_quota(
|
|
2627
2779
|
tmp_path, monkeypatch
|
|
2628
2780
|
):
|
|
2629
|
-
"""
|
|
2630
|
-
|
|
2631
|
-
A full scan would issue one full pull read per open PR - the API cost that
|
|
2632
|
-
made the canary time out. With a window of 10 over 40 PRs, only the 10 in the
|
|
2633
|
-
window are read.
|
|
2634
|
-
"""
|
|
2781
|
+
"""Classification is comprehensive while launches remain bounded."""
|
|
2635
2782
|
module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
|
|
2636
|
-
monkeypatch.setattr(module, "SCAN_WINDOW", 10)
|
|
2637
|
-
_kv_store(module, monkeypatch)
|
|
2638
2783
|
_wire_scan(one, _many_unrequested(40), 101)
|
|
2639
|
-
|
|
2640
|
-
list_reads = []
|
|
2641
|
-
original_pages = one.gh_pages
|
|
2642
|
-
|
|
2643
|
-
def counting_pages(path):
|
|
2644
|
-
if path.startswith("/pulls?"):
|
|
2645
|
-
list_reads.append(path)
|
|
2646
|
-
return original_pages(path)
|
|
2647
|
-
|
|
2648
|
-
one.gh_pages = counting_pages
|
|
2649
2784
|
one.gh = Mock(side_effect=one.gh)
|
|
2650
2785
|
|
|
2651
2786
|
_run_scan(module, [one])
|
|
2652
2787
|
|
|
2653
2788
|
full_reads = [
|
|
2654
|
-
call
|
|
2789
|
+
call
|
|
2790
|
+
for call in one.gh.call_args_list
|
|
2655
2791
|
if call.args[0] == "GET" and str(call.args[1]).startswith("/pulls/")
|
|
2656
2792
|
]
|
|
2657
|
-
assert len(
|
|
2658
|
-
assert
|
|
2793
|
+
assert len(full_reads) == 40
|
|
2794
|
+
assert _examined(one) == [1, 2, 3, 4, 5]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Tests for how the stale-CI pull request closer reports its run."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from github_automation_helpers import worker
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.parametrize(
|
|
10
|
+
("environment", "expected"),
|
|
11
|
+
[
|
|
12
|
+
pytest.param(
|
|
13
|
+
{"AUTOMATION_CALLBACK_API_KEY": "callback-key"},
|
|
14
|
+
"Bearer callback-key",
|
|
15
|
+
id="local-run-uses-its-callback-key",
|
|
16
|
+
),
|
|
17
|
+
pytest.param(
|
|
18
|
+
{"OPENHANDS_API_KEY": "openhands-key"},
|
|
19
|
+
"Bearer openhands-key",
|
|
20
|
+
id="cloud-run-has-no-callback-key-and-uses-its-api-key",
|
|
21
|
+
),
|
|
22
|
+
],
|
|
23
|
+
)
|
|
24
|
+
def test_completion_is_reported_with_the_key_the_run_was_given(
|
|
25
|
+
tmp_path, monkeypatch, environment, expected
|
|
26
|
+
):
|
|
27
|
+
# Arrange
|
|
28
|
+
module = worker("github-stale-ci-pr-closer", tmp_path, monkeypatch)
|
|
29
|
+
for name in ("AUTOMATION_CALLBACK_API_KEY", "OPENHANDS_API_KEY"):
|
|
30
|
+
monkeypatch.delenv(name, raising=False)
|
|
31
|
+
for name, value in environment.items():
|
|
32
|
+
monkeypatch.setenv(name, value)
|
|
33
|
+
monkeypatch.setenv("AUTOMATION_CALLBACK_URL", "https://automation.test/callback")
|
|
34
|
+
monkeypatch.setenv("AUTOMATION_RUN_ID", "run-1")
|
|
35
|
+
sent = []
|
|
36
|
+
|
|
37
|
+
class _Response:
|
|
38
|
+
def __enter__(self):
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def __exit__(self, *args):
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
def urlopen(request):
|
|
45
|
+
sent.append(request)
|
|
46
|
+
return _Response()
|
|
47
|
+
|
|
48
|
+
monkeypatch.setattr(module, "urlopen", urlopen)
|
|
49
|
+
|
|
50
|
+
# Act
|
|
51
|
+
module.complete_run()
|
|
52
|
+
|
|
53
|
+
# Assert
|
|
54
|
+
[request] = sent
|
|
55
|
+
assert request.get_header("Authorization") == expected
|
|
56
|
+
assert json.loads(request.data) == {"status": "COMPLETED", "run_id": "run-1"}
|
|
@@ -30,6 +30,12 @@ def _with(mutation):
|
|
|
30
30
|
return document
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _filter(document, filter_id):
|
|
34
|
+
return next(
|
|
35
|
+
f for f in document["pages"]["list"]["filters"] if f["id"] == filter_id
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
33
39
|
def test_schema_is_a_valid_draft_2020_12_schema():
|
|
34
40
|
jsonschema.Draft202012Validator.check_schema(SCHEMA)
|
|
35
41
|
|
|
@@ -94,7 +100,23 @@ def test_featured_automations_resolve_to_catalog_entries():
|
|
|
94
100
|
),
|
|
95
101
|
(
|
|
96
102
|
"a status filter without the all option",
|
|
97
|
-
lambda doc: doc
|
|
103
|
+
lambda doc: _filter(doc, "status")["options"].pop(0),
|
|
104
|
+
),
|
|
105
|
+
(
|
|
106
|
+
"a creator filter without the all option",
|
|
107
|
+
lambda doc: _filter(doc, "created_by")["options"].pop(0),
|
|
108
|
+
),
|
|
109
|
+
(
|
|
110
|
+
"a creator filter value the host does not implement",
|
|
111
|
+
lambda doc: _filter(doc, "created_by")["options"].append(
|
|
112
|
+
{"value": "team", "label": "My team"}
|
|
113
|
+
),
|
|
114
|
+
),
|
|
115
|
+
(
|
|
116
|
+
"more filters than the host renders",
|
|
117
|
+
lambda doc: doc["pages"]["list"]["filters"].append(
|
|
118
|
+
{**doc["pages"]["list"]["filters"][0], "label": "Filter again"}
|
|
119
|
+
),
|
|
98
120
|
),
|
|
99
121
|
],
|
|
100
122
|
)
|