@openhands/extensions 0.17.0 → 0.19.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.
Files changed (76) hide show
  1. package/.agents/skills/custom-codereview-guide.md +7 -0
  2. package/.github/dependabot.yml +24 -0
  3. package/.github/scripts/check_issue_readiness.py +272 -0
  4. package/.github/scripts/check_pr_description.py +226 -0
  5. package/.github/scripts/post-readiness-comment.mjs +184 -0
  6. package/.github/scripts/refresh_linked_pr_checks.py +171 -0
  7. package/.github/workflows/issue-readiness-check.yml +142 -0
  8. package/.github/workflows/pr-description-check.yml +37 -0
  9. package/.release-please-manifest.json +1 -1
  10. package/AGENTS.md +16 -0
  11. package/README.md +13 -2
  12. package/automations/README.md +3 -2
  13. package/automations/bundle-index.js +10 -1
  14. package/automations/catalog/github-agents-md-maintainer/manifest.json +101 -0
  15. package/automations/catalog/github-issue-to-pr/manifest.json +112 -0
  16. package/automations/catalog/github-pr-reviewer/manifest.json +6 -0
  17. package/automations/catalog/github-repo-monitor/manifest.json +6 -0
  18. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +23 -0
  19. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +23 -0
  20. package/automations/catalog/linear-issue-to-bitbucket-pr/manifest.json +23 -0
  21. package/automations/catalog/linear-issue-to-github-pr/manifest.json +23 -0
  22. package/automations/catalog/linear-issue-to-gitlab-mr/manifest.json +23 -0
  23. package/automations/catalog/news-digest/manifest.json +81 -0
  24. package/automations/catalog/qa-changes/manifest.json +72 -0
  25. package/automations/catalog/slack-github-linear-daily-organization.json +27 -0
  26. package/automations/catalog-index.js +25 -7
  27. package/automations/catalog.schema.json +35 -2
  28. package/automations/index.d.ts +34 -1
  29. package/automations/interface.json +3 -1
  30. package/integrations/catalog/datadog.json +2 -2
  31. package/integrations/catalog/gitlab.json +41 -0
  32. package/integrations/catalog/notion.json +1 -1
  33. package/integrations/catalog-index.js +71 -69
  34. package/integrations/index.d.ts +1 -1
  35. package/marketplaces/openhands-extensions.json +59 -6
  36. package/package.json +1 -1
  37. package/plugins/pr-review/scripts/prompt.py +2 -0
  38. package/pyproject.toml +1 -1
  39. package/python/openhands_extensions/_version.py +1 -1
  40. package/skills/github-agents-md-maintainer/.plugin/plugin.json +19 -0
  41. package/skills/github-agents-md-maintainer/README.md +59 -0
  42. package/skills/github-agents-md-maintainer/SKILL.md +271 -0
  43. package/skills/github-agents-md-maintainer/commands/agents-md-setup.md +8 -0
  44. package/skills/github-agents-md-maintainer/references/state-schema.md +113 -0
  45. package/skills/github-agents-md-maintainer/scripts/main.py +1187 -0
  46. package/skills/github-issue-to-pr/.plugin/plugin.json +19 -0
  47. package/skills/github-issue-to-pr/README.md +84 -0
  48. package/skills/github-issue-to-pr/SKILL.md +373 -0
  49. package/skills/github-issue-to-pr/commands/issue-to-pr-setup.md +8 -0
  50. package/skills/github-issue-to-pr/references/state-schema.md +139 -0
  51. package/skills/github-issue-to-pr/scripts/main.py +1305 -0
  52. package/skills/github-pr-reviewer/SKILL.md +2 -1
  53. package/skills/github-pr-reviewer/scripts/main.py +45 -4
  54. package/skills/github-pr-reviewer/tests/test_main.py +74 -0
  55. package/skills/index.js +38 -2
  56. package/skills/news-digest/.plugin/plugin.json +19 -0
  57. package/skills/news-digest/README.md +71 -0
  58. package/skills/news-digest/SKILL.md +284 -0
  59. package/skills/news-digest/commands/news-digest-setup.md +8 -0
  60. package/skills/news-digest/references/state-schema.md +178 -0
  61. package/skills/news-digest/scripts/main.py +1250 -0
  62. package/skills/openhands-sdk/SKILL.md +1 -0
  63. package/skills/ticket-to-code-change/.plugin/plugin.json +21 -0
  64. package/skills/ticket-to-code-change/README.md +5 -0
  65. package/skills/ticket-to-code-change/SKILL.md +50 -0
  66. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +8 -0
  67. package/tests/fixtures/automations/github-repo-monitor.json +77 -0
  68. package/tests/test_automation_setup.py +56 -0
  69. package/tests/test_catalogs.py +5 -3
  70. package/tests/test_check_issue_readiness.py +135 -0
  71. package/tests/test_check_pr_description.py +228 -0
  72. package/tests/test_github_agents_md_maintainer.py +352 -0
  73. package/tests/test_github_issue_to_pr.py +539 -0
  74. package/tests/test_news_digest.py +646 -0
  75. package/tests/test_pr_review_prompt.py +10 -0
  76. package/tests/test_refresh_linked_pr_checks.py +130 -0
@@ -198,6 +198,7 @@ Source: [`examples/`](https://github.com/OpenHands/software-agent-sdk/tree/main/
198
198
  - [`55_persistent_memory.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/55_persistent_memory.py)
199
199
  - [`56_structured_output.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/56_structured_output.py)
200
200
  - [`57_prompt_hooks`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk/57_prompt_hooks)
201
+ - [`58_ask_oracle_tool`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk/58_ask_oracle_tool)
201
202
 
202
203
  ### [`02_remote_agent_server/`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server)
203
204
 
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "ticket-to-code-change",
3
+ "version": "1.0.0",
4
+ "description": "Create pull or merge requests from Jira or Linear tickets across GitHub, GitLab, and Bitbucket.",
5
+ "author": {
6
+ "name": "OpenHands",
7
+ "email": "contact@all-hands.dev"
8
+ },
9
+ "homepage": "https://github.com/OpenHands/extensions",
10
+ "repository": "https://github.com/OpenHands/extensions",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "jira",
14
+ "linear",
15
+ "github",
16
+ "gitlab",
17
+ "bitbucket",
18
+ "pull-request",
19
+ "automation"
20
+ ]
21
+ }
@@ -0,0 +1,5 @@
1
+ # Ticket to code change
2
+
3
+ Sets up a Jira or Linear automation that sends implementation-ready tickets to OpenHands and opens the resulting change in GitHub, GitLab, or Bitbucket.
4
+
5
+ See `SKILL.md` for prerequisites and the setup workflow.
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: ticket-to-code-change
3
+ description: >
4
+ Set up a ticket-to-code-change automation using Jira or Linear as the issue
5
+ tracker and GitHub, GitLab, or Bitbucket as the source-control provider.
6
+ Watches for implementation-ready tickets, starts an OpenHands conversation
7
+ to implement and test the request, opens a pull or merge request, and links
8
+ the result back to the ticket.
9
+ triggers:
10
+ - /ticket-to-code-change:setup
11
+ ---
12
+
13
+ # Ticket to code change
14
+
15
+ Create an automation that turns implementation-ready tickets into tested pull
16
+ requests or merge requests.
17
+
18
+ ## Information to collect
19
+
20
+ Ask the user for:
21
+
22
+ 1. The issue tracker: Jira Cloud or Linear.
23
+ 2. The source-control provider: GitHub, GitLab, or Bitbucket Cloud.
24
+ 3. The project or team to watch and the label or workflow state that means
25
+ "implementation ready".
26
+ 4. How a ticket identifies its target repository and base branch.
27
+ 5. The polling schedule or issue event to use.
28
+ 6. The ticket state to set when work starts and when the change request opens.
29
+ 7. Whether linked dependencies must be completed before dispatch.
30
+
31
+ ## Setup workflow
32
+
33
+ 1. Verify that both required integrations are connected and can read the
34
+ selected project and repository.
35
+ 2. Prefer an issue event trigger when the deployment can receive events;
36
+ otherwise use cron polling with durable issue-ID deduplication.
37
+ 3. Limit each run to a small configurable number of new tickets. On initial
38
+ deployment, establish a baseline instead of dispatching the entire backlog.
39
+ 4. Build a prompt that includes the full ticket, acceptance criteria,
40
+ dependency status, repository, base branch, and provider-specific request
41
+ terminology. Require the agent to run the repository's tests before opening
42
+ the pull request or merge request.
43
+ 5. Create the automation through the Automation backend described in
44
+ `<RUNTIME_SERVICES>`. Use its prompt-preset endpoint and authenticate with
45
+ the runtime-provided automation API key.
46
+ 6. Configure the automation to post the OpenHands conversation URL immediately
47
+ and the resulting pull-request or merge-request URL back to the ticket.
48
+
49
+ Do not dispatch tickets with unresolved dependencies or without an unambiguous
50
+ target repository; comment on the ticket with the missing information instead.
@@ -0,0 +1,8 @@
1
+ ---
2
+ # auto-generated by sync_extensions.py
3
+ description: Set up a ticket-to-code-change automation using Jira or Linear as the issue tracker and GitHub, GitLab, or Bitbucket as the source-control provider. Watches for implementation-ready tickets, starts an OpenHands conversation to implement and test the request, opens a pull or merge request, and links the result back to the ticket.
4
+ ---
5
+
6
+ Read and follow the complete instructions in the SKILL.md file located in this skill's directory.
7
+
8
+ $ARGUMENTS
@@ -37,6 +37,17 @@
37
37
  "type": "cron",
38
38
  "schedule": "*/15 * * * *",
39
39
  "timezone": "UTC"
40
+ },
41
+ "template": {
42
+ "id": "github-repo-monitor",
43
+ "version": "1.0.0",
44
+ "config": {
45
+ "repository": "OpenHands/agent-server-gui",
46
+ "triggerPhrase": "@openhands",
47
+ "ref": "main",
48
+ "schedule": "*/15 * * * *",
49
+ "timezone": "UTC"
50
+ }
40
51
  }
41
52
  }
42
53
  }
@@ -67,6 +78,17 @@
67
78
  "type": "cron",
68
79
  "schedule": "*/15 * * * *",
69
80
  "timezone": "UTC"
81
+ },
82
+ "template": {
83
+ "id": "github-repo-monitor",
84
+ "version": "1.0.0",
85
+ "config": {
86
+ "repository": "OpenHands/agent-server-gui",
87
+ "triggerPhrase": "@openhands",
88
+ "ref": "main",
89
+ "schedule": "*/15 * * * *",
90
+ "timezone": "UTC"
91
+ }
70
92
  }
71
93
  }
72
94
  },
@@ -79,6 +101,28 @@
79
101
  "model": null,
80
102
  "name": "GitHub repository monitor - OpenHands/agent-server-gui",
81
103
  "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
104
+ "preset_metadata": {
105
+ "preset_type": "prompt",
106
+ "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
107
+ "repos": [
108
+ {
109
+ "url": "OpenHands/agent-server-gui",
110
+ "ref": "main",
111
+ "provider": "github"
112
+ }
113
+ ],
114
+ "template": {
115
+ "id": "github-repo-monitor",
116
+ "version": "1.0.0",
117
+ "config": {
118
+ "repository": "OpenHands/agent-server-gui",
119
+ "triggerPhrase": "@openhands",
120
+ "ref": "main",
121
+ "schedule": "*/15 * * * *",
122
+ "timezone": "UTC"
123
+ }
124
+ }
125
+ },
82
126
  "trigger": {
83
127
  "type": "cron",
84
128
  "schedule": "*/15 * * * *",
@@ -128,6 +172,17 @@
128
172
  "type": "cron",
129
173
  "schedule": "*/1 * * * *",
130
174
  "timezone": "UTC"
175
+ },
176
+ "template": {
177
+ "id": "github-repo-monitor",
178
+ "version": "1.0.0",
179
+ "config": {
180
+ "repository": "OpenHands/agent-server-gui",
181
+ "triggerPhrase": "@openhands",
182
+ "ref": "main",
183
+ "schedule": "*/1 * * * *",
184
+ "timezone": "UTC"
185
+ }
131
186
  }
132
187
  }
133
188
  }
@@ -178,6 +233,17 @@
178
233
  "type": "cron",
179
234
  "schedule": "0 0 31 2 *",
180
235
  "timezone": "UTC"
236
+ },
237
+ "template": {
238
+ "id": "github-repo-monitor",
239
+ "version": "1.0.0",
240
+ "config": {
241
+ "repository": "OpenHands/agent-server-gui",
242
+ "triggerPhrase": "@openhands",
243
+ "ref": "main",
244
+ "schedule": "0 0 31 2 *",
245
+ "timezone": "UTC"
246
+ }
181
247
  }
182
248
  }
183
249
  },
@@ -229,6 +295,17 @@
229
295
  "type": "cron",
230
296
  "schedule": "*/15 * * * *",
231
297
  "timezone": "UTC"
298
+ },
299
+ "template": {
300
+ "id": "github-repo-monitor",
301
+ "version": "1.0.0",
302
+ "config": {
303
+ "repository": "OpenHands/agent-server-gui",
304
+ "triggerPhrase": "@openhands",
305
+ "ref": "main",
306
+ "schedule": "*/15 * * * *",
307
+ "timezone": "UTC"
308
+ }
232
309
  }
233
310
  }
234
311
  },
@@ -278,6 +278,16 @@ def _render_payload(entry: dict, form_values: dict, tarball_path: str = "") -> d
278
278
 
279
279
  body["trigger"] = _derive_trigger(entry, form_values)
280
280
 
281
+ # A versioned entry sends its provenance: the service stores it opaquely,
282
+ # keyed by id for idempotent creation. The form values are non-secret by
283
+ # design (credentials come only from connected integrations).
284
+ if "version" in entry:
285
+ body["template"] = {
286
+ "id": entry["id"],
287
+ "version": entry["version"],
288
+ "config": dict(form_values),
289
+ }
290
+
281
291
  return body
282
292
 
283
293
 
@@ -497,6 +507,10 @@ def test_schema_rejects_content_a_setup_block_must_never_carry() -> None:
497
507
  with_repeated_identity["setup"]["description"] = "a second description"
498
508
  rejected.append(("description", with_repeated_identity))
499
509
 
510
+ with_bad_version = deepcopy(entry)
511
+ with_bad_version["version"] = "1.0"
512
+ rejected.append(("'1.0' does not match", with_bad_version))
513
+
500
514
  for expected_fragment, invalid in rejected:
501
515
  errors = list(VALIDATOR.iter_errors(invalid))
502
516
  assert any(expected_fragment in error.message for error in errors), (
@@ -504,6 +518,28 @@ def test_schema_rejects_content_a_setup_block_must_never_carry() -> None:
504
518
  )
505
519
 
506
520
 
521
+ IMPACT_REJECTIONS: list[tuple[str, dict]] = [
522
+ ("a basis the host does not know how to compute", {"basis": "run-counter"}),
523
+ ("markup in a phrase", {"one": "<b>1 sweep</b>"}),
524
+ ("a placeholder from another namespace", {"other": "{{count}} sweeps for {{form.repo}}"}),
525
+ ("a plural phrase that hides the count", {"other": "many sweeps completed"}),
526
+ ("an extra key beside the declared three", {"detail": "and saved hours"}),
527
+ ]
528
+
529
+
530
+ @pytest.mark.parametrize(
531
+ ("case", "override"),
532
+ [pytest.param(case, override, id=case) for case, override in IMPACT_REJECTIONS],
533
+ )
534
+ def test_schema_refuses_an_impact_statement_the_host_must_never_render(
535
+ case: str, override: dict
536
+ ) -> None:
537
+ entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json"))
538
+ entry["impact"].update(override)
539
+
540
+ assert list(VALIDATOR.iter_errors(entry)), f"schema admitted {case}"
541
+
542
+
507
543
  @pytest.mark.parametrize("entry_path", list(_setup_paths()))
508
544
  def test_form_placeholders_reference_declared_fields(entry_path: Path) -> None:
509
545
  """A {{form.x}} that names no field renders as an empty value at runtime."""
@@ -520,6 +556,26 @@ def test_form_placeholders_reference_declared_fields(entry_path: Path) -> None:
520
556
  assert referenced - fields == set()
521
557
 
522
558
 
559
+ @pytest.mark.parametrize("entry_path", list(_setup_paths()))
560
+ def test_the_declared_features_match_the_archetype(entry_path: Path) -> None:
561
+ """A bundle needs a deployment that runs a client-supplied tarball; a prompt
562
+ needs the preset endpoint. Declaring the other one's features is a check the
563
+ host runs against the wrong capability, so the entry is either offered where
564
+ it cannot run or withheld where it can."""
565
+ entry = _load(entry_path)
566
+ features = set(entry["requires"].get("features", []))
567
+
568
+ if _is_bundle(entry):
569
+ assert "customTarball" in features, f"{entry['id']}: a bundle must declare customTarball"
570
+ assert "presetPrompt" not in features, (
571
+ f"{entry['id']}: a bundle creates through {BUNDLE_CREATE_PATH}, not the preset endpoint"
572
+ )
573
+ else:
574
+ assert "customTarball" not in features, (
575
+ f"{entry['id']}: only a bundle uploads a tarball"
576
+ )
577
+
578
+
523
579
  @pytest.mark.parametrize("entry_path", list(_setup_paths()))
524
580
  def test_select_fields_offer_options(entry_path: Path) -> None:
525
581
  """A select without options is an empty dropdown the user cannot get past.
@@ -34,8 +34,10 @@ def test_catalog_ids_are_unique_and_automations_reference_things_that_exist():
34
34
 
35
35
  known_integration_ids = set(integration_ids)
36
36
  for automation in automations:
37
+ # May be empty: an automation that needs nothing connected says so with
38
+ # an empty object rather than by leaving the key out. What it may not do
39
+ # is name an integration that does not exist.
37
40
  required = automation["requires"]["integrations"]
38
- assert required
39
41
  assert set(required) - known_integration_ids == set()
40
42
 
41
43
  # The launch command is looked up from this skill rather than stored on
@@ -91,7 +93,7 @@ def test_remote_no_auth_mcp_entries_are_intentionally_public():
91
93
  transport = option.get("transport", {})
92
94
  # An option with `headerFields` still requires the user to supply
93
95
  # credentials (just via named headers, e.g. Datadog's
94
- # DD_API_KEY/DD_APPLICATION_KEY), so it is NOT "public/no-auth".
96
+ # DD-API-KEY/DD-APPLICATION-KEY), so it is NOT "public/no-auth".
95
97
  has_header_credentials = bool(transport.get("headerFields"))
96
98
  if (
97
99
  option["provider"] == "mcp"
@@ -117,7 +119,7 @@ def test_datadog_mcp_uses_documented_api_key_headers():
117
119
 
118
120
  assert [
119
121
  field["key"] for field in api_option["transport"]["headerFields"]
120
- ] == ["DD_API_KEY", "DD_APPLICATION_KEY"]
122
+ ] == ["DD-API-KEY", "DD-APPLICATION-KEY"]
121
123
 
122
124
 
123
125
  def test_credential_fields_have_helper_text_and_link():
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def _load_prod_module():
9
+ repo_root = Path(__file__).resolve().parents[1]
10
+ script_path = repo_root / ".github" / "scripts" / "check_issue_readiness.py"
11
+ name = "check_issue_readiness"
12
+ spec = importlib.util.spec_from_file_location(name, script_path)
13
+ assert spec and spec.loader
14
+ mod = importlib.util.module_from_spec(spec)
15
+ sys.modules[name] = mod
16
+ spec.loader.exec_module(mod)
17
+ return mod
18
+
19
+
20
+ _prod = _load_prod_module()
21
+ evaluate_readiness = _prod.evaluate_readiness
22
+ extract_sections = _prod.extract_sections
23
+
24
+
25
+ ENHANCEMENT_READY = """### Problem or Use Case
26
+
27
+ The integration catalog lacks a Linear entry.
28
+
29
+ ### Desired Behavior
30
+
31
+ `integrations/catalog/linear.json` describes the Linear MCP server so the
32
+ catalog build picks it up.
33
+
34
+ ### Acceptance Criteria
35
+
36
+ - [ ] `npm run build:integrations` succeeds with the new entry
37
+ - [ ] `getIntegrationCatalogEntry("linear")` returns the Linear catalog model
38
+ """
39
+
40
+ BUG_READY = """### Actual Behavior
41
+
42
+ Running `uv run --group test pytest tests/` fails in
43
+ `test_skills_catalog.py` with a KeyError when a skill is missing from the
44
+ marketplace.
45
+
46
+ ### Acceptance Criteria
47
+
48
+ - [ ] `uv run --group test pytest tests/` passes with a clear error message
49
+ """
50
+
51
+
52
+ def test_extract_sections_splits_on_headings():
53
+ sections = extract_sections("### Alpha\n\ntext\n\n### Beta\n\nmore\n")
54
+ assert sections["alpha"] == "\ntext\n\n"
55
+ assert sections["beta"] == "\nmore\n"
56
+
57
+
58
+ def test_enhancement_ready_passes():
59
+ result = evaluate_readiness(ENHANCEMENT_READY, ["enhancement"])
60
+ assert result.ready is True
61
+ assert result.reasons == []
62
+
63
+
64
+ def test_enhancement_missing_acceptance_criteria_fails():
65
+ body = "### Desired Behavior\n\nSome desired change.\n"
66
+ result = evaluate_readiness(body, ["enhancement"])
67
+ assert result.ready is False
68
+ assert any("Acceptance Criteria" in r for r in result.reasons)
69
+
70
+
71
+ def test_enhancement_missing_desired_behavior_fails():
72
+ body = ENHANCEMENT_READY.replace(
73
+ "### Desired Behavior\n\n"
74
+ "`integrations/catalog/linear.json` describes the Linear MCP server so the\n"
75
+ "catalog build picks it up.\n\n",
76
+ "",
77
+ )
78
+ result = evaluate_readiness(body, ["enhancement"])
79
+ assert result.ready is False
80
+ assert any("Desired Behavior" in r for r in result.reasons)
81
+
82
+
83
+ def test_bug_ready_passes():
84
+ result = evaluate_readiness(BUG_READY, ["bug"])
85
+ assert result.ready is True
86
+ assert result.reasons == []
87
+
88
+
89
+ def test_bug_missing_run_method_fails():
90
+ body = BUG_READY.replace(
91
+ "Running `uv run --group test pytest tests/` fails",
92
+ "Running the test suite fails",
93
+ )
94
+ result = evaluate_readiness(body, ["bug"])
95
+ assert result.ready is False
96
+ assert any("reproducible command" in r for r in result.reasons)
97
+
98
+
99
+ def test_bug_npm_build_command_is_a_valid_run_method():
100
+ body = BUG_READY.replace(
101
+ "Running `uv run --group test pytest tests/` fails in\n"
102
+ "`test_skills_catalog.py` with a KeyError when a skill is missing from the\n"
103
+ "marketplace.",
104
+ "Running `npm run build:skills` fails with a schema error.",
105
+ )
106
+ result = evaluate_readiness(body, ["bug"])
107
+ assert result.ready is True
108
+ assert result.reasons == []
109
+
110
+
111
+ def test_bug_acceptance_needs_checklist_item():
112
+ body = BUG_READY.replace(
113
+ "- [ ] `uv run --group test pytest tests/` passes with a clear error message",
114
+ "Make the test error message clearer",
115
+ )
116
+ result = evaluate_readiness(body, ["bug"])
117
+ assert result.ready is False
118
+ assert any("checklist item" in r for r in result.reasons)
119
+
120
+
121
+ def test_no_response_field_counts_as_empty():
122
+ body = BUG_READY.replace(
123
+ "### Acceptance Criteria\n\n- [ ] `uv run --group test pytest tests/` "
124
+ "passes with a clear error message\n",
125
+ "### Acceptance Criteria\n\n_No response_\n",
126
+ )
127
+ result = evaluate_readiness(body, ["bug"])
128
+ assert result.ready is False
129
+ assert any("Acceptance Criteria" in r for r in result.reasons)
130
+
131
+
132
+ def test_no_bug_or_enhancement_label_not_ready():
133
+ result = evaluate_readiness(ENHANCEMENT_READY, [])
134
+ assert result.ready is False
135
+ assert any("bug" in r and "enhancement" in r for r in result.reasons)