claude-dev-env 1.35.0 → 1.36.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/clean-coder.md +109 -1
- package/bin/install.mjs +28 -8
- package/bin/install.test.mjs +9 -1
- package/docs/CODE_RULES.md +3 -0
- package/docs/agents-md-alignment-plan.md +123 -0
- package/hooks/blocking/code_rules_enforcer.py +451 -39
- package/hooks/blocking/es_exe_path_rewriter.py +10 -4
- package/hooks/blocking/test_code_rules_enforcer.py +182 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
- package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +191 -0
- package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +40 -0
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
- package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +87 -3
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -0
- package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
- package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
- package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
- package/hooks/blocking/windows_rmtree_blocker.py +23 -6
- package/hooks/config/banned_identifiers_constants.py +24 -0
- package/hooks/config/hardcoded_user_path_constants.py +12 -0
- package/hooks/config/hook_log_extractor_constants.py +1 -1
- package/hooks/config/pre_tool_use_stdin.py +48 -0
- package/hooks/config/setup_project_paths_constants.py +4 -0
- package/hooks/config/stuttering_check_config.py +14 -0
- package/hooks/config/stuttering_import_binding_constants.py +11 -0
- package/hooks/config/sys_path_insert_constants.py +4 -0
- package/hooks/config/test_banned_identifiers_constants.py +48 -0
- package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
- package/hooks/config/test_hook_log_extractor_constants.py +3 -3
- package/hooks/config/test_pre_tool_use_stdin.py +80 -0
- package/hooks/config/unused_module_import_constants.py +7 -0
- package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
- package/hooks/git-hooks/config.py +3 -3
- package/hooks/git-hooks/test_gate_utils.py +10 -10
- package/hooks/mypy.ini +2 -0
- package/package.json +1 -1
- package/rules/gh-paginate.md +125 -0
- package/skills/bugteam/CONSTRAINTS.md +12 -6
- package/skills/bugteam/SKILL.md +77 -91
- package/skills/bugteam/SKILL_EVALS.md +25 -23
- package/skills/bugteam/reference/README.md +2 -0
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
- package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
- package/skills/bugteam/test_skill_additions.py +13 -4
- package/skills/bugteam/test_team_lifecycle.py +94 -0
- package/skills/findbugs/SKILL.md +3 -3
- package/skills/fixbugs/SKILL.md +4 -4
- package/skills/monitor-open-prs/SKILL.md +32 -2
- package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
- package/skills/pr-converge/SKILL.md +562 -97
- package/skills/pr-converge/scripts/README.md +145 -0
- package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
- package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
- package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
- package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
- package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
- package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
- package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
- package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
- package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
- package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
- package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
- package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
- package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
- package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
- package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
- package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
- package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
- package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
- package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
- package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
- package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
- package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
- package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
- package/skills/pr-converge/scripts/view_pr_context.py +47 -0
- package/skills/pr-converge/test_team_lifecycle.py +47 -0
- package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
- package/skills/qbug/SKILL.md +4 -4
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
- package/skills/resume-review/SKILL.md +261 -0
- package/skills/bugteam/scripts/README.md +0 -58
- package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
- package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
- package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
- package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
- /package/skills/{bugteam → pr-converge}/scripts/config/__init__.py +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Tests for request_copilot_review.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- gh api -X POST is invoked against requested_reviewers endpoint
|
|
5
|
+
- the reviewer id literal is `copilot-pull-request-reviewer[bot]` (the [bot]
|
|
6
|
+
suffix is load-bearing per skills/copilot-review/SKILL.md)
|
|
7
|
+
- subprocess errors propagate
|
|
8
|
+
- the field carries the documented `reviewers[]=<id>` form
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.util
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
from unittest.mock import MagicMock, patch
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_module() -> ModuleType:
|
|
23
|
+
module_path = Path(__file__).parent / "request_copilot_review.py"
|
|
24
|
+
spec = importlib.util.spec_from_file_location("request_copilot_review", module_path)
|
|
25
|
+
assert spec is not None
|
|
26
|
+
assert spec.loader is not None
|
|
27
|
+
module = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(module)
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
request_copilot_review_module = _load_module()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _completed(stdout: str) -> subprocess.CompletedProcess:
|
|
36
|
+
process = MagicMock(spec=subprocess.CompletedProcess)
|
|
37
|
+
process.stdout = stdout
|
|
38
|
+
process.returncode = 0
|
|
39
|
+
return process
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_should_invoke_gh_api_post_against_requested_reviewers_endpoint() -> None:
|
|
43
|
+
with patch("subprocess.run") as mock_run:
|
|
44
|
+
mock_run.return_value = _completed("{}")
|
|
45
|
+
request_copilot_review_module.request_copilot_review(
|
|
46
|
+
owner="acme", repo="widget", number=42
|
|
47
|
+
)
|
|
48
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
49
|
+
assert invoked_argv[0:2] == ["gh", "api"]
|
|
50
|
+
assert "-X" in invoked_argv
|
|
51
|
+
assert "POST" in invoked_argv
|
|
52
|
+
assert "repos/acme/widget/pulls/42/requested_reviewers" in invoked_argv
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_should_request_copilot_with_bot_suffix_literal() -> None:
|
|
56
|
+
with patch("subprocess.run") as mock_run:
|
|
57
|
+
mock_run.return_value = _completed("{}")
|
|
58
|
+
request_copilot_review_module.request_copilot_review(
|
|
59
|
+
owner="acme", repo="widget", number=42
|
|
60
|
+
)
|
|
61
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
62
|
+
assert "reviewers[]=copilot-pull-request-reviewer[bot]" in invoked_argv
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_should_pass_reviewer_field_via_dash_f_flag() -> None:
|
|
66
|
+
with patch("subprocess.run") as mock_run:
|
|
67
|
+
mock_run.return_value = _completed("{}")
|
|
68
|
+
request_copilot_review_module.request_copilot_review(
|
|
69
|
+
owner="acme", repo="widget", number=42
|
|
70
|
+
)
|
|
71
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
72
|
+
assert "-f" in invoked_argv
|
|
73
|
+
field_index = invoked_argv.index("-f")
|
|
74
|
+
field_value = invoked_argv[field_index + 1]
|
|
75
|
+
assert field_value == "reviewers[]=copilot-pull-request-reviewer[bot]"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_should_raise_when_gh_subprocess_fails() -> None:
|
|
79
|
+
failure = subprocess.CalledProcessError(
|
|
80
|
+
returncode=1, cmd=["gh"], stderr="auth failure"
|
|
81
|
+
)
|
|
82
|
+
with patch("subprocess.run", side_effect=failure):
|
|
83
|
+
with pytest.raises(subprocess.CalledProcessError):
|
|
84
|
+
request_copilot_review_module.request_copilot_review(
|
|
85
|
+
owner="acme", repo="widget", number=42
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_should_render_endpoint_via_named_template_constant() -> None:
|
|
90
|
+
with patch("subprocess.run") as mock_run:
|
|
91
|
+
mock_run.return_value = _completed("{}")
|
|
92
|
+
request_copilot_review_module.request_copilot_review(
|
|
93
|
+
owner="acme", repo="widget", number=42
|
|
94
|
+
)
|
|
95
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
96
|
+
expected_endpoint = (
|
|
97
|
+
request_copilot_review_module.GH_REQUESTED_REVIEWERS_PATH_TEMPLATE.format(
|
|
98
|
+
owner="acme", repo="widget", number=42
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
assert expected_endpoint in invoked_argv
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tests for resolve_pr_head.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- gh command targets the single-object PR endpoint with --jq .head.sha
|
|
5
|
+
- single-object endpoint does NOT use --paginate or --slurp (per gh-paginate rule)
|
|
6
|
+
- the trimmed SHA is returned
|
|
7
|
+
- subprocess errors propagate
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
from unittest.mock import MagicMock, patch
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_module() -> ModuleType:
|
|
22
|
+
module_path = Path(__file__).parent / "resolve_pr_head.py"
|
|
23
|
+
spec = importlib.util.spec_from_file_location("resolve_pr_head", module_path)
|
|
24
|
+
assert spec is not None
|
|
25
|
+
assert spec.loader is not None
|
|
26
|
+
module = importlib.util.module_from_spec(spec)
|
|
27
|
+
spec.loader.exec_module(module)
|
|
28
|
+
return module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
resolve_pr_head_module = _load_module()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _completed(stdout: str) -> subprocess.CompletedProcess:
|
|
35
|
+
process = MagicMock(spec=subprocess.CompletedProcess)
|
|
36
|
+
process.stdout = stdout
|
|
37
|
+
process.returncode = 0
|
|
38
|
+
return process
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_should_invoke_gh_against_single_object_pr_endpoint() -> None:
|
|
42
|
+
with patch("subprocess.run") as mock_run:
|
|
43
|
+
mock_run.return_value = _completed("85309a0789e\n")
|
|
44
|
+
resolve_pr_head_module.resolve_pr_head(owner="acme", repo="widget", number=42)
|
|
45
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
46
|
+
assert invoked_argv[0] == "gh"
|
|
47
|
+
assert invoked_argv[1] == "api"
|
|
48
|
+
assert invoked_argv[2] == "repos/acme/widget/pulls/42"
|
|
49
|
+
assert "--jq" in invoked_argv
|
|
50
|
+
assert ".head.sha" in invoked_argv
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_should_not_use_paginate_or_slurp_on_single_object_endpoint() -> None:
|
|
54
|
+
with patch("subprocess.run") as mock_run:
|
|
55
|
+
mock_run.return_value = _completed("abc\n")
|
|
56
|
+
resolve_pr_head_module.resolve_pr_head(owner="acme", repo="widget", number=42)
|
|
57
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
58
|
+
assert "--paginate" not in invoked_argv
|
|
59
|
+
assert "--slurp" not in invoked_argv
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_should_return_trimmed_sha() -> None:
|
|
63
|
+
with patch("subprocess.run") as mock_run:
|
|
64
|
+
mock_run.return_value = _completed(" 85309a0789e \n")
|
|
65
|
+
resolved_sha = resolve_pr_head_module.resolve_pr_head(
|
|
66
|
+
owner="acme", repo="widget", number=42
|
|
67
|
+
)
|
|
68
|
+
assert resolved_sha == "85309a0789e"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_should_raise_when_gh_subprocess_fails() -> None:
|
|
72
|
+
failure = subprocess.CalledProcessError(
|
|
73
|
+
returncode=1, cmd=["gh"], stderr="auth failure"
|
|
74
|
+
)
|
|
75
|
+
with patch("subprocess.run", side_effect=failure):
|
|
76
|
+
with pytest.raises(subprocess.CalledProcessError):
|
|
77
|
+
resolve_pr_head_module.resolve_pr_head(
|
|
78
|
+
owner="acme", repo="widget", number=42
|
|
79
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Tests for review_field_helpers.
|
|
2
|
+
|
|
3
|
+
Covers defensive field-coercion for the four GitHub payload field accessors
|
|
4
|
+
shared across fetch_*_reviews.py and fetch_*_inline_comments.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_module() -> ModuleType:
|
|
15
|
+
module_path = Path(__file__).parent / "review_field_helpers.py"
|
|
16
|
+
spec = importlib.util.spec_from_file_location("review_field_helpers", module_path)
|
|
17
|
+
assert spec is not None
|
|
18
|
+
assert spec.loader is not None
|
|
19
|
+
module = importlib.util.module_from_spec(spec)
|
|
20
|
+
spec.loader.exec_module(module)
|
|
21
|
+
return module
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
review_field_helpers_module = _load_module()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_login_of_should_return_login_string_when_user_dict_has_login() -> None:
|
|
28
|
+
payload = {"user": {"login": "cursor[bot]"}}
|
|
29
|
+
assert review_field_helpers_module.login_of(payload) == "cursor[bot]"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_login_of_should_return_none_when_user_field_missing() -> None:
|
|
33
|
+
assert review_field_helpers_module.login_of({}) is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_login_of_should_return_none_when_user_field_not_dict() -> None:
|
|
37
|
+
assert review_field_helpers_module.login_of({"user": "not-a-dict"}) is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_login_of_should_return_none_when_login_field_not_string() -> None:
|
|
41
|
+
assert review_field_helpers_module.login_of({"user": {"login": 12345}}) is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_body_of_should_return_body_string_when_present() -> None:
|
|
45
|
+
assert review_field_helpers_module.body_of({"body": "review body"}) == "review body"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_body_of_should_return_empty_string_when_body_missing() -> None:
|
|
49
|
+
assert review_field_helpers_module.body_of({}) == ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_body_of_should_return_empty_string_when_body_not_string() -> None:
|
|
53
|
+
assert review_field_helpers_module.body_of({"body": None}) == ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_submitted_at_of_should_return_string_when_present() -> None:
|
|
57
|
+
payload = {"submitted_at": "2026-05-03T12:00:00Z"}
|
|
58
|
+
assert (
|
|
59
|
+
review_field_helpers_module.submitted_at_of(payload) == "2026-05-03T12:00:00Z"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_submitted_at_of_should_return_empty_string_when_missing() -> None:
|
|
64
|
+
assert review_field_helpers_module.submitted_at_of({}) == ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_submitted_at_of_should_return_empty_string_when_not_string() -> None:
|
|
68
|
+
assert review_field_helpers_module.submitted_at_of({"submitted_at": 0}) == ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_state_of_should_return_state_string_when_present() -> None:
|
|
72
|
+
assert review_field_helpers_module.state_of({"state": "APPROVED"}) == "APPROVED"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_state_of_should_return_empty_string_when_missing() -> None:
|
|
76
|
+
assert review_field_helpers_module.state_of({}) == ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_state_of_should_return_empty_string_when_not_string() -> None:
|
|
80
|
+
assert review_field_helpers_module.state_of({"state": ["APPROVED"]}) == ""
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Tests for trigger_bugbot.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- gh pr comment is invoked with --body-file (per gh-body-file rule)
|
|
5
|
+
- the body file written contains the literal phrase "bugbot run\\n"
|
|
6
|
+
- the comment URL emitted by gh is returned
|
|
7
|
+
- the temp body file is cleaned up
|
|
8
|
+
- subprocess errors propagate
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.util
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
from unittest.mock import MagicMock, patch
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_module() -> ModuleType:
|
|
23
|
+
module_path = Path(__file__).parent / "trigger_bugbot.py"
|
|
24
|
+
spec = importlib.util.spec_from_file_location("trigger_bugbot", module_path)
|
|
25
|
+
assert spec is not None
|
|
26
|
+
assert spec.loader is not None
|
|
27
|
+
module = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(module)
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
trigger_bugbot_module = _load_module()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _completed(stdout: str) -> subprocess.CompletedProcess:
|
|
36
|
+
process = MagicMock(spec=subprocess.CompletedProcess)
|
|
37
|
+
process.stdout = stdout
|
|
38
|
+
process.returncode = 0
|
|
39
|
+
return process
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_should_invoke_gh_pr_comment_with_body_file_flag() -> None:
|
|
43
|
+
captured_body_paths: list[str] = []
|
|
44
|
+
|
|
45
|
+
def capture_body_file_contents(*subprocess_args, **_subprocess_kwargs):
|
|
46
|
+
invoked_argv = subprocess_args[0]
|
|
47
|
+
assert "--body-file" in invoked_argv
|
|
48
|
+
body_file_path = invoked_argv[invoked_argv.index("--body-file") + 1]
|
|
49
|
+
captured_body_paths.append(body_file_path)
|
|
50
|
+
return _completed("https://github.com/acme/widget/issues/42#issuecomment-99\n")
|
|
51
|
+
|
|
52
|
+
with patch("subprocess.run", side_effect=capture_body_file_contents) as mock_run:
|
|
53
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=42)
|
|
54
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
55
|
+
assert invoked_argv[0:3] == ["gh", "pr", "comment"]
|
|
56
|
+
assert "42" in invoked_argv
|
|
57
|
+
assert "--repo" in invoked_argv
|
|
58
|
+
assert "acme/widget" in invoked_argv
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_should_write_literal_bugbot_run_phrase_into_body_file() -> None:
|
|
62
|
+
captured_body_contents: list[str] = []
|
|
63
|
+
|
|
64
|
+
def capture_body_file_contents(*subprocess_args, **_subprocess_kwargs):
|
|
65
|
+
invoked_argv = subprocess_args[0]
|
|
66
|
+
body_file_path = Path(invoked_argv[invoked_argv.index("--body-file") + 1])
|
|
67
|
+
captured_body_contents.append(body_file_path.read_text(encoding="utf-8"))
|
|
68
|
+
return _completed("https://example.com\n")
|
|
69
|
+
|
|
70
|
+
with patch("subprocess.run", side_effect=capture_body_file_contents):
|
|
71
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=42)
|
|
72
|
+
assert len(captured_body_contents) == 1
|
|
73
|
+
assert captured_body_contents[0] == "bugbot run\n"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_should_return_comment_url_from_gh_stdout() -> None:
|
|
77
|
+
expected_url = "https://github.com/acme/widget/pull/42#issuecomment-12345"
|
|
78
|
+
with patch("subprocess.run") as mock_run:
|
|
79
|
+
mock_run.return_value = _completed(f"{expected_url}\n")
|
|
80
|
+
comment_url = trigger_bugbot_module.trigger_bugbot(
|
|
81
|
+
owner="acme", repo="widget", number=42
|
|
82
|
+
)
|
|
83
|
+
assert comment_url == expected_url
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_should_remove_temp_body_file_after_invocation() -> None:
|
|
87
|
+
captured_body_paths: list[Path] = []
|
|
88
|
+
|
|
89
|
+
def capture_body_file_contents(*subprocess_args, **_subprocess_kwargs):
|
|
90
|
+
invoked_argv = subprocess_args[0]
|
|
91
|
+
captured_body_paths.append(
|
|
92
|
+
Path(invoked_argv[invoked_argv.index("--body-file") + 1])
|
|
93
|
+
)
|
|
94
|
+
return _completed("https://example.com\n")
|
|
95
|
+
|
|
96
|
+
with patch("subprocess.run", side_effect=capture_body_file_contents):
|
|
97
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=42)
|
|
98
|
+
assert len(captured_body_paths) == 1
|
|
99
|
+
assert not captured_body_paths[0].exists()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_raise_when_gh_subprocess_fails() -> None:
|
|
103
|
+
failure = subprocess.CalledProcessError(
|
|
104
|
+
returncode=1, cmd=["gh"], stderr="auth failure"
|
|
105
|
+
)
|
|
106
|
+
with patch("subprocess.run", side_effect=failure):
|
|
107
|
+
with pytest.raises(subprocess.CalledProcessError):
|
|
108
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=42)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_should_write_imported_constant_directly_without_local_alias() -> None:
|
|
112
|
+
captured_body_contents: list[str] = []
|
|
113
|
+
|
|
114
|
+
def capture_body_file_contents(*subprocess_args, **_subprocess_kwargs):
|
|
115
|
+
invoked_argv = subprocess_args[0]
|
|
116
|
+
body_file_path = Path(invoked_argv[invoked_argv.index("--body-file") + 1])
|
|
117
|
+
captured_body_contents.append(body_file_path.read_text(encoding="utf-8"))
|
|
118
|
+
return _completed("https://example.com\n")
|
|
119
|
+
|
|
120
|
+
with patch("subprocess.run", side_effect=capture_body_file_contents):
|
|
121
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=99)
|
|
122
|
+
assert len(captured_body_contents) == 1
|
|
123
|
+
assert (
|
|
124
|
+
captured_body_contents[0]
|
|
125
|
+
== trigger_bugbot_module.BUGBOT_RUN_TRIGGER_PHRASE
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_should_render_repo_arg_via_named_template_constant() -> None:
|
|
130
|
+
with patch("subprocess.run") as mock_run:
|
|
131
|
+
mock_run.return_value = _completed("https://example.com\n")
|
|
132
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=42)
|
|
133
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
134
|
+
expected_repo_arg = trigger_bugbot_module.GH_REPO_ARG_TEMPLATE.format(
|
|
135
|
+
owner="acme", repo="widget"
|
|
136
|
+
)
|
|
137
|
+
assert expected_repo_arg == "acme/widget"
|
|
138
|
+
repo_flag_index = invoked_argv.index("--repo")
|
|
139
|
+
assert invoked_argv[repo_flag_index + 1] == expected_repo_arg
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Tests for view_pr_context.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- gh pr view is invoked with the documented --json field list
|
|
5
|
+
- the parsed JSON object is returned
|
|
6
|
+
- subprocess errors propagate
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
from unittest.mock import MagicMock, patch
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_module() -> ModuleType:
|
|
22
|
+
module_path = Path(__file__).parent / "view_pr_context.py"
|
|
23
|
+
spec = importlib.util.spec_from_file_location("view_pr_context", module_path)
|
|
24
|
+
assert spec is not None
|
|
25
|
+
assert spec.loader is not None
|
|
26
|
+
module = importlib.util.module_from_spec(spec)
|
|
27
|
+
spec.loader.exec_module(module)
|
|
28
|
+
return module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
view_pr_context_module = _load_module()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _completed(stdout: str) -> subprocess.CompletedProcess:
|
|
35
|
+
process = MagicMock(spec=subprocess.CompletedProcess)
|
|
36
|
+
process.stdout = stdout
|
|
37
|
+
process.returncode = 0
|
|
38
|
+
return process
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_should_invoke_gh_pr_view_with_documented_field_list() -> None:
|
|
42
|
+
payload = json.dumps(
|
|
43
|
+
{
|
|
44
|
+
"number": 42,
|
|
45
|
+
"url": "https://github.com/acme/widget/pull/42",
|
|
46
|
+
"headRefOid": "abc123",
|
|
47
|
+
"baseRefName": "main",
|
|
48
|
+
"headRefName": "feat/x",
|
|
49
|
+
"isDraft": True,
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
with patch("subprocess.run") as mock_run:
|
|
53
|
+
mock_run.return_value = _completed(payload)
|
|
54
|
+
view_pr_context_module.view_pr_context()
|
|
55
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
56
|
+
assert invoked_argv[0:3] == ["gh", "pr", "view"]
|
|
57
|
+
assert "--json" in invoked_argv
|
|
58
|
+
fields_arg = invoked_argv[invoked_argv.index("--json") + 1]
|
|
59
|
+
for required_field in (
|
|
60
|
+
"number",
|
|
61
|
+
"url",
|
|
62
|
+
"headRefOid",
|
|
63
|
+
"baseRefName",
|
|
64
|
+
"headRefName",
|
|
65
|
+
"isDraft",
|
|
66
|
+
):
|
|
67
|
+
assert required_field in fields_arg
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_should_return_parsed_json_object() -> None:
|
|
71
|
+
payload = {
|
|
72
|
+
"number": 42,
|
|
73
|
+
"url": "https://github.com/acme/widget/pull/42",
|
|
74
|
+
"headRefOid": "abc123",
|
|
75
|
+
"baseRefName": "main",
|
|
76
|
+
"headRefName": "feat/x",
|
|
77
|
+
"isDraft": True,
|
|
78
|
+
}
|
|
79
|
+
with patch("subprocess.run") as mock_run:
|
|
80
|
+
mock_run.return_value = _completed(json.dumps(payload))
|
|
81
|
+
pr_context = view_pr_context_module.view_pr_context()
|
|
82
|
+
assert pr_context == payload
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_raise_when_gh_subprocess_fails() -> None:
|
|
86
|
+
failure = subprocess.CalledProcessError(
|
|
87
|
+
returncode=1, cmd=["gh"], stderr="auth failure"
|
|
88
|
+
)
|
|
89
|
+
with patch("subprocess.run", side_effect=failure):
|
|
90
|
+
with pytest.raises(subprocess.CalledProcessError):
|
|
91
|
+
view_pr_context_module.view_pr_context()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_should_pass_imported_constant_directly_without_local_alias() -> None:
|
|
95
|
+
payload = json.dumps(
|
|
96
|
+
{
|
|
97
|
+
"number": 7,
|
|
98
|
+
"url": "https://github.com/acme/widget/pull/7",
|
|
99
|
+
"headRefOid": "deadbeef",
|
|
100
|
+
"baseRefName": "main",
|
|
101
|
+
"headRefName": "feat/y",
|
|
102
|
+
"isDraft": False,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
with patch("subprocess.run") as mock_run:
|
|
106
|
+
mock_run.return_value = _completed(payload)
|
|
107
|
+
view_pr_context_module.view_pr_context()
|
|
108
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
109
|
+
fields_arg = invoked_argv[invoked_argv.index("--json") + 1]
|
|
110
|
+
expected_fields = view_pr_context_module.PR_CONTEXT_FIELDS
|
|
111
|
+
assert fields_arg is expected_fields
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Post a `bugbot run` comment to re-trigger a Cursor Bugbot review.
|
|
2
|
+
|
|
3
|
+
Writes the literal trigger phrase to a temp file (per the gh-body-file rule —
|
|
4
|
+
`gh pr comment --body "..."` may corrupt backticks), invokes
|
|
5
|
+
`gh pr comment --body-file`, and removes the temp file on success or failure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
17
|
+
|
|
18
|
+
from evict_cached_config_modules import evict_cached_config_modules
|
|
19
|
+
|
|
20
|
+
evict_cached_config_modules()
|
|
21
|
+
|
|
22
|
+
from config.pr_converge_constants import (
|
|
23
|
+
BUGBOT_RUN_TEMPFILE_PREFIX,
|
|
24
|
+
BUGBOT_RUN_TEMPFILE_SUFFIX,
|
|
25
|
+
BUGBOT_RUN_TRIGGER_PHRASE,
|
|
26
|
+
GH_REPO_ARG_TEMPLATE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def trigger_bugbot(*, owner: str, repo: str, number: int) -> str:
|
|
31
|
+
"""Post the bugbot re-trigger comment, return the comment URL gh emits."""
|
|
32
|
+
file_descriptor, raw_path = tempfile.mkstemp(
|
|
33
|
+
suffix=BUGBOT_RUN_TEMPFILE_SUFFIX, prefix=BUGBOT_RUN_TEMPFILE_PREFIX
|
|
34
|
+
)
|
|
35
|
+
try:
|
|
36
|
+
os.close(file_descriptor)
|
|
37
|
+
body_file_path = Path(raw_path)
|
|
38
|
+
body_file_path.write_text(BUGBOT_RUN_TRIGGER_PHRASE, encoding="utf-8")
|
|
39
|
+
repo_arg = GH_REPO_ARG_TEMPLATE.format(owner=owner, repo=repo)
|
|
40
|
+
gh_command: list[str] = [
|
|
41
|
+
"gh",
|
|
42
|
+
"pr",
|
|
43
|
+
"comment",
|
|
44
|
+
str(number),
|
|
45
|
+
"--repo",
|
|
46
|
+
repo_arg,
|
|
47
|
+
"--body-file",
|
|
48
|
+
str(body_file_path),
|
|
49
|
+
]
|
|
50
|
+
completed = subprocess.run(
|
|
51
|
+
gh_command,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
check=True,
|
|
54
|
+
text=True,
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
errors="replace",
|
|
57
|
+
)
|
|
58
|
+
return completed.stdout.strip()
|
|
59
|
+
finally:
|
|
60
|
+
Path(raw_path).unlink(missing_ok=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main() -> int:
|
|
64
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
65
|
+
parser.add_argument("--owner", required=True)
|
|
66
|
+
parser.add_argument("--repo", required=True)
|
|
67
|
+
parser.add_argument("--number", required=True, type=int)
|
|
68
|
+
parsed_arguments = parser.parse_args()
|
|
69
|
+
comment_url = trigger_bugbot(
|
|
70
|
+
owner=parsed_arguments.owner, repo=parsed_arguments.repo, number=parsed_arguments.number
|
|
71
|
+
)
|
|
72
|
+
sys.stdout.write(f"{comment_url}\n")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
sys.exit(main())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Resolve the per-tick PR context (number, url, head sha, branch names, draft state).
|
|
2
|
+
|
|
3
|
+
Wraps `gh pr view --json ...` so the skill body emits one script invocation
|
|
4
|
+
instead of repeating the field list inline.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
15
|
+
|
|
16
|
+
from evict_cached_config_modules import evict_cached_config_modules
|
|
17
|
+
|
|
18
|
+
evict_cached_config_modules()
|
|
19
|
+
|
|
20
|
+
from config.pr_converge_constants import PR_CONTEXT_FIELDS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def view_pr_context() -> dict[str, object]:
|
|
24
|
+
"""Return the parsed JSON object from `gh pr view --json <fields>`."""
|
|
25
|
+
gh_command: list[str] = ["gh", "pr", "view", "--json", PR_CONTEXT_FIELDS]
|
|
26
|
+
completed = subprocess.run(
|
|
27
|
+
gh_command,
|
|
28
|
+
capture_output=True,
|
|
29
|
+
check=True,
|
|
30
|
+
text=True,
|
|
31
|
+
encoding="utf-8",
|
|
32
|
+
errors="replace",
|
|
33
|
+
)
|
|
34
|
+
return json.loads(completed.stdout)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main() -> int:
|
|
38
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
39
|
+
parser.parse_args()
|
|
40
|
+
pr_context = view_pr_context()
|
|
41
|
+
json.dump(pr_context, sys.stdout)
|
|
42
|
+
sys.stdout.write("\n")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
sys.exit(main())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Markdown assertion tests for pr-converge orchestrator team lifecycle.
|
|
2
|
+
|
|
3
|
+
Locks in the contract that pr-converge multi-PR orchestration must:
|
|
4
|
+
- own a single long-lived team for the whole sweep
|
|
5
|
+
- pass that team to every bugteam invocation via attach mode
|
|
6
|
+
- tear down only when every PR reaches `converged` or `blocked`
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pathlib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _skill_text() -> str:
|
|
15
|
+
here = pathlib.Path(__file__).parent
|
|
16
|
+
return (here / "SKILL.md").read_text(encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_skill_documents_orchestrator_owned_team_in_multi_pr_mode():
|
|
20
|
+
skill_text = _skill_text()
|
|
21
|
+
assert "team_name" in skill_text
|
|
22
|
+
assert "TeamCreate" in skill_text
|
|
23
|
+
assert "orchestrator" in skill_text.lower()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_skill_passes_attach_mode_to_bugteam_invocations():
|
|
27
|
+
skill_text = _skill_text()
|
|
28
|
+
assert "BUGTEAM_TEAM_LIFECYCLE" in skill_text
|
|
29
|
+
assert "attach" in skill_text
|
|
30
|
+
assert "BUGTEAM_TEAM_NAME" in skill_text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_skill_tears_down_team_only_on_full_convergence():
|
|
34
|
+
skill_text = _skill_text()
|
|
35
|
+
assert "TeamDelete" in skill_text
|
|
36
|
+
convergence_phrases = [
|
|
37
|
+
"every PR",
|
|
38
|
+
"all PRs",
|
|
39
|
+
"fully converged",
|
|
40
|
+
"every prs[",
|
|
41
|
+
]
|
|
42
|
+
assert any(phrase in skill_text for phrase in convergence_phrases)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_state_schema_includes_team_name_field():
|
|
46
|
+
skill_text = _skill_text()
|
|
47
|
+
assert '"team_name"' in skill_text or "team_name:" in skill_text
|