claude-dev-env 1.35.0 → 1.36.1

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 (115) 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 +364 -154
  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/scripts/reflow_skill_md.py +298 -0
  50. package/skills/bugteam/test_skill_additions.py +13 -4
  51. package/skills/bugteam/test_team_lifecycle.py +103 -0
  52. package/skills/findbugs/SKILL.md +3 -3
  53. package/skills/fixbugs/SKILL.md +4 -4
  54. package/skills/monitor-open-prs/SKILL.md +32 -2
  55. package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
  56. package/skills/pr-converge/SKILL.md +1206 -131
  57. package/skills/pr-converge/scripts/README.md +145 -0
  58. package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
  59. package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
  60. package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
  61. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
  62. package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
  63. package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
  64. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
  65. package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
  66. package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
  67. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
  68. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
  69. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
  70. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
  71. package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
  72. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
  73. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
  74. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
  75. package/skills/pr-converge/scripts/reflow_skill_md.py +288 -0
  76. package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
  77. package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
  78. package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
  79. package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
  80. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
  81. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
  82. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
  83. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
  84. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
  85. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
  86. package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
  87. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
  88. package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
  89. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
  90. package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
  91. package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
  92. package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
  93. package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
  94. package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
  95. package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
  96. package/skills/pr-converge/scripts/view_pr_context.py +47 -0
  97. package/skills/pr-converge/test_team_lifecycle.py +56 -0
  98. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
  99. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
  100. package/skills/qbug/SKILL.md +4 -4
  101. package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
  102. package/skills/resume-review/SKILL.md +261 -0
  103. package/skills/bugteam/scripts/README.md +0 -58
  104. package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
  105. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
  106. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
  107. package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
  108. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
  109. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
  110. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
  111. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
  112. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
  113. package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
  114. package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
  115. /package/skills/{bugteam → pr-converge}/scripts/config/__init__.py +0 -0
@@ -0,0 +1,280 @@
1
+ """Tests for fetch_copilot_reviews.
2
+
3
+ Covers:
4
+ - gh command uses --paginate --slurp (per gh-paginate rule)
5
+ - per-page filter happens in Python after fetching all pages
6
+ - only copilot-pull-request-reviewer[bot] reviews are returned
7
+ - reviews are sorted newest-first by submitted_at
8
+ - reviews with state APPROVED are classified "clean"
9
+ - reviews with state CHANGES_REQUESTED or COMMENTED are classified "dirty"
10
+ - subprocess errors propagate with stderr context
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import json
17
+ import subprocess
18
+ from pathlib import Path
19
+ from types import ModuleType
20
+ from unittest.mock import MagicMock, patch
21
+
22
+ import pytest
23
+
24
+
25
+ def _load_module() -> ModuleType:
26
+ module_path = Path(__file__).parent / "fetch_copilot_reviews.py"
27
+ spec = importlib.util.spec_from_file_location("fetch_copilot_reviews", module_path)
28
+ assert spec is not None
29
+ assert spec.loader is not None
30
+ module = importlib.util.module_from_spec(spec)
31
+ spec.loader.exec_module(module)
32
+ return module
33
+
34
+
35
+ fetch_copilot_reviews_module = _load_module()
36
+
37
+
38
+ def _completed(stdout: str) -> subprocess.CompletedProcess:
39
+ process = MagicMock(spec=subprocess.CompletedProcess)
40
+ process.stdout = stdout
41
+ process.returncode = 0
42
+ return process
43
+
44
+
45
+ def test_should_invoke_gh_with_paginate_slurp_against_reviews_endpoint() -> None:
46
+ pages_payload = json.dumps([[]])
47
+ with patch("subprocess.run") as mock_run:
48
+ mock_run.return_value = _completed(pages_payload)
49
+ fetch_copilot_reviews_module.fetch_copilot_reviews(
50
+ owner="acme", repo="widget", number=42
51
+ )
52
+ invoked_argv = mock_run.call_args[0][0]
53
+ assert invoked_argv[0] == "gh"
54
+ assert invoked_argv[1] == "api"
55
+ assert "repos/acme/widget/pulls/42/reviews?per_page=100" in invoked_argv[2]
56
+ assert "--paginate" in invoked_argv
57
+ assert "--slurp" in invoked_argv
58
+
59
+
60
+ def test_should_filter_to_copilot_reviewer_only() -> None:
61
+ pages_payload = json.dumps(
62
+ [
63
+ [
64
+ {
65
+ "id": 1,
66
+ "user": {"login": "cursor[bot]"},
67
+ "state": "COMMENTED",
68
+ "commit_id": "abc",
69
+ "submitted_at": "2026-01-01T00:00:00Z",
70
+ "body": "bugbot stuff",
71
+ },
72
+ {
73
+ "id": 2,
74
+ "user": {"login": "copilot-pull-request-reviewer[bot]"},
75
+ "state": "CHANGES_REQUESTED",
76
+ "commit_id": "abc",
77
+ "submitted_at": "2026-01-02T00:00:00Z",
78
+ "body": "copilot finding",
79
+ },
80
+ ]
81
+ ]
82
+ )
83
+ with patch("subprocess.run") as mock_run:
84
+ mock_run.return_value = _completed(pages_payload)
85
+ all_reviews = fetch_copilot_reviews_module.fetch_copilot_reviews(
86
+ owner="acme", repo="widget", number=42
87
+ )
88
+ assert len(all_reviews) == 1
89
+ assert all_reviews[0]["review_id"] == 2
90
+
91
+
92
+ def test_should_return_reviews_newest_first_across_pages() -> None:
93
+ pages_payload = json.dumps(
94
+ [
95
+ [
96
+ {
97
+ "id": 10,
98
+ "user": {"login": "copilot-pull-request-reviewer[bot]"},
99
+ "state": "APPROVED",
100
+ "commit_id": "old",
101
+ "submitted_at": "2026-01-01T00:00:00Z",
102
+ "body": "lgtm",
103
+ }
104
+ ],
105
+ [
106
+ {
107
+ "id": 11,
108
+ "user": {"login": "copilot-pull-request-reviewer[bot]"},
109
+ "state": "CHANGES_REQUESTED",
110
+ "commit_id": "new",
111
+ "submitted_at": "2026-01-03T00:00:00Z",
112
+ "body": "issues found",
113
+ },
114
+ {
115
+ "id": 12,
116
+ "user": {"login": "copilot-pull-request-reviewer[bot]"},
117
+ "state": "APPROVED",
118
+ "commit_id": "mid",
119
+ "submitted_at": "2026-01-02T00:00:00Z",
120
+ "body": "lgtm",
121
+ },
122
+ ],
123
+ ]
124
+ )
125
+ with patch("subprocess.run") as mock_run:
126
+ mock_run.return_value = _completed(pages_payload)
127
+ all_reviews = fetch_copilot_reviews_module.fetch_copilot_reviews(
128
+ owner="acme", repo="widget", number=42
129
+ )
130
+ submitted_at_sequence = [each_review["submitted_at"] for each_review in all_reviews]
131
+ assert submitted_at_sequence == [
132
+ "2026-01-03T00:00:00Z",
133
+ "2026-01-02T00:00:00Z",
134
+ "2026-01-01T00:00:00Z",
135
+ ]
136
+
137
+
138
+ def test_should_classify_dirty_review_when_state_is_changes_requested() -> None:
139
+ pages_payload = json.dumps(
140
+ [
141
+ [
142
+ {
143
+ "id": 1,
144
+ "user": {"login": "copilot-pull-request-reviewer[bot]"},
145
+ "state": "CHANGES_REQUESTED",
146
+ "commit_id": "abc",
147
+ "submitted_at": "2026-01-01T00:00:00Z",
148
+ "body": "Issues need addressing.",
149
+ }
150
+ ]
151
+ ]
152
+ )
153
+ with patch("subprocess.run") as mock_run:
154
+ mock_run.return_value = _completed(pages_payload)
155
+ all_reviews = fetch_copilot_reviews_module.fetch_copilot_reviews(
156
+ owner="acme", repo="widget", number=42
157
+ )
158
+ assert all_reviews[0]["classification"] == "dirty"
159
+
160
+
161
+ def test_should_classify_dirty_review_when_state_is_commented_with_body() -> None:
162
+ pages_payload = json.dumps(
163
+ [
164
+ [
165
+ {
166
+ "id": 1,
167
+ "user": {"login": "copilot-pull-request-reviewer[bot]"},
168
+ "state": "COMMENTED",
169
+ "commit_id": "abc",
170
+ "submitted_at": "2026-01-01T00:00:00Z",
171
+ "body": "Found a couple of nits inline.",
172
+ }
173
+ ]
174
+ ]
175
+ )
176
+ with patch("subprocess.run") as mock_run:
177
+ mock_run.return_value = _completed(pages_payload)
178
+ all_reviews = fetch_copilot_reviews_module.fetch_copilot_reviews(
179
+ owner="acme", repo="widget", number=42
180
+ )
181
+ assert all_reviews[0]["classification"] == "dirty"
182
+
183
+
184
+ def test_should_classify_clean_review_when_state_is_commented_with_empty_body() -> None:
185
+ pages_payload = json.dumps(
186
+ [
187
+ [
188
+ {
189
+ "id": 1,
190
+ "user": {"login": "copilot-pull-request-reviewer[bot]"},
191
+ "state": "COMMENTED",
192
+ "commit_id": "abc",
193
+ "submitted_at": "2026-01-01T00:00:00Z",
194
+ "body": "",
195
+ }
196
+ ]
197
+ ]
198
+ )
199
+ with patch("subprocess.run") as mock_run:
200
+ mock_run.return_value = _completed(pages_payload)
201
+ all_reviews = fetch_copilot_reviews_module.fetch_copilot_reviews(
202
+ owner="acme", repo="widget", number=42
203
+ )
204
+ assert all_reviews[0]["classification"] == "clean"
205
+
206
+
207
+ def test_should_dispatch_dirty_classification_off_copilot_dirty_review_states_tuple() -> None:
208
+ source_text = (
209
+ Path(__file__).resolve().parent / "fetch_copilot_reviews.py"
210
+ ).read_text(encoding="utf-8")
211
+ assert "ALL_COPILOT_DIRTY_REVIEW_STATES" in source_text
212
+ assert "in ALL_COPILOT_DIRTY_REVIEW_STATES" in source_text
213
+
214
+
215
+ def test_should_classify_clean_review_when_state_is_approved() -> None:
216
+ pages_payload = json.dumps(
217
+ [
218
+ [
219
+ {
220
+ "id": 1,
221
+ "user": {"login": "copilot-pull-request-reviewer[bot]"},
222
+ "state": "APPROVED",
223
+ "commit_id": "abc",
224
+ "submitted_at": "2026-01-01T00:00:00Z",
225
+ "body": "looks good",
226
+ }
227
+ ]
228
+ ]
229
+ )
230
+ with patch("subprocess.run") as mock_run:
231
+ mock_run.return_value = _completed(pages_payload)
232
+ all_reviews = fetch_copilot_reviews_module.fetch_copilot_reviews(
233
+ owner="acme", repo="widget", number=42
234
+ )
235
+ assert all_reviews[0]["classification"] == "clean"
236
+
237
+
238
+ def test_should_reference_copilot_login_constant_directly_without_local_alias() -> None:
239
+ source_text = (
240
+ Path(__file__).resolve().parent / "fetch_copilot_reviews.py"
241
+ ).read_text(encoding="utf-8")
242
+ assert "copilot_reviewer_login = COPILOT_REVIEWER_LOGIN" not in source_text
243
+ assert "COPILOT_REVIEWER_LOGIN" in source_text
244
+
245
+
246
+ def test_should_raise_when_gh_subprocess_fails() -> None:
247
+ failure = subprocess.CalledProcessError(
248
+ returncode=1, cmd=["gh"], stderr="auth failure"
249
+ )
250
+ with patch("subprocess.run", side_effect=failure):
251
+ with pytest.raises(subprocess.CalledProcessError):
252
+ fetch_copilot_reviews_module.fetch_copilot_reviews(
253
+ owner="acme", repo="widget", number=42
254
+ )
255
+
256
+
257
+ def test_should_return_entries_whose_keys_are_strings() -> None:
258
+ pages_payload = json.dumps(
259
+ [
260
+ [
261
+ {
262
+ "id": 1,
263
+ "user": {"login": "copilot-pull-request-reviewer[bot]"},
264
+ "state": "APPROVED",
265
+ "commit_id": "abc",
266
+ "submitted_at": "2026-01-01T00:00:00Z",
267
+ "body": "looks good",
268
+ }
269
+ ]
270
+ ]
271
+ )
272
+ with patch("subprocess.run") as mock_run:
273
+ mock_run.return_value = _completed(pages_payload)
274
+ all_reviews = fetch_copilot_reviews_module.fetch_copilot_reviews(
275
+ owner="acme", repo="widget", number=42
276
+ )
277
+ assert len(all_reviews) == 1
278
+ first_review_entry = all_reviews[0]
279
+ assert isinstance(first_review_entry, dict)
280
+ assert all(isinstance(each_key, str) for each_key in first_review_entry.keys())
@@ -0,0 +1,69 @@
1
+ """Tests for mark_pr_ready.
2
+
3
+ Covers:
4
+ - gh pr ready is invoked with the PR number and --repo flag
5
+ - subprocess errors propagate
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import subprocess
12
+ from pathlib import Path
13
+ from types import ModuleType
14
+ from unittest.mock import MagicMock, patch
15
+
16
+ import pytest
17
+
18
+
19
+ def _load_module() -> ModuleType:
20
+ module_path = Path(__file__).parent / "mark_pr_ready.py"
21
+ spec = importlib.util.spec_from_file_location("mark_pr_ready", module_path)
22
+ assert spec is not None
23
+ assert spec.loader is not None
24
+ module = importlib.util.module_from_spec(spec)
25
+ spec.loader.exec_module(module)
26
+ return module
27
+
28
+
29
+ mark_pr_ready_module = _load_module()
30
+
31
+
32
+ def _completed(stdout: str) -> subprocess.CompletedProcess:
33
+ process = MagicMock(spec=subprocess.CompletedProcess)
34
+ process.stdout = stdout
35
+ process.returncode = 0
36
+ return process
37
+
38
+
39
+ def test_should_invoke_gh_pr_ready_with_number_and_repo() -> None:
40
+ with patch("subprocess.run") as mock_run:
41
+ mock_run.return_value = _completed('Pull request "#42" is marked as ready')
42
+ mark_pr_ready_module.mark_pr_ready(owner="acme", repo="widget", number=42)
43
+ invoked_argv = mock_run.call_args[0][0]
44
+ assert invoked_argv[0:3] == ["gh", "pr", "ready"]
45
+ assert "42" in invoked_argv
46
+ assert "--repo" in invoked_argv
47
+ assert "acme/widget" in invoked_argv
48
+
49
+
50
+ def test_should_raise_when_gh_subprocess_fails() -> None:
51
+ failure = subprocess.CalledProcessError(
52
+ returncode=1, cmd=["gh"], stderr="auth failure"
53
+ )
54
+ with patch("subprocess.run", side_effect=failure):
55
+ with pytest.raises(subprocess.CalledProcessError):
56
+ mark_pr_ready_module.mark_pr_ready(owner="acme", repo="widget", number=42)
57
+
58
+
59
+ def test_should_render_repo_arg_via_named_template_constant() -> None:
60
+ with patch("subprocess.run") as mock_run:
61
+ mock_run.return_value = _completed("ok\n")
62
+ mark_pr_ready_module.mark_pr_ready(owner="acme", repo="widget", number=42)
63
+ invoked_argv = mock_run.call_args[0][0]
64
+ expected_repo_arg = mark_pr_ready_module.GH_REPO_ARG_TEMPLATE.format(
65
+ owner="acme", repo="widget"
66
+ )
67
+ assert expected_repo_arg == "acme/widget"
68
+ repo_flag_index = invoked_argv.index("--repo")
69
+ assert invoked_argv[repo_flag_index + 1] == expected_repo_arg
@@ -0,0 +1,236 @@
1
+ """Tests for open_followup_copilot_pr.
2
+
3
+ Covers:
4
+ - branch name follows COPILOT_FOLLOWUP_BRANCH_TEMPLATE with the short SHA
5
+ - subprocess sequence: gh pr view (base ref) -> git fetch -> git switch -c -> git push -> gh pr create
6
+ - gh pr create uses --draft, --base, --head, --title, --body-file (per gh-body-file rule)
7
+ - the returned PR URL is the trimmed stdout from gh pr create
8
+ - subprocess errors propagate
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib.util
14
+ import json
15
+ import subprocess
16
+ from pathlib import Path
17
+ from types import ModuleType
18
+ from unittest.mock import MagicMock, patch
19
+
20
+ import pytest
21
+
22
+
23
+ def _load_module() -> ModuleType:
24
+ module_path = Path(__file__).parent / "open_followup_copilot_pr.py"
25
+ spec = importlib.util.spec_from_file_location(
26
+ "open_followup_copilot_pr", module_path
27
+ )
28
+ assert spec is not None
29
+ assert spec.loader is not None
30
+ module = importlib.util.module_from_spec(spec)
31
+ spec.loader.exec_module(module)
32
+ return module
33
+
34
+
35
+ open_followup_copilot_pr_module = _load_module()
36
+
37
+
38
+ def _completed(stdout: str) -> subprocess.CompletedProcess:
39
+ process = MagicMock(spec=subprocess.CompletedProcess)
40
+ process.stdout = stdout
41
+ process.returncode = 0
42
+ return process
43
+
44
+
45
+ def _scripted_subprocess_runs(
46
+ *,
47
+ base_ref_payload: str,
48
+ new_pr_url: str,
49
+ ) -> list[subprocess.CompletedProcess]:
50
+ return [
51
+ _completed(base_ref_payload),
52
+ _completed(""),
53
+ _completed(""),
54
+ _completed(""),
55
+ _completed(new_pr_url),
56
+ ]
57
+
58
+
59
+ def test_should_build_branch_name_from_parent_number_and_short_sha(
60
+ tmp_path: Path,
61
+ ) -> None:
62
+ findings_file = tmp_path / "findings.md"
63
+ findings_file.write_text("- Item 1\n", encoding="utf-8")
64
+ payload_sequence = _scripted_subprocess_runs(
65
+ base_ref_payload=json.dumps({"baseRefName": "main"}),
66
+ new_pr_url="https://github.com/acme/widget/pull/313\n",
67
+ )
68
+ with patch("subprocess.run", side_effect=payload_sequence) as mock_run:
69
+ open_followup_copilot_pr_module.open_followup_copilot_pr(
70
+ owner="acme",
71
+ repo="widget",
72
+ parent_number=312,
73
+ head="abc12345deadbeefcafe",
74
+ findings_file=findings_file,
75
+ )
76
+ git_switch_argv = mock_run.call_args_list[2][0][0]
77
+ expected_branch = "chore/copilot-followup-312-abc12345"
78
+ assert expected_branch in git_switch_argv
79
+
80
+
81
+ def test_should_invoke_subprocess_call_sequence_in_documented_order(
82
+ tmp_path: Path,
83
+ ) -> None:
84
+ findings_file = tmp_path / "findings.md"
85
+ findings_file.write_text("- Item\n", encoding="utf-8")
86
+ payload_sequence = _scripted_subprocess_runs(
87
+ base_ref_payload=json.dumps({"baseRefName": "main"}),
88
+ new_pr_url="https://github.com/acme/widget/pull/313\n",
89
+ )
90
+ with patch("subprocess.run", side_effect=payload_sequence) as mock_run:
91
+ open_followup_copilot_pr_module.open_followup_copilot_pr(
92
+ owner="acme",
93
+ repo="widget",
94
+ parent_number=312,
95
+ head="abc12345deadbeefcafe",
96
+ findings_file=findings_file,
97
+ )
98
+ invoked_command_sequence = [
99
+ each_call[0][0] for each_call in mock_run.call_args_list
100
+ ]
101
+ assert invoked_command_sequence[0][0:3] == ["gh", "pr", "view"]
102
+ assert invoked_command_sequence[1][0:2] == ["git", "fetch"]
103
+ assert invoked_command_sequence[2][0:3] == ["git", "switch", "-c"]
104
+ assert invoked_command_sequence[3][0:2] == ["git", "push"]
105
+ assert invoked_command_sequence[4][0:3] == ["gh", "pr", "create"]
106
+
107
+
108
+ def test_should_invoke_gh_pr_create_with_draft_and_body_file_flags(
109
+ tmp_path: Path,
110
+ ) -> None:
111
+ findings_file = tmp_path / "findings.md"
112
+ findings_file.write_text("- Finding A\n", encoding="utf-8")
113
+ payload_sequence = _scripted_subprocess_runs(
114
+ base_ref_payload=json.dumps({"baseRefName": "develop"}),
115
+ new_pr_url="https://github.com/acme/widget/pull/444\n",
116
+ )
117
+ with patch("subprocess.run", side_effect=payload_sequence) as mock_run:
118
+ open_followup_copilot_pr_module.open_followup_copilot_pr(
119
+ owner="acme",
120
+ repo="widget",
121
+ parent_number=312,
122
+ head="abc12345deadbeef",
123
+ findings_file=findings_file,
124
+ )
125
+ pr_create_argv = mock_run.call_args_list[4][0][0]
126
+ assert pr_create_argv[0:3] == ["gh", "pr", "create"]
127
+ assert "--draft" in pr_create_argv
128
+ assert "--base" in pr_create_argv
129
+ assert "develop" in pr_create_argv
130
+ assert "--head" in pr_create_argv
131
+ assert "--title" in pr_create_argv
132
+ assert "--body-file" in pr_create_argv
133
+ body_file_argv = pr_create_argv[pr_create_argv.index("--body-file") + 1]
134
+ assert body_file_argv == str(findings_file)
135
+
136
+
137
+ def test_should_render_pr_title_via_named_template_constant(tmp_path: Path) -> None:
138
+ findings_file = tmp_path / "findings.md"
139
+ findings_file.write_text("- Finding\n", encoding="utf-8")
140
+ payload_sequence = _scripted_subprocess_runs(
141
+ base_ref_payload=json.dumps({"baseRefName": "main"}),
142
+ new_pr_url="https://github.com/acme/widget/pull/513\n",
143
+ )
144
+ with patch("subprocess.run", side_effect=payload_sequence) as mock_run:
145
+ open_followup_copilot_pr_module.open_followup_copilot_pr(
146
+ owner="acme",
147
+ repo="widget",
148
+ parent_number=312,
149
+ head="abc12345deadbeef",
150
+ findings_file=findings_file,
151
+ )
152
+ pr_create_argv = mock_run.call_args_list[4][0][0]
153
+ title_index = pr_create_argv.index("--title")
154
+ title_value = pr_create_argv[title_index + 1]
155
+ assert title_value == "chore: address Copilot findings from PR #312"
156
+
157
+
158
+ def test_should_return_trimmed_pr_url(tmp_path: Path) -> None:
159
+ findings_file = tmp_path / "findings.md"
160
+ findings_file.write_text("- Finding\n", encoding="utf-8")
161
+ payload_sequence = _scripted_subprocess_runs(
162
+ base_ref_payload=json.dumps({"baseRefName": "main"}),
163
+ new_pr_url=" https://github.com/acme/widget/pull/313 \n",
164
+ )
165
+ with patch("subprocess.run", side_effect=payload_sequence):
166
+ new_pr_url = open_followup_copilot_pr_module.open_followup_copilot_pr(
167
+ owner="acme",
168
+ repo="widget",
169
+ parent_number=312,
170
+ head="abc12345deadbeef",
171
+ findings_file=findings_file,
172
+ )
173
+ assert new_pr_url == "https://github.com/acme/widget/pull/313"
174
+
175
+
176
+ def test_should_pass_repo_arg_to_gh_pr_view_for_base_ref(tmp_path: Path) -> None:
177
+ findings_file = tmp_path / "findings.md"
178
+ findings_file.write_text("- Finding\n", encoding="utf-8")
179
+ payload_sequence = _scripted_subprocess_runs(
180
+ base_ref_payload=json.dumps({"baseRefName": "main"}),
181
+ new_pr_url="https://github.com/acme/widget/pull/313\n",
182
+ )
183
+ with patch("subprocess.run", side_effect=payload_sequence) as mock_run:
184
+ open_followup_copilot_pr_module.open_followup_copilot_pr(
185
+ owner="acme",
186
+ repo="widget",
187
+ parent_number=312,
188
+ head="abc12345deadbeef",
189
+ findings_file=findings_file,
190
+ )
191
+ pr_view_argv = mock_run.call_args_list[0][0][0]
192
+ assert pr_view_argv[0:3] == ["gh", "pr", "view"]
193
+ assert "--repo" in pr_view_argv
194
+ repo_arg_value = pr_view_argv[pr_view_argv.index("--repo") + 1]
195
+ assert repo_arg_value == "acme/widget"
196
+
197
+
198
+ def test_should_pass_repo_arg_to_gh_pr_create_for_followup_pr(
199
+ tmp_path: Path,
200
+ ) -> None:
201
+ findings_file = tmp_path / "findings.md"
202
+ findings_file.write_text("- Finding\n", encoding="utf-8")
203
+ payload_sequence = _scripted_subprocess_runs(
204
+ base_ref_payload=json.dumps({"baseRefName": "main"}),
205
+ new_pr_url="https://github.com/acme/widget/pull/313\n",
206
+ )
207
+ with patch("subprocess.run", side_effect=payload_sequence) as mock_run:
208
+ open_followup_copilot_pr_module.open_followup_copilot_pr(
209
+ owner="acme",
210
+ repo="widget",
211
+ parent_number=312,
212
+ head="abc12345deadbeef",
213
+ findings_file=findings_file,
214
+ )
215
+ pr_create_argv = mock_run.call_args_list[4][0][0]
216
+ assert pr_create_argv[0:3] == ["gh", "pr", "create"]
217
+ assert "--repo" in pr_create_argv
218
+ repo_arg_value = pr_create_argv[pr_create_argv.index("--repo") + 1]
219
+ assert repo_arg_value == "acme/widget"
220
+
221
+
222
+ def test_should_raise_when_subprocess_fails(tmp_path: Path) -> None:
223
+ findings_file = tmp_path / "findings.md"
224
+ findings_file.write_text("- Finding\n", encoding="utf-8")
225
+ failure = subprocess.CalledProcessError(
226
+ returncode=1, cmd=["gh"], stderr="auth failure"
227
+ )
228
+ with patch("subprocess.run", side_effect=failure):
229
+ with pytest.raises(subprocess.CalledProcessError):
230
+ open_followup_copilot_pr_module.open_followup_copilot_pr(
231
+ owner="acme",
232
+ repo="widget",
233
+ parent_number=312,
234
+ head="abc12345",
235
+ findings_file=findings_file,
236
+ )