@openhands/extensions 0.27.0 → 0.29.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 +31 -0
- package/.claude-plugin/marketplace.json +9 -0
- package/.codex-plugin/marketplace.json +9 -0
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/deprecation-check.yml +1 -1
- package/.github/workflows/pypi-publish.yml +2 -2
- package/.github/workflows/tests.yml +1 -1
- package/.plugin/marketplace.json +9 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +7 -2
- package/README.md +3 -2
- package/automations/bundle-index.js +5 -5
- package/automations/catalog/github-issue-to-pr/manifest.json +2 -2
- package/automations/catalog/github-issue-triage/manifest.json +2 -2
- package/automations/catalog/github-pr-reviewer/manifest.json +2 -2
- package/marketplaces/openhands-extensions.json +9 -0
- package/package.json +1 -1
- package/plugins/openhands/skills/openhands-automation/README.md +6 -4
- package/plugins/openhands/skills/openhands-automation/SKILL.md +103 -21
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +79 -12
- package/plugins/openhands/skills/openhands-sdk/SKILL.md +2 -1
- package/plugins/qa-changes/README.md +4 -0
- package/plugins/qa-changes/skills/qa-changes/README.md +3 -1
- package/plugins/qa-changes/skills/qa-changes/SKILL.md +42 -0
- package/plugins/vulnerability-remediation/skills/github/README.md +5 -1
- package/plugins/vulnerability-remediation/skills/github/SKILL.md +27 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +51 -4
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/sync_openhands_sdk_skill.py +16 -13
- package/skills/azure-devops/README.md +3 -1
- package/skills/azure-devops/SKILL.md +4 -0
- package/skills/bitbucket/README.md +3 -1
- package/skills/bitbucket/SKILL.md +4 -2
- package/skills/bitbucket-cloud/README.md +2 -0
- package/skills/bitbucket-cloud/SKILL.md +2 -0
- package/skills/bitbucket-data-center/README.md +2 -0
- package/skills/bitbucket-data-center/SKILL.md +2 -0
- package/skills/canvas-extension-api/SKILL.md +4 -1
- package/skills/canvas-extension-api/references/native-design.md +250 -0
- package/skills/github/README.md +5 -1
- package/skills/github/SKILL.md +27 -0
- package/skills/github/scripts/agent_conversation.py +51 -4
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +51 -4
- package/skills/github-issue-triage/scripts/agent_conversation.py +51 -4
- package/skills/github-pr-reviewer/README.md +5 -0
- package/skills/github-pr-reviewer/SKILL.md +12 -0
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +51 -4
- package/skills/github-pr-reviewer/scripts/main.py +20 -6
- package/skills/github-pr-reviewer/scripts/worker.py +77 -15
- package/skills/github-pr-reviewer/tests/test_main.py +23 -0
- package/skills/gitlab/README.md +3 -1
- package/skills/gitlab/SKILL.md +4 -0
- package/skills/index.js +25 -13
- package/skills/linear/README.md +2 -0
- package/skills/linear/SKILL.md +6 -2
- package/skills/openhands-automation/README.md +6 -4
- package/skills/openhands-automation/SKILL.md +103 -21
- package/skills/openhands-automation/references/custom-automation.md +79 -12
- package/skills/openhands-sdk/SKILL.md +2 -1
- package/skills/qa-changes/README.md +3 -1
- package/skills/qa-changes/SKILL.md +42 -0
- package/skills/simplified-technical-english/.claude-plugin/plugin.json +15 -0
- package/skills/simplified-technical-english/.codex-plugin/plugin.json +15 -0
- package/skills/simplified-technical-english/.plugin/plugin.json +15 -0
- package/skills/simplified-technical-english/README.md +21 -0
- package/skills/simplified-technical-english/SKILL.md +65 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +130 -0
- package/tests/test_github_reviewer_delivery.py +279 -2
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: simplified-technical-english
|
|
3
|
+
description: >
|
|
4
|
+
Write explanations and chat in the style of ASD-STE100 Simplified Technical
|
|
5
|
+
English: short active sentences, one idea each, plain approved words. Use this
|
|
6
|
+
when the reader benefits from maximum clarity: summaries, PR descriptions,
|
|
7
|
+
safety- or ops-critical instructions, or any comment or reply that should be easy
|
|
8
|
+
to read the first time. Applies to PROSE ONLY; it must never change code, identifiers,
|
|
9
|
+
APIs, or commit messages.
|
|
10
|
+
license: MIT
|
|
11
|
+
triggers:
|
|
12
|
+
- simplified technical english
|
|
13
|
+
- STE
|
|
14
|
+
- ASD-STE100
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Simplified Technical English
|
|
18
|
+
|
|
19
|
+
ASD-STE100 Simplified Technical English is a controlled-language standard from
|
|
20
|
+
aerospace and defense. It exists to make technical text easy to read the first
|
|
21
|
+
time, especially for people who read English as a second language. This skill
|
|
22
|
+
adapts its spirit for agent prose.
|
|
23
|
+
|
|
24
|
+
Apply it to your **prose**: chat replies, explanations, summaries, step lists,
|
|
25
|
+
recommendations. The goal is easy reading, not dumbed-down thinking. Open the
|
|
26
|
+
text up and keep the substance.
|
|
27
|
+
|
|
28
|
+
## Core rules
|
|
29
|
+
|
|
30
|
+
- **Prefer short sentences.** Aim for 20 words or fewer, particularly for instructions.
|
|
31
|
+
- **One idea per sentence, one topic per paragraph.** Split a sentence that carries two ideas.
|
|
32
|
+
- **Active voice.** Write "Run the tests", not "The tests should be run".
|
|
33
|
+
- **Plain, consistent words.** Prefer a common word over a fancy one. Use one word for one idea. Do not switch synonyms for the same thing mid-text.
|
|
34
|
+
- **Say the thing directly.** Lead with the answer. Give the number, name, or path. Cut filler like "in terms of", "a range of", "going forward".
|
|
35
|
+
- **Positive instructions.** Tell the reader what to do, not only what to avoid.
|
|
36
|
+
|
|
37
|
+
## The hard boundary: prose, not code
|
|
38
|
+
|
|
39
|
+
A controlled *English* vocabulary
|
|
40
|
+
must never bleed into the code the agent writes.
|
|
41
|
+
|
|
42
|
+
- **Do not** simplify or rename variables, functions, types, files, config keys, CLI flags, API fields, or error strings to obey "approved words". Code needs exact, domain-precise names.
|
|
43
|
+
- **Do not** apply it to commit messages, code comments, or reference/API documentation, where technical precision and established terminology win.
|
|
44
|
+
- **Do** keep using the project's real domain terms in prose. Naming a concept correctly beats naming it "simply".
|
|
45
|
+
|
|
46
|
+
If clarity and a required technical term ever conflict in prose, keep the term
|
|
47
|
+
and add a short plain-English gloss beside it.
|
|
48
|
+
|
|
49
|
+
## When to use it
|
|
50
|
+
|
|
51
|
+
- The user asked for plain or simple or clear text.
|
|
52
|
+
- Instructions where a misread is costly (ops, deploys, security steps).
|
|
53
|
+
- Long or dense explanations that need to be scannable.
|
|
54
|
+
|
|
55
|
+
## When not to use it
|
|
56
|
+
|
|
57
|
+
- Code, identifiers, commit messages, or API/reference docs (see the boundary above).
|
|
58
|
+
- Creative or personal-voice writing, where rhythm and personality matter more than the controlled register.
|
|
59
|
+
- Cases where a precise domain term must stay exact. Keep the term.
|
|
60
|
+
|
|
61
|
+
## Attribution
|
|
62
|
+
|
|
63
|
+
Standard: ASD-STE100 (ASD, the AeroSpace and Defence Industries Association of
|
|
64
|
+
Europe). This skill adapts its principles for agent prose; it is not the
|
|
65
|
+
official specification and does not reproduce its controlled dictionary.
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"timeout": 300,
|
|
69
69
|
"template": {
|
|
70
70
|
"id": "github-pr-reviewer",
|
|
71
|
-
"version": "1.
|
|
71
|
+
"version": "1.9.2",
|
|
72
72
|
"config": {
|
|
73
73
|
"repos": [
|
|
74
74
|
"OpenHands/agent-server-gui"
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
"timeout": 300,
|
|
109
109
|
"template": {
|
|
110
110
|
"id": "github-pr-reviewer",
|
|
111
|
-
"version": "1.
|
|
111
|
+
"version": "1.9.2",
|
|
112
112
|
"config": {
|
|
113
113
|
"repos": [
|
|
114
114
|
"OpenHands/agent-server-gui"
|
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
"timeout": 300,
|
|
214
214
|
"template": {
|
|
215
215
|
"id": "github-pr-reviewer",
|
|
216
|
-
"version": "1.
|
|
216
|
+
"version": "1.9.2",
|
|
217
217
|
"config": {
|
|
218
218
|
"repos": [
|
|
219
219
|
"OpenHands/agent-server-gui",
|
|
@@ -254,7 +254,7 @@
|
|
|
254
254
|
"timeout": 300,
|
|
255
255
|
"template": {
|
|
256
256
|
"id": "github-pr-reviewer",
|
|
257
|
-
"version": "1.
|
|
257
|
+
"version": "1.9.2",
|
|
258
258
|
"config": {
|
|
259
259
|
"repos": [
|
|
260
260
|
"OpenHands/agent-server-gui",
|
|
@@ -296,7 +296,7 @@
|
|
|
296
296
|
"preset_metadata": {
|
|
297
297
|
"template": {
|
|
298
298
|
"id": "github-pr-reviewer",
|
|
299
|
-
"version": "1.
|
|
299
|
+
"version": "1.9.2",
|
|
300
300
|
"config": {
|
|
301
301
|
"repos": [
|
|
302
302
|
"OpenHands/agent-server-gui",
|
|
@@ -351,7 +351,7 @@
|
|
|
351
351
|
"timeout": 300,
|
|
352
352
|
"template": {
|
|
353
353
|
"id": "github-pr-reviewer",
|
|
354
|
-
"version": "1.
|
|
354
|
+
"version": "1.9.2",
|
|
355
355
|
"config": {
|
|
356
356
|
"repos": [
|
|
357
357
|
"OpenHands/agent-server-gui"
|
|
@@ -444,7 +444,7 @@
|
|
|
444
444
|
"timeout": 300,
|
|
445
445
|
"template": {
|
|
446
446
|
"id": "github-pr-reviewer",
|
|
447
|
-
"version": "1.
|
|
447
|
+
"version": "1.9.2",
|
|
448
448
|
"config": {
|
|
449
449
|
"repos": [
|
|
450
450
|
"OpenHands/agent-server-gui"
|
|
@@ -540,7 +540,7 @@
|
|
|
540
540
|
"timeout": 300,
|
|
541
541
|
"template": {
|
|
542
542
|
"id": "github-pr-reviewer",
|
|
543
|
-
"version": "1.
|
|
543
|
+
"version": "1.9.2",
|
|
544
544
|
"config": {
|
|
545
545
|
"repos": [
|
|
546
546
|
"OpenHands/agent-server-gui"
|
|
@@ -616,7 +616,7 @@
|
|
|
616
616
|
"timeout": 300,
|
|
617
617
|
"template": {
|
|
618
618
|
"id": "github-pr-reviewer",
|
|
619
|
-
"version": "1.
|
|
619
|
+
"version": "1.9.2",
|
|
620
620
|
"config": {
|
|
621
621
|
"repos": [
|
|
622
622
|
"OpenHands/automation"
|
|
@@ -263,6 +263,91 @@ def test_new_delivery_on_a_running_new_head_still_sends_a_turn(monkeypatch):
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
|
|
266
|
+
def _errored_local_conversation(monkeypatch):
|
|
267
|
+
monkeypatch.setattr(agent_conversation, "_register_tools", lambda: None)
|
|
268
|
+
workspace = MagicMock()
|
|
269
|
+
workspace.__enter__.return_value = workspace
|
|
270
|
+
workspace.get_secrets.return_value = {}
|
|
271
|
+
monkeypatch.setattr(
|
|
272
|
+
agent_conversation, "RemoteWorkspace", lambda **kwargs: workspace
|
|
273
|
+
)
|
|
274
|
+
conversation = MagicMock()
|
|
275
|
+
conversation.state.execution_status = ConversationExecutionStatus.ERROR
|
|
276
|
+
monkeypatch.setattr(
|
|
277
|
+
agent_conversation.RemoteConversation,
|
|
278
|
+
"attach",
|
|
279
|
+
MagicMock(return_value=conversation),
|
|
280
|
+
)
|
|
281
|
+
return conversation
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_errored_conversation_retries_its_delivery(monkeypatch):
|
|
285
|
+
"""A matched delivery whose conversation died is retried, not deduplicated.
|
|
286
|
+
|
|
287
|
+
The delivery string never changes for a stable subject, so treating ERROR as
|
|
288
|
+
"deduplicated" would strand that subject forever with its work unfinished.
|
|
289
|
+
"""
|
|
290
|
+
state = {
|
|
291
|
+
_state_key("repo:pr:9"): {
|
|
292
|
+
"subject": "repo:pr:9",
|
|
293
|
+
"conversation_id": "22222222-2222-4222-8222-222222222222",
|
|
294
|
+
"delivery": "head-1",
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
_fake_kv(monkeypatch, state)
|
|
298
|
+
conversation = _errored_local_conversation(monkeypatch)
|
|
299
|
+
|
|
300
|
+
with _dispatcher(monkeypatch) as dispatcher:
|
|
301
|
+
result = dispatcher.deliver("repo:pr:9", "head-1", "old")
|
|
302
|
+
|
|
303
|
+
assert result["disposition"] == "retried"
|
|
304
|
+
conversation.update_secrets.assert_called_once_with(dispatcher._secrets)
|
|
305
|
+
conversation.send_message.assert_called_once_with("old")
|
|
306
|
+
conversation.run.assert_called_once_with(blocking=False)
|
|
307
|
+
assert state[_state_key("repo:pr:9")]["error_retries"] == 1
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_errored_conversation_stops_retrying_after_the_bound(monkeypatch):
|
|
311
|
+
"""A conversation that keeps failing is not re-run on every scan."""
|
|
312
|
+
state = {
|
|
313
|
+
_state_key("repo:pr:9"): {
|
|
314
|
+
"subject": "repo:pr:9",
|
|
315
|
+
"conversation_id": "22222222-2222-4222-8222-222222222222",
|
|
316
|
+
"delivery": "head-1",
|
|
317
|
+
"error_retries": agent_conversation._MAX_ERROR_RETRIES,
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
_fake_kv(monkeypatch, state)
|
|
321
|
+
conversation = _errored_local_conversation(monkeypatch)
|
|
322
|
+
|
|
323
|
+
with _dispatcher(monkeypatch) as dispatcher:
|
|
324
|
+
result = dispatcher.deliver("repo:pr:9", "head-1", "old")
|
|
325
|
+
|
|
326
|
+
assert result["disposition"] == "deduplicated"
|
|
327
|
+
conversation.send_message.assert_not_called()
|
|
328
|
+
conversation.run.assert_not_called()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_new_delivery_resets_the_error_retry_count(monkeypatch):
|
|
332
|
+
state = {
|
|
333
|
+
_state_key("repo:pr:9"): {
|
|
334
|
+
"subject": "repo:pr:9",
|
|
335
|
+
"conversation_id": "22222222-2222-4222-8222-222222222222",
|
|
336
|
+
"delivery": "head-1",
|
|
337
|
+
"error_retries": agent_conversation._MAX_ERROR_RETRIES,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
_fake_kv(monkeypatch, state)
|
|
341
|
+
conversation = _errored_local_conversation(monkeypatch)
|
|
342
|
+
|
|
343
|
+
with _dispatcher(monkeypatch) as dispatcher:
|
|
344
|
+
result = dispatcher.deliver("repo:pr:9", "head-2", "new")
|
|
345
|
+
|
|
346
|
+
assert result["disposition"] == "resumed"
|
|
347
|
+
conversation.send_message.assert_called_once_with("new")
|
|
348
|
+
assert "error_retries" not in state[_state_key("repo:pr:9")]
|
|
349
|
+
|
|
350
|
+
|
|
266
351
|
def test_subjects_use_independent_kv_records(monkeypatch):
|
|
267
352
|
state = {}
|
|
268
353
|
_fake_kv(monkeypatch, state)
|
|
@@ -421,3 +506,48 @@ def test_cloud_run_replaces_a_conversation_whose_sandbox_is_gone(monkeypatch):
|
|
|
421
506
|
[started] = cloud.started
|
|
422
507
|
assert started["id"] != gone
|
|
423
508
|
assert state[_state_key("repo:pr:7")]["conversation_id"] == started["id"]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_cloud_run_retries_a_delivery_whose_conversation_errored(monkeypatch):
|
|
512
|
+
# Arrange
|
|
513
|
+
state = {
|
|
514
|
+
_state_key("repo:pr:7"): {
|
|
515
|
+
"conversation_id": "22222222-2222-4222-8222-222222222222",
|
|
516
|
+
"delivery": "revision-1",
|
|
517
|
+
"head": "sha-1",
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
_fake_kv(monkeypatch, state)
|
|
521
|
+
cloud = _FakeCloud({"sandbox_status": "RUNNING", "execution_status": "error"})
|
|
522
|
+
|
|
523
|
+
# Act
|
|
524
|
+
with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
|
|
525
|
+
result = dispatcher.deliver("repo:pr:7", "revision-1", "review it", head="sha-1")
|
|
526
|
+
|
|
527
|
+
# Assert
|
|
528
|
+
assert result["disposition"] == "retried"
|
|
529
|
+
assert cloud.sent == ["review it"]
|
|
530
|
+
assert cloud.started == []
|
|
531
|
+
assert state[_state_key("repo:pr:7")]["error_retries"] == 1
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def test_cloud_run_stops_retrying_an_errored_delivery_after_the_bound(monkeypatch):
|
|
535
|
+
# Arrange
|
|
536
|
+
state = {
|
|
537
|
+
_state_key("repo:pr:7"): {
|
|
538
|
+
"conversation_id": "22222222-2222-4222-8222-222222222222",
|
|
539
|
+
"delivery": "revision-1",
|
|
540
|
+
"head": "sha-1",
|
|
541
|
+
"error_retries": agent_conversation._MAX_ERROR_RETRIES,
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
_fake_kv(monkeypatch, state)
|
|
545
|
+
cloud = _FakeCloud({"sandbox_status": "RUNNING", "execution_status": "error"})
|
|
546
|
+
|
|
547
|
+
# Act
|
|
548
|
+
with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
|
|
549
|
+
result = dispatcher.deliver("repo:pr:7", "revision-1", "review it", head="sha-1")
|
|
550
|
+
|
|
551
|
+
# Assert
|
|
552
|
+
assert result["disposition"] == "deduplicated"
|
|
553
|
+
assert cloud.sent == []
|
|
@@ -17,9 +17,11 @@ def _reviews(
|
|
|
17
17
|
submitted_at="2026-01-02T00:00:00Z",
|
|
18
18
|
sha="head-2",
|
|
19
19
|
login="all-hands-bot",
|
|
20
|
+
review_id=None,
|
|
20
21
|
):
|
|
21
22
|
return [
|
|
22
23
|
{
|
|
24
|
+
"id": review_id,
|
|
23
25
|
"body": f"Review body\n\n{verdict}",
|
|
24
26
|
"commit_id": sha,
|
|
25
27
|
"submitted_at": submitted_at,
|
|
@@ -114,7 +116,9 @@ def _workflow_run(
|
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
|
|
117
|
-
def _event(
|
|
119
|
+
def _event(
|
|
120
|
+
monkeypatch, *, action="review_requested", login="all-hands-bot", review_id=None
|
|
121
|
+
):
|
|
118
122
|
payload = {
|
|
119
123
|
"action": action,
|
|
120
124
|
"repository": {"full_name": "owner/repo"},
|
|
@@ -124,6 +128,8 @@ def _event(monkeypatch, *, action="review_requested", login="all-hands-bot"):
|
|
|
124
128
|
payload["requested_reviewer"] = {"login": login}
|
|
125
129
|
else:
|
|
126
130
|
payload["review"] = {"user": {"login": login}}
|
|
131
|
+
if review_id is not None:
|
|
132
|
+
payload["review"]["id"] = review_id
|
|
127
133
|
monkeypatch.setenv(
|
|
128
134
|
"AUTOMATION_EVENT_PAYLOAD",
|
|
129
135
|
json.dumps({"automation_id": "automation", "event": {"payload": payload}}),
|
|
@@ -358,6 +364,191 @@ def test_reviewer_event_hands_positive_review_to_maintainer(tmp_path, monkeypatc
|
|
|
358
364
|
run.dispatcher.deliver.assert_not_called()
|
|
359
365
|
|
|
360
366
|
|
|
367
|
+
def test_reviewer_delayed_submitted_event_completes_prior_request(
|
|
368
|
+
tmp_path, monkeypatch
|
|
369
|
+
):
|
|
370
|
+
"""The review a submitted delivery reports can predate the latest
|
|
371
|
+
`review_requested` event, and it still completes that work."""
|
|
372
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
373
|
+
_event(monkeypatch, action="submitted", review_id=555)
|
|
374
|
+
run.config["maintainers"] = "neubig, VascoSch92"
|
|
375
|
+
pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
|
|
376
|
+
request = {
|
|
377
|
+
"id": 42,
|
|
378
|
+
"event": "review_requested",
|
|
379
|
+
"created_at": "2026-01-03T00:00:00Z",
|
|
380
|
+
"requested_reviewer": {"login": "all-hands-bot"},
|
|
381
|
+
}
|
|
382
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
|
|
383
|
+
run.gh_pages = lambda path: (
|
|
384
|
+
[request]
|
|
385
|
+
if path.endswith("/events")
|
|
386
|
+
else _reviews(submitted_at="2026-01-01T00:00:00Z", review_id=555)
|
|
387
|
+
)
|
|
388
|
+
handoff = Mock(return_value="VascoSch92")
|
|
389
|
+
monkeypatch.setattr(module, "request_maintainer_review", handoff)
|
|
390
|
+
|
|
391
|
+
run.run()
|
|
392
|
+
|
|
393
|
+
handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
|
|
394
|
+
run.dispatcher.deliver.assert_not_called()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_reviewer_submitted_event_completes_without_request_event(
|
|
398
|
+
tmp_path, monkeypatch
|
|
399
|
+
):
|
|
400
|
+
"""A review whose request is outside the issue-event window still completes
|
|
401
|
+
through its own submitted delivery."""
|
|
402
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
403
|
+
_event(monkeypatch, action="submitted", review_id=555)
|
|
404
|
+
run.config["maintainers"] = "neubig"
|
|
405
|
+
pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
|
|
406
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
|
|
407
|
+
run.gh_pages = lambda path: (
|
|
408
|
+
[] if path.endswith("/events") else _reviews(review_id=555)
|
|
409
|
+
)
|
|
410
|
+
handoff = Mock(return_value="neubig")
|
|
411
|
+
monkeypatch.setattr(module, "request_maintainer_review", handoff)
|
|
412
|
+
|
|
413
|
+
run.run()
|
|
414
|
+
|
|
415
|
+
handoff.assert_called_once_with(run, pr, ["neubig"])
|
|
416
|
+
run.dispatcher.deliver.assert_not_called()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_reviewer_submitted_event_matches_only_the_reported_review(
|
|
420
|
+
tmp_path, monkeypatch
|
|
421
|
+
):
|
|
422
|
+
"""Another decisive review on the head is not the one the delivery reports."""
|
|
423
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
424
|
+
_event(monkeypatch, action="submitted", review_id=556)
|
|
425
|
+
run.config["maintainers"] = "neubig"
|
|
426
|
+
pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
|
|
427
|
+
request = {
|
|
428
|
+
"id": 42,
|
|
429
|
+
"event": "review_requested",
|
|
430
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
431
|
+
"requested_reviewer": {"login": "all-hands-bot"},
|
|
432
|
+
}
|
|
433
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
434
|
+
run.gh_pages = lambda path: (
|
|
435
|
+
[request] if path.endswith("/events") else _reviews(review_id=555)
|
|
436
|
+
)
|
|
437
|
+
handoff = Mock()
|
|
438
|
+
monkeypatch.setattr(module, "request_maintainer_review", handoff)
|
|
439
|
+
|
|
440
|
+
run.run()
|
|
441
|
+
|
|
442
|
+
handoff.assert_not_called()
|
|
443
|
+
run.dispatcher.deliver.assert_not_called()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def test_reviewer_delayed_submitted_approval_yields_to_a_newer_verdict(
|
|
448
|
+
tmp_path, monkeypatch
|
|
449
|
+
):
|
|
450
|
+
"""A late delivery for an earlier approval does not hand off when this
|
|
451
|
+
account has since requested changes on the same head."""
|
|
452
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
453
|
+
_event(monkeypatch, action="submitted", review_id=555)
|
|
454
|
+
run.config["maintainers"] = "neubig"
|
|
455
|
+
pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
|
|
456
|
+
request = {
|
|
457
|
+
"id": 42,
|
|
458
|
+
"event": "review_requested",
|
|
459
|
+
"created_at": "2026-01-03T00:00:00Z",
|
|
460
|
+
"requested_reviewer": {"login": "all-hands-bot"},
|
|
461
|
+
}
|
|
462
|
+
reviews = _reviews(submitted_at="2026-01-02T00:00:00Z", review_id=555) + _reviews(
|
|
463
|
+
verdict="🔄 CHANGES REQUESTED",
|
|
464
|
+
submitted_at="2026-01-04T00:00:00Z",
|
|
465
|
+
review_id=556,
|
|
466
|
+
)
|
|
467
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
|
|
468
|
+
run.gh_pages = lambda path: [request] if path.endswith("/events") else reviews
|
|
469
|
+
handoff = Mock()
|
|
470
|
+
monkeypatch.setattr(module, "request_maintainer_review", handoff)
|
|
471
|
+
|
|
472
|
+
run.run()
|
|
473
|
+
|
|
474
|
+
handoff.assert_not_called()
|
|
475
|
+
run.dispatcher.deliver.assert_not_called()
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def test_reviewer_submitted_event_ignores_a_dismissed_review(tmp_path, monkeypatch):
|
|
479
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
480
|
+
_event(monkeypatch, action="submitted", review_id=555)
|
|
481
|
+
run.config["maintainers"] = "neubig"
|
|
482
|
+
pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
|
|
483
|
+
reviews = _reviews(review_id=555)
|
|
484
|
+
reviews[0]["state"] = "DISMISSED"
|
|
485
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
|
|
486
|
+
run.gh_pages = lambda path: [] if path.endswith("/events") else reviews
|
|
487
|
+
handoff = Mock()
|
|
488
|
+
monkeypatch.setattr(module, "request_maintainer_review", handoff)
|
|
489
|
+
|
|
490
|
+
run.run()
|
|
491
|
+
|
|
492
|
+
handoff.assert_not_called()
|
|
493
|
+
run.dispatcher.deliver.assert_not_called()
|
|
494
|
+
|
|
495
|
+
def test_reviewer_delayed_submitted_event_without_roster_is_a_noop(
|
|
496
|
+
tmp_path, monkeypatch
|
|
497
|
+
):
|
|
498
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
499
|
+
_event(monkeypatch, action="submitted", review_id=555)
|
|
500
|
+
pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
|
|
501
|
+
request = {
|
|
502
|
+
"id": 42,
|
|
503
|
+
"event": "review_requested",
|
|
504
|
+
"created_at": "2026-01-03T00:00:00Z",
|
|
505
|
+
"requested_reviewer": {"login": "all-hands-bot"},
|
|
506
|
+
}
|
|
507
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
|
|
508
|
+
run.gh_pages = lambda path: (
|
|
509
|
+
[request]
|
|
510
|
+
if path.endswith("/events")
|
|
511
|
+
else _reviews(submitted_at="2026-01-01T00:00:00Z", review_id=555)
|
|
512
|
+
)
|
|
513
|
+
handoff = Mock()
|
|
514
|
+
monkeypatch.setattr(module, "request_maintainer_review", handoff)
|
|
515
|
+
|
|
516
|
+
run.run()
|
|
517
|
+
|
|
518
|
+
handoff.assert_not_called()
|
|
519
|
+
run.dispatcher.deliver.assert_not_called()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def test_reviewer_subsequent_review_request_for_new_head_starts_new_review(
|
|
523
|
+
tmp_path, monkeypatch
|
|
524
|
+
):
|
|
525
|
+
"""A completed review of an earlier head does not swallow a later explicit
|
|
526
|
+
request once the head has moved."""
|
|
527
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
528
|
+
_event(monkeypatch)
|
|
529
|
+
pr = {"number": 2, "head": {"sha": "head-3"}, "labels": []}
|
|
530
|
+
request = {
|
|
531
|
+
"id": 43,
|
|
532
|
+
"event": "review_requested",
|
|
533
|
+
"created_at": "2026-01-03T00:00:00Z",
|
|
534
|
+
"requested_reviewer": {"login": "all-hands-bot"},
|
|
535
|
+
}
|
|
536
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
537
|
+
run.gh_pages = lambda path: (
|
|
538
|
+
[request]
|
|
539
|
+
if path.endswith("/events")
|
|
540
|
+
else _reviews(sha="head-2", submitted_at="2026-01-02T00:00:00Z", review_id=555)
|
|
541
|
+
)
|
|
542
|
+
run.dispatcher.deliver.return_value = {
|
|
543
|
+
"disposition": "created",
|
|
544
|
+
"conversation_id": "conversation",
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
run.run()
|
|
548
|
+
|
|
549
|
+
assert run.dispatcher.deliver.call_args.kwargs["delivery"] == "43:head-3"
|
|
550
|
+
|
|
551
|
+
|
|
361
552
|
def test_reviewer_submitted_non_decisive_review_does_not_dispatch(
|
|
362
553
|
tmp_path, monkeypatch
|
|
363
554
|
):
|
|
@@ -1088,6 +1279,38 @@ def test_reviewer_waits_for_a_workflow_run_that_has_not_finished(
|
|
|
1088
1279
|
assert "`Tests`" in body
|
|
1089
1280
|
|
|
1090
1281
|
|
|
1282
|
+
|
|
1283
|
+
def test_reviewer_waits_on_a_fork_workflow_run_awaiting_approval(
|
|
1284
|
+
tmp_path, monkeypatch
|
|
1285
|
+
):
|
|
1286
|
+
"""On a fork head, GitHub parks the workflow run itself at action_required
|
|
1287
|
+
and creates no check runs until a maintainer approves it."""
|
|
1288
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
1289
|
+
pr = _labeled_pr()
|
|
1290
|
+
run.gh_pages = _gate_pages(pr, [])
|
|
1291
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
|
|
1292
|
+
run.workflow_runs = lambda sha: [
|
|
1293
|
+
_workflow_run(
|
|
1294
|
+
"Tests",
|
|
1295
|
+
"completed",
|
|
1296
|
+
"action_required",
|
|
1297
|
+
run_id=4,
|
|
1298
|
+
suite_id=4004,
|
|
1299
|
+
workflow_id=236324519,
|
|
1300
|
+
sha=sha,
|
|
1301
|
+
)
|
|
1302
|
+
]
|
|
1303
|
+
|
|
1304
|
+
run.run()
|
|
1305
|
+
|
|
1306
|
+
run.dispatcher.deliver.assert_not_called()
|
|
1307
|
+
posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
|
|
1308
|
+
assert len(posted) == 1
|
|
1309
|
+
body = posted[0].args[2]["body"]
|
|
1310
|
+
assert "<!-- openhands-review-gate:waiting:head-2 -->" in body
|
|
1311
|
+
assert "`Tests (awaiting maintainer approval)`" in body
|
|
1312
|
+
assert "A maintainer needs to approve the workflow runs" in body
|
|
1313
|
+
|
|
1091
1314
|
def test_reviewer_ignores_workflow_runs_from_an_obsolete_head(
|
|
1092
1315
|
tmp_path, monkeypatch
|
|
1093
1316
|
):
|
|
@@ -1372,7 +1595,9 @@ def test_reviewer_fails_closed_on_an_unknown_conclusion(tmp_path, monkeypatch):
|
|
|
1372
1595
|
pr = _labeled_pr()
|
|
1373
1596
|
run.gh_pages = _gate_pages(pr, [])
|
|
1374
1597
|
run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
|
|
1375
|
-
run.check_runs = lambda sha: _checks(
|
|
1598
|
+
run.check_runs = lambda sha: _checks(
|
|
1599
|
+
("mystery", "completed", "some_new_conclusion"), sha=sha
|
|
1600
|
+
)
|
|
1376
1601
|
|
|
1377
1602
|
run.run()
|
|
1378
1603
|
|
|
@@ -1385,6 +1610,35 @@ def test_reviewer_fails_closed_on_an_unknown_conclusion(tmp_path, monkeypatch):
|
|
|
1385
1610
|
assert "<!-- openhands-review-gate:blocked:head-2 -->" in body
|
|
1386
1611
|
|
|
1387
1612
|
|
|
1613
|
+
|
|
1614
|
+
def test_reviewer_waits_on_a_run_awaiting_maintainer_approval(tmp_path, monkeypatch):
|
|
1615
|
+
"""A fork head parked at action_required is waiting on a maintainer.
|
|
1616
|
+
|
|
1617
|
+
GitHub sets this conclusion on a fork head's workflow runs until a
|
|
1618
|
+
maintainer approves them. That is not a failure, and the explanation must
|
|
1619
|
+
ask for the approval rather than say no action is needed.
|
|
1620
|
+
"""
|
|
1621
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
1622
|
+
pr = _labeled_pr()
|
|
1623
|
+
run.gh_pages = _gate_pages(pr, [])
|
|
1624
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
|
|
1625
|
+
run.check_runs = lambda sha: _checks(
|
|
1626
|
+
("tests", "completed", "action_required"), sha=sha
|
|
1627
|
+
)
|
|
1628
|
+
|
|
1629
|
+
run.run()
|
|
1630
|
+
|
|
1631
|
+
run.dispatcher.deliver.assert_not_called()
|
|
1632
|
+
body = [
|
|
1633
|
+
call.args[2]["body"]
|
|
1634
|
+
for call in _gate_comment_calls(run)
|
|
1635
|
+
if call.args[0] == "POST"
|
|
1636
|
+
][0]
|
|
1637
|
+
assert "<!-- openhands-review-gate:waiting:head-2 -->" in body
|
|
1638
|
+
assert "`tests (awaiting maintainer approval)`" in body
|
|
1639
|
+
assert "A maintainer needs to approve the workflow runs" in body
|
|
1640
|
+
assert "No action is needed" not in body
|
|
1641
|
+
|
|
1388
1642
|
def test_reviewer_does_not_duplicate_the_gate_comment_for_the_same_head(
|
|
1389
1643
|
tmp_path, monkeypatch
|
|
1390
1644
|
):
|
|
@@ -2792,3 +3046,26 @@ def test_scan_reads_every_pull_request_but_starts_only_the_quota(
|
|
|
2792
3046
|
]
|
|
2793
3047
|
assert len(full_reads) == 40
|
|
2794
3048
|
assert _examined(one) == [1, 2, 3, 4, 5]
|
|
3049
|
+
|
|
3050
|
+
|
|
3051
|
+
def test_a_retried_conversation_uses_a_launch_slot(tmp_path, monkeypatch):
|
|
3052
|
+
"""Retrying errored reviews must not bypass the per-scan launch bound."""
|
|
3053
|
+
module = worker("github-pr-reviewer", tmp_path, monkeypatch)
|
|
3054
|
+
intake = module.ReviewIntake()
|
|
3055
|
+
started = []
|
|
3056
|
+
for number in (1, 2, 3):
|
|
3057
|
+
intake.register(
|
|
3058
|
+
{
|
|
3059
|
+
"priority": 1,
|
|
3060
|
+
"created_at": f"2026-01-0{number}T00:00:00Z",
|
|
3061
|
+
"repository": "owner/repo",
|
|
3062
|
+
"number": number,
|
|
3063
|
+
"config": {"max_new_per_run": 1},
|
|
3064
|
+
"start": lambda number=number: started.append(number)
|
|
3065
|
+
or {"disposition": "retried", "conversation_id": str(number)},
|
|
3066
|
+
}
|
|
3067
|
+
)
|
|
3068
|
+
|
|
3069
|
+
intake.drain()
|
|
3070
|
+
|
|
3071
|
+
assert started == [1]
|