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.
Files changed (113) hide show
  1. package/agents/clean-coder.md +109 -1
  2. package/bin/install.mjs +28 -8
  3. package/bin/install.test.mjs +9 -1
  4. package/docs/CODE_RULES.md +3 -0
  5. package/docs/agents-md-alignment-plan.md +123 -0
  6. package/hooks/blocking/code_rules_enforcer.py +451 -39
  7. package/hooks/blocking/es_exe_path_rewriter.py +10 -4
  8. package/hooks/blocking/test_code_rules_enforcer.py +182 -0
  9. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
  10. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
  11. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +191 -0
  12. package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +40 -0
  13. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
  14. package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +87 -3
  15. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -0
  16. package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
  17. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
  18. package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
  19. package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
  20. package/hooks/blocking/windows_rmtree_blocker.py +23 -6
  21. package/hooks/config/banned_identifiers_constants.py +24 -0
  22. package/hooks/config/hardcoded_user_path_constants.py +12 -0
  23. package/hooks/config/hook_log_extractor_constants.py +1 -1
  24. package/hooks/config/pre_tool_use_stdin.py +48 -0
  25. package/hooks/config/setup_project_paths_constants.py +4 -0
  26. package/hooks/config/stuttering_check_config.py +14 -0
  27. package/hooks/config/stuttering_import_binding_constants.py +11 -0
  28. package/hooks/config/sys_path_insert_constants.py +4 -0
  29. package/hooks/config/test_banned_identifiers_constants.py +48 -0
  30. package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
  31. package/hooks/config/test_hook_log_extractor_constants.py +3 -3
  32. package/hooks/config/test_pre_tool_use_stdin.py +80 -0
  33. package/hooks/config/unused_module_import_constants.py +7 -0
  34. package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
  35. package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
  36. package/hooks/git-hooks/config.py +3 -3
  37. package/hooks/git-hooks/test_gate_utils.py +10 -10
  38. package/hooks/mypy.ini +2 -0
  39. package/package.json +1 -1
  40. package/rules/gh-paginate.md +125 -0
  41. package/skills/bugteam/CONSTRAINTS.md +12 -6
  42. package/skills/bugteam/SKILL.md +77 -91
  43. package/skills/bugteam/SKILL_EVALS.md +25 -23
  44. package/skills/bugteam/reference/README.md +2 -0
  45. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  46. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  47. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
  48. package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
  49. package/skills/bugteam/test_skill_additions.py +13 -4
  50. package/skills/bugteam/test_team_lifecycle.py +94 -0
  51. package/skills/findbugs/SKILL.md +3 -3
  52. package/skills/fixbugs/SKILL.md +4 -4
  53. package/skills/monitor-open-prs/SKILL.md +32 -2
  54. package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
  55. package/skills/pr-converge/SKILL.md +562 -97
  56. package/skills/pr-converge/scripts/README.md +145 -0
  57. package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
  58. package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
  59. package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
  60. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
  61. package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
  62. package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
  63. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
  64. package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
  65. package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
  66. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
  67. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
  68. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
  69. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
  70. package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
  71. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
  72. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
  73. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
  74. package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
  75. package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
  76. package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
  77. package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
  78. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
  79. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
  80. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
  81. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
  82. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
  83. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
  84. package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
  85. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
  86. package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
  87. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
  88. package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
  89. package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
  90. package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
  91. package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
  92. package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
  93. package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
  94. package/skills/pr-converge/scripts/view_pr_context.py +47 -0
  95. package/skills/pr-converge/test_team_lifecycle.py +47 -0
  96. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
  97. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
  98. package/skills/qbug/SKILL.md +4 -4
  99. package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
  100. package/skills/resume-review/SKILL.md +261 -0
  101. package/skills/bugteam/scripts/README.md +0 -58
  102. package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
  103. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
  104. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
  105. package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
  106. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
  107. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
  108. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
  109. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
  110. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
  111. package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
  112. package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
  113. /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