claude-dev-env 1.81.0 → 1.82.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/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/conventional_pr_title_gate.py +444 -0
- package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
- package/package.json +1 -1
- package/skills/autoconverge/SKILL.md +90 -5
- package/skills/autoconverge/reference/gotchas.md +5 -5
- package/skills/autoconverge/workflow/converge.contract.test.mjs +159 -27
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +104 -7
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.mjs +62 -22
- package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +26 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""Unit tests for conventional_pr_title_gate PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import pathlib
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
10
|
+
|
|
11
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
12
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
14
|
+
|
|
15
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
16
|
+
"conventional_pr_title_gate",
|
|
17
|
+
_HOOK_DIR / "conventional_pr_title_gate.py",
|
|
18
|
+
)
|
|
19
|
+
assert hook_spec is not None
|
|
20
|
+
assert hook_spec.loader is not None
|
|
21
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
22
|
+
hook_spec.loader.exec_module(hook_module)
|
|
23
|
+
|
|
24
|
+
_matches_gh_pr_title_subcommand = hook_module._matches_gh_pr_title_subcommand
|
|
25
|
+
_parsed_command_tokens = hook_module._parsed_command_tokens
|
|
26
|
+
_extract_flag_value = hook_module._extract_flag_value
|
|
27
|
+
_is_conventional_commit_title = hook_module._is_conventional_commit_title
|
|
28
|
+
_repo_enforces_default_conventional_pr_titles = (
|
|
29
|
+
hook_module._repo_enforces_default_conventional_pr_titles
|
|
30
|
+
)
|
|
31
|
+
_workflow_customizes_semantic_types = hook_module._workflow_customizes_semantic_types
|
|
32
|
+
_pull_request_title_to_validate = hook_module._pull_request_title_to_validate
|
|
33
|
+
|
|
34
|
+
_SEMANTIC_WORKFLOW_TEXT = (
|
|
35
|
+
"name: PR checks\n"
|
|
36
|
+
"on:\n"
|
|
37
|
+
" pull_request:\n"
|
|
38
|
+
"jobs:\n"
|
|
39
|
+
" validate:\n"
|
|
40
|
+
" steps:\n"
|
|
41
|
+
" - uses: amannn/action-semantic-pull-request@v5\n"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_PLAIN_WORKFLOW_TEXT = (
|
|
45
|
+
"name: Tests\non:\n push:\njobs:\n test:\n steps:\n - run: npm test\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_SEMANTIC_WORKFLOW_WITH_CUSTOM_TYPES_TEXT = (
|
|
49
|
+
"name: PR checks\n"
|
|
50
|
+
"on:\n"
|
|
51
|
+
" pull_request:\n"
|
|
52
|
+
" types: [opened, edited, synchronize]\n"
|
|
53
|
+
"jobs:\n"
|
|
54
|
+
" validate:\n"
|
|
55
|
+
" steps:\n"
|
|
56
|
+
" - uses: amannn/action-semantic-pull-request@v5\n"
|
|
57
|
+
" env:\n"
|
|
58
|
+
" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
|
|
59
|
+
" with:\n"
|
|
60
|
+
" types: |\n"
|
|
61
|
+
" feat\n"
|
|
62
|
+
" fix\n"
|
|
63
|
+
" wip\n"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_SEMANTIC_WORKFLOW_WITH_EVENT_TYPES_ONLY_TEXT = (
|
|
67
|
+
"name: PR checks\n"
|
|
68
|
+
"on:\n"
|
|
69
|
+
" pull_request:\n"
|
|
70
|
+
" types: [opened, edited, synchronize]\n"
|
|
71
|
+
"jobs:\n"
|
|
72
|
+
" validate:\n"
|
|
73
|
+
" steps:\n"
|
|
74
|
+
" - uses: amannn/action-semantic-pull-request@v5\n"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
_SEMANTIC_WORKFLOW_NAME_STEP_CUSTOM_TYPES_TEXT = (
|
|
78
|
+
"name: PR checks\n"
|
|
79
|
+
"on:\n"
|
|
80
|
+
" pull_request:\n"
|
|
81
|
+
" types: [opened, edited, synchronize]\n"
|
|
82
|
+
"jobs:\n"
|
|
83
|
+
" validate:\n"
|
|
84
|
+
" steps:\n"
|
|
85
|
+
" - name: Validate PR title\n"
|
|
86
|
+
" uses: amannn/action-semantic-pull-request@v5\n"
|
|
87
|
+
" with:\n"
|
|
88
|
+
" types: |\n"
|
|
89
|
+
" feat\n"
|
|
90
|
+
" fix\n"
|
|
91
|
+
" wip\n"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
_SEMANTIC_WORKFLOW_TYPES_ABOVE_USES_TEXT = (
|
|
95
|
+
"name: PR checks\n"
|
|
96
|
+
"on:\n"
|
|
97
|
+
" pull_request:\n"
|
|
98
|
+
" types: [opened, edited, synchronize]\n"
|
|
99
|
+
"jobs:\n"
|
|
100
|
+
" validate:\n"
|
|
101
|
+
" steps:\n"
|
|
102
|
+
" - name: Validate PR title\n"
|
|
103
|
+
" with:\n"
|
|
104
|
+
" types: |\n"
|
|
105
|
+
" feat\n"
|
|
106
|
+
" fix\n"
|
|
107
|
+
" wip\n"
|
|
108
|
+
" uses: amannn/action-semantic-pull-request@v5\n"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
_SEMANTIC_WORKFLOW_FLOW_STYLE_TYPES_TEXT = (
|
|
112
|
+
"name: PR checks\n"
|
|
113
|
+
"on:\n"
|
|
114
|
+
" pull_request:\n"
|
|
115
|
+
" types: [opened, edited, synchronize]\n"
|
|
116
|
+
"jobs:\n"
|
|
117
|
+
" validate:\n"
|
|
118
|
+
" steps:\n"
|
|
119
|
+
" - uses: amannn/action-semantic-pull-request@v5\n"
|
|
120
|
+
" with: { types: [feat, fix, wip] }\n"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _init_repo_with_workflow(repo_root: pathlib.Path, workflow_text: str) -> None:
|
|
125
|
+
subprocess.run(["git", "init", str(repo_root)], capture_output=True, check=True)
|
|
126
|
+
workflows_directory = repo_root / ".github" / "workflows"
|
|
127
|
+
workflows_directory.mkdir(parents=True)
|
|
128
|
+
(workflows_directory / "pr-check.yml").write_text(workflow_text, encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _run_hook_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
|
|
132
|
+
captured_stdout = io.StringIO()
|
|
133
|
+
captured_stderr = io.StringIO()
|
|
134
|
+
exit_code = 0
|
|
135
|
+
sys.stdin = io.StringIO(stdin_text)
|
|
136
|
+
try:
|
|
137
|
+
with redirect_stdout(captured_stdout), redirect_stderr(captured_stderr):
|
|
138
|
+
try:
|
|
139
|
+
hook_module.main()
|
|
140
|
+
except SystemExit as exit_signal:
|
|
141
|
+
exit_code = exit_signal.code or 0
|
|
142
|
+
finally:
|
|
143
|
+
sys.stdin = sys.__stdin__
|
|
144
|
+
return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _run_hook(hook_input: dict) -> tuple[str, int]:
|
|
148
|
+
stdout_text, _stderr_text, exit_code = _run_hook_with_stdin_text(json.dumps(hook_input))
|
|
149
|
+
return stdout_text, exit_code
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_matches_gh_pr_create_with_title() -> None:
|
|
153
|
+
assert _matches_gh_pr_title_subcommand('gh pr create --title "add x"')
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_matches_gh_pr_edit_with_title() -> None:
|
|
157
|
+
assert _matches_gh_pr_title_subcommand('gh pr edit 10 --title "add x"')
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_does_not_match_gh_pr_comment() -> None:
|
|
161
|
+
assert not _matches_gh_pr_title_subcommand('gh pr comment 10 --body "LGTM"')
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_does_not_match_unrelated_command() -> None:
|
|
165
|
+
assert not _matches_gh_pr_title_subcommand("gh pr list --repo owner/repo")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_does_not_match_echoed_gh_pr_create() -> None:
|
|
169
|
+
assert not _matches_gh_pr_title_subcommand('echo gh pr create --title "add x"')
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_matches_gh_pr_create_when_invoked_by_executable_path() -> None:
|
|
173
|
+
assert _matches_gh_pr_title_subcommand('/usr/bin/gh pr create --title "add x"')
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_extract_flag_value_space_form() -> None:
|
|
177
|
+
assert (
|
|
178
|
+
_extract_flag_value('gh pr create --title "feat: add x" --draft', "--title", "-t")
|
|
179
|
+
== "feat: add x"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_extract_flag_value_equals_form() -> None:
|
|
184
|
+
assert (
|
|
185
|
+
_extract_flag_value('gh pr create --title="fix: broken thing" --draft', "--title", "-t")
|
|
186
|
+
== "fix: broken thing"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_extract_flag_value_short_form() -> None:
|
|
191
|
+
assert _extract_flag_value('gh pr create -t "chore: bump deps"', "--title", "-t") == (
|
|
192
|
+
"chore: bump deps"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_extract_flag_value_returns_none_when_absent() -> None:
|
|
197
|
+
assert _extract_flag_value("gh pr create --draft", "--title", "-t") is None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_extract_flag_value_skips_title_word_embedded_in_label_value() -> None:
|
|
201
|
+
command = 'gh pr create --label="use --title inside" --title "feat: real"'
|
|
202
|
+
assert _extract_flag_value(command, "--title", "-t") == "feat: real"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_extract_flag_value_reads_attached_short_repo_flag() -> None:
|
|
206
|
+
assert (
|
|
207
|
+
_extract_flag_value("gh pr create -Rowner/other --title x", "--repo", "-R") == "owner/other"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_extract_flag_value_reads_equals_short_repo_flag() -> None:
|
|
212
|
+
assert (
|
|
213
|
+
_extract_flag_value("gh pr create -R=owner/other --title x", "--repo", "-R")
|
|
214
|
+
== "owner/other"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_parsed_command_tokens_returns_none_on_unparseable_command() -> None:
|
|
219
|
+
assert _parsed_command_tokens("gh pr create --title 'unmatched quote here") is None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_is_conventional_commit_title_accepts_plain_type() -> None:
|
|
223
|
+
assert _is_conventional_commit_title("feat: add the thing")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_is_conventional_commit_title_accepts_scope_and_breaking_marker() -> None:
|
|
227
|
+
assert _is_conventional_commit_title("feat(hooks)!: drop the old flag")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_is_conventional_commit_title_rejects_non_conventional_title() -> None:
|
|
231
|
+
assert not _is_conventional_commit_title("add the thing")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_pull_request_title_to_validate_returns_none_for_shell_variable_title() -> None:
|
|
235
|
+
assert _pull_request_title_to_validate('gh pr create --title "$TITLE"') is None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_pull_request_title_to_validate_returns_literal_for_plain_title() -> None:
|
|
239
|
+
assert (
|
|
240
|
+
_pull_request_title_to_validate('gh pr create --title "add the thing"') == "add the thing"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_pull_request_title_to_validate_reads_real_title_past_embedded_flag_word() -> None:
|
|
245
|
+
command = 'gh pr create --label="use --title inside" --title "feat: x"'
|
|
246
|
+
assert _pull_request_title_to_validate(command) == "feat: x"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_pull_request_title_to_validate_returns_none_for_attached_repo_flag() -> None:
|
|
250
|
+
command = 'gh pr create -Rowner/other --title "add the thing"'
|
|
251
|
+
assert _pull_request_title_to_validate(command) is None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_repo_enforces_default_conventional_pr_titles_true_for_marker_workflow(
|
|
255
|
+
tmp_path: pathlib.Path,
|
|
256
|
+
) -> None:
|
|
257
|
+
repo_root = tmp_path / "semantic_repo"
|
|
258
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TEXT)
|
|
259
|
+
assert _repo_enforces_default_conventional_pr_titles(str(repo_root))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_repo_enforces_default_conventional_pr_titles_false_for_plain_workflow(
|
|
263
|
+
tmp_path: pathlib.Path,
|
|
264
|
+
) -> None:
|
|
265
|
+
repo_root = tmp_path / "plain_repo"
|
|
266
|
+
_init_repo_with_workflow(repo_root, _PLAIN_WORKFLOW_TEXT)
|
|
267
|
+
assert not _repo_enforces_default_conventional_pr_titles(str(repo_root))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_repo_enforces_default_conventional_pr_titles_false_when_no_workflows_directory(
|
|
271
|
+
tmp_path: pathlib.Path,
|
|
272
|
+
) -> None:
|
|
273
|
+
repo_root = tmp_path / "no_workflows_repo"
|
|
274
|
+
subprocess.run(["git", "init", str(repo_root)], capture_output=True, check=True)
|
|
275
|
+
assert not _repo_enforces_default_conventional_pr_titles(str(repo_root))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_repo_enforces_default_conventional_pr_titles_false_when_action_customizes_types(
|
|
279
|
+
tmp_path: pathlib.Path,
|
|
280
|
+
) -> None:
|
|
281
|
+
repo_root = tmp_path / "custom_types_repo"
|
|
282
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_WITH_CUSTOM_TYPES_TEXT)
|
|
283
|
+
assert not _repo_enforces_default_conventional_pr_titles(str(repo_root))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_repo_enforces_default_conventional_pr_titles_true_when_only_event_types_present(
|
|
287
|
+
tmp_path: pathlib.Path,
|
|
288
|
+
) -> None:
|
|
289
|
+
repo_root = tmp_path / "event_types_repo"
|
|
290
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_WITH_EVENT_TYPES_ONLY_TEXT)
|
|
291
|
+
assert _repo_enforces_default_conventional_pr_titles(str(repo_root))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_repo_enforces_default_conventional_pr_titles_true_when_one_marker_workflow_keeps_default_types(
|
|
295
|
+
tmp_path: pathlib.Path,
|
|
296
|
+
) -> None:
|
|
297
|
+
repo_root = tmp_path / "mixed_types_repo"
|
|
298
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_WITH_CUSTOM_TYPES_TEXT)
|
|
299
|
+
workflows_directory = repo_root / ".github" / "workflows"
|
|
300
|
+
(workflows_directory / "pr-title-default.yml").write_text(
|
|
301
|
+
_SEMANTIC_WORKFLOW_TEXT, encoding="utf-8"
|
|
302
|
+
)
|
|
303
|
+
assert _repo_enforces_default_conventional_pr_titles(str(repo_root))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_workflow_customizes_semantic_types_true_for_action_types_input() -> None:
|
|
307
|
+
assert _workflow_customizes_semantic_types(_SEMANTIC_WORKFLOW_WITH_CUSTOM_TYPES_TEXT)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_workflow_customizes_semantic_types_false_for_event_types_only() -> None:
|
|
311
|
+
assert not _workflow_customizes_semantic_types(_SEMANTIC_WORKFLOW_WITH_EVENT_TYPES_ONLY_TEXT)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_workflow_customizes_semantic_types_true_for_name_step_layout() -> None:
|
|
315
|
+
assert _workflow_customizes_semantic_types(_SEMANTIC_WORKFLOW_NAME_STEP_CUSTOM_TYPES_TEXT)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_repo_enforces_default_conventional_pr_titles_false_for_name_step_custom_types(
|
|
319
|
+
tmp_path: pathlib.Path,
|
|
320
|
+
) -> None:
|
|
321
|
+
repo_root = tmp_path / "name_step_custom_types_repo"
|
|
322
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_NAME_STEP_CUSTOM_TYPES_TEXT)
|
|
323
|
+
assert not _repo_enforces_default_conventional_pr_titles(str(repo_root))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_workflow_customizes_semantic_types_true_when_types_above_uses() -> None:
|
|
327
|
+
assert _workflow_customizes_semantic_types(_SEMANTIC_WORKFLOW_TYPES_ABOVE_USES_TEXT)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_repo_enforces_default_conventional_pr_titles_false_when_types_above_uses(
|
|
331
|
+
tmp_path: pathlib.Path,
|
|
332
|
+
) -> None:
|
|
333
|
+
repo_root = tmp_path / "types_above_uses_repo"
|
|
334
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TYPES_ABOVE_USES_TEXT)
|
|
335
|
+
assert not _repo_enforces_default_conventional_pr_titles(str(repo_root))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_workflow_customizes_semantic_types_true_for_flow_style_types() -> None:
|
|
339
|
+
assert _workflow_customizes_semantic_types(_SEMANTIC_WORKFLOW_FLOW_STYLE_TYPES_TEXT)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_repo_enforces_default_conventional_pr_titles_false_for_flow_style_types(
|
|
343
|
+
tmp_path: pathlib.Path,
|
|
344
|
+
) -> None:
|
|
345
|
+
repo_root = tmp_path / "flow_style_types_repo"
|
|
346
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_FLOW_STYLE_TYPES_TEXT)
|
|
347
|
+
assert not _repo_enforces_default_conventional_pr_titles(str(repo_root))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_main_blocks_non_conventional_title_in_semantic_ci_repo(
|
|
351
|
+
tmp_path: pathlib.Path,
|
|
352
|
+
) -> None:
|
|
353
|
+
repo_root = tmp_path / "semantic_repo"
|
|
354
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TEXT)
|
|
355
|
+
stdout_text, exit_code = _run_hook(
|
|
356
|
+
{
|
|
357
|
+
"tool_name": "Bash",
|
|
358
|
+
"cwd": str(repo_root),
|
|
359
|
+
"tool_input": {"command": 'gh pr create --title "add the thing"'},
|
|
360
|
+
}
|
|
361
|
+
)
|
|
362
|
+
assert exit_code == 0
|
|
363
|
+
response_payload = json.loads(stdout_text)
|
|
364
|
+
decision_block = response_payload["hookSpecificOutput"]
|
|
365
|
+
assert decision_block["permissionDecision"] == "deny"
|
|
366
|
+
assert "conventional-pr-title" in decision_block["permissionDecisionReason"]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_main_allows_shell_variable_title_in_semantic_ci_repo(tmp_path: pathlib.Path) -> None:
|
|
370
|
+
repo_root = tmp_path / "semantic_repo"
|
|
371
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TEXT)
|
|
372
|
+
stdout_text, exit_code = _run_hook(
|
|
373
|
+
{
|
|
374
|
+
"tool_name": "Bash",
|
|
375
|
+
"cwd": str(repo_root),
|
|
376
|
+
"tool_input": {"command": 'gh pr create --title "$TITLE"'},
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
assert exit_code == 0
|
|
380
|
+
assert stdout_text == ""
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_main_allows_conventional_title_in_semantic_ci_repo(tmp_path: pathlib.Path) -> None:
|
|
384
|
+
repo_root = tmp_path / "semantic_repo"
|
|
385
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TEXT)
|
|
386
|
+
stdout_text, exit_code = _run_hook(
|
|
387
|
+
{
|
|
388
|
+
"tool_name": "Bash",
|
|
389
|
+
"cwd": str(repo_root),
|
|
390
|
+
"tool_input": {"command": 'gh pr create --title "feat(hooks): add the thing"'},
|
|
391
|
+
}
|
|
392
|
+
)
|
|
393
|
+
assert exit_code == 0
|
|
394
|
+
assert stdout_text == ""
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_main_allows_conventional_title_with_scope_and_breaking_marker(
|
|
398
|
+
tmp_path: pathlib.Path,
|
|
399
|
+
) -> None:
|
|
400
|
+
repo_root = tmp_path / "semantic_repo"
|
|
401
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TEXT)
|
|
402
|
+
stdout_text, exit_code = _run_hook(
|
|
403
|
+
{
|
|
404
|
+
"tool_name": "Bash",
|
|
405
|
+
"cwd": str(repo_root),
|
|
406
|
+
"tool_input": {"command": 'gh pr create --title "feat(x)!: drop y"'},
|
|
407
|
+
}
|
|
408
|
+
)
|
|
409
|
+
assert exit_code == 0
|
|
410
|
+
assert stdout_text == ""
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def test_main_allows_junk_title_when_no_semantic_marker(tmp_path: pathlib.Path) -> None:
|
|
414
|
+
repo_root = tmp_path / "plain_repo"
|
|
415
|
+
_init_repo_with_workflow(repo_root, _PLAIN_WORKFLOW_TEXT)
|
|
416
|
+
stdout_text, exit_code = _run_hook(
|
|
417
|
+
{
|
|
418
|
+
"tool_name": "Bash",
|
|
419
|
+
"cwd": str(repo_root),
|
|
420
|
+
"tool_input": {"command": 'gh pr create --title "add the thing"'},
|
|
421
|
+
}
|
|
422
|
+
)
|
|
423
|
+
assert exit_code == 0
|
|
424
|
+
assert stdout_text == ""
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_main_allows_custom_type_title_when_action_customizes_types(
|
|
428
|
+
tmp_path: pathlib.Path,
|
|
429
|
+
) -> None:
|
|
430
|
+
repo_root = tmp_path / "custom_types_repo"
|
|
431
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_WITH_CUSTOM_TYPES_TEXT)
|
|
432
|
+
stdout_text, exit_code = _run_hook(
|
|
433
|
+
{
|
|
434
|
+
"tool_name": "Bash",
|
|
435
|
+
"cwd": str(repo_root),
|
|
436
|
+
"tool_input": {"command": 'gh pr create --title "wip: iterate on the thing"'},
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
assert exit_code == 0
|
|
440
|
+
assert stdout_text == ""
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def test_main_blocks_non_conventional_title_when_one_marker_workflow_keeps_default_types(
|
|
444
|
+
tmp_path: pathlib.Path,
|
|
445
|
+
) -> None:
|
|
446
|
+
repo_root = tmp_path / "mixed_types_repo"
|
|
447
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_WITH_CUSTOM_TYPES_TEXT)
|
|
448
|
+
workflows_directory = repo_root / ".github" / "workflows"
|
|
449
|
+
(workflows_directory / "pr-title-default.yml").write_text(
|
|
450
|
+
_SEMANTIC_WORKFLOW_TEXT, encoding="utf-8"
|
|
451
|
+
)
|
|
452
|
+
stdout_text, exit_code = _run_hook(
|
|
453
|
+
{
|
|
454
|
+
"tool_name": "Bash",
|
|
455
|
+
"cwd": str(repo_root),
|
|
456
|
+
"tool_input": {"command": 'gh pr create --title "add the thing"'},
|
|
457
|
+
}
|
|
458
|
+
)
|
|
459
|
+
assert exit_code == 0
|
|
460
|
+
response_payload = json.loads(stdout_text)
|
|
461
|
+
decision_block = response_payload["hookSpecificOutput"]
|
|
462
|
+
assert decision_block["permissionDecision"] == "deny"
|
|
463
|
+
assert "conventional-pr-title" in decision_block["permissionDecisionReason"]
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_main_allows_custom_type_title_for_name_step_layout(
|
|
467
|
+
tmp_path: pathlib.Path,
|
|
468
|
+
) -> None:
|
|
469
|
+
repo_root = tmp_path / "name_step_custom_types_repo"
|
|
470
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_NAME_STEP_CUSTOM_TYPES_TEXT)
|
|
471
|
+
stdout_text, exit_code = _run_hook(
|
|
472
|
+
{
|
|
473
|
+
"tool_name": "Bash",
|
|
474
|
+
"cwd": str(repo_root),
|
|
475
|
+
"tool_input": {"command": 'gh pr create --title "wip: iterate on the thing"'},
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
assert exit_code == 0
|
|
479
|
+
assert stdout_text == ""
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def test_main_allows_custom_type_title_when_types_above_uses(
|
|
483
|
+
tmp_path: pathlib.Path,
|
|
484
|
+
) -> None:
|
|
485
|
+
repo_root = tmp_path / "types_above_uses_repo"
|
|
486
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TYPES_ABOVE_USES_TEXT)
|
|
487
|
+
stdout_text, exit_code = _run_hook(
|
|
488
|
+
{
|
|
489
|
+
"tool_name": "Bash",
|
|
490
|
+
"cwd": str(repo_root),
|
|
491
|
+
"tool_input": {"command": 'gh pr create --title "wip: iterate on the thing"'},
|
|
492
|
+
}
|
|
493
|
+
)
|
|
494
|
+
assert exit_code == 0
|
|
495
|
+
assert stdout_text == ""
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_main_allows_custom_type_title_for_flow_style_types(
|
|
499
|
+
tmp_path: pathlib.Path,
|
|
500
|
+
) -> None:
|
|
501
|
+
repo_root = tmp_path / "flow_style_types_repo"
|
|
502
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_FLOW_STYLE_TYPES_TEXT)
|
|
503
|
+
stdout_text, exit_code = _run_hook(
|
|
504
|
+
{
|
|
505
|
+
"tool_name": "Bash",
|
|
506
|
+
"cwd": str(repo_root),
|
|
507
|
+
"tool_input": {"command": 'gh pr create --title "wip: iterate on the thing"'},
|
|
508
|
+
}
|
|
509
|
+
)
|
|
510
|
+
assert exit_code == 0
|
|
511
|
+
assert stdout_text == ""
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def test_main_allows_conventional_title_past_embedded_flag_word_in_label(
|
|
515
|
+
tmp_path: pathlib.Path,
|
|
516
|
+
) -> None:
|
|
517
|
+
repo_root = tmp_path / "semantic_repo"
|
|
518
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TEXT)
|
|
519
|
+
stdout_text, exit_code = _run_hook(
|
|
520
|
+
{
|
|
521
|
+
"tool_name": "Bash",
|
|
522
|
+
"cwd": str(repo_root),
|
|
523
|
+
"tool_input": {
|
|
524
|
+
"command": 'gh pr create --label="use --title inside" --title "feat: add x"'
|
|
525
|
+
},
|
|
526
|
+
}
|
|
527
|
+
)
|
|
528
|
+
assert exit_code == 0
|
|
529
|
+
assert stdout_text == ""
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def test_main_allows_cross_repo_title_with_attached_repo_flag(
|
|
533
|
+
tmp_path: pathlib.Path,
|
|
534
|
+
) -> None:
|
|
535
|
+
repo_root = tmp_path / "semantic_repo"
|
|
536
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TEXT)
|
|
537
|
+
stdout_text, exit_code = _run_hook(
|
|
538
|
+
{
|
|
539
|
+
"tool_name": "Bash",
|
|
540
|
+
"cwd": str(repo_root),
|
|
541
|
+
"tool_input": {"command": 'gh pr create -Rowner/other --title "add the thing"'},
|
|
542
|
+
}
|
|
543
|
+
)
|
|
544
|
+
assert exit_code == 0
|
|
545
|
+
assert stdout_text == ""
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def test_main_blocks_non_conventional_title_when_only_event_types_present(
|
|
549
|
+
tmp_path: pathlib.Path,
|
|
550
|
+
) -> None:
|
|
551
|
+
repo_root = tmp_path / "event_types_repo"
|
|
552
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_WITH_EVENT_TYPES_ONLY_TEXT)
|
|
553
|
+
stdout_text, exit_code = _run_hook(
|
|
554
|
+
{
|
|
555
|
+
"tool_name": "Bash",
|
|
556
|
+
"cwd": str(repo_root),
|
|
557
|
+
"tool_input": {"command": 'gh pr create --title "add the thing"'},
|
|
558
|
+
}
|
|
559
|
+
)
|
|
560
|
+
assert exit_code == 0
|
|
561
|
+
response_payload = json.loads(stdout_text)
|
|
562
|
+
assert response_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def test_main_allows_echoed_gh_pr_create_in_semantic_ci_repo(tmp_path: pathlib.Path) -> None:
|
|
566
|
+
repo_root = tmp_path / "semantic_repo"
|
|
567
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TEXT)
|
|
568
|
+
stdout_text, exit_code = _run_hook(
|
|
569
|
+
{
|
|
570
|
+
"tool_name": "Bash",
|
|
571
|
+
"cwd": str(repo_root),
|
|
572
|
+
"tool_input": {"command": 'echo gh pr create --title "add the thing"'},
|
|
573
|
+
}
|
|
574
|
+
)
|
|
575
|
+
assert exit_code == 0
|
|
576
|
+
assert stdout_text == ""
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def test_main_allows_non_gh_pr_create_command(tmp_path: pathlib.Path) -> None:
|
|
580
|
+
repo_root = tmp_path / "semantic_repo"
|
|
581
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TEXT)
|
|
582
|
+
stdout_text, exit_code = _run_hook(
|
|
583
|
+
{
|
|
584
|
+
"tool_name": "Bash",
|
|
585
|
+
"cwd": str(repo_root),
|
|
586
|
+
"tool_input": {"command": "gh pr list --repo owner/repo"},
|
|
587
|
+
}
|
|
588
|
+
)
|
|
589
|
+
assert exit_code == 0
|
|
590
|
+
assert stdout_text == ""
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def test_main_allows_gh_pr_create_with_no_title(tmp_path: pathlib.Path) -> None:
|
|
594
|
+
repo_root = tmp_path / "semantic_repo"
|
|
595
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TEXT)
|
|
596
|
+
stdout_text, exit_code = _run_hook(
|
|
597
|
+
{
|
|
598
|
+
"tool_name": "Bash",
|
|
599
|
+
"cwd": str(repo_root),
|
|
600
|
+
"tool_input": {"command": "gh pr create --draft"},
|
|
601
|
+
}
|
|
602
|
+
)
|
|
603
|
+
assert exit_code == 0
|
|
604
|
+
assert stdout_text == ""
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def test_main_allows_when_repo_flag_present(tmp_path: pathlib.Path) -> None:
|
|
608
|
+
repo_root = tmp_path / "semantic_repo"
|
|
609
|
+
_init_repo_with_workflow(repo_root, _SEMANTIC_WORKFLOW_TEXT)
|
|
610
|
+
stdout_text, exit_code = _run_hook(
|
|
611
|
+
{
|
|
612
|
+
"tool_name": "Bash",
|
|
613
|
+
"cwd": str(repo_root),
|
|
614
|
+
"tool_input": {"command": 'gh pr create --repo owner/other --title "add the thing"'},
|
|
615
|
+
}
|
|
616
|
+
)
|
|
617
|
+
assert exit_code == 0
|
|
618
|
+
assert stdout_text == ""
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def test_main_allows_non_bash_tool() -> None:
|
|
622
|
+
stdout_text, exit_code = _run_hook(
|
|
623
|
+
{"tool_name": "Write", "tool_input": {"content": "add the thing"}}
|
|
624
|
+
)
|
|
625
|
+
assert exit_code == 0
|
|
626
|
+
assert stdout_text == ""
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def test_main_with_empty_stdin_exits_silently() -> None:
|
|
630
|
+
stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("")
|
|
631
|
+
assert exit_code == 0
|
|
632
|
+
assert stdout_text == ""
|
|
633
|
+
assert stderr_text == ""
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def test_main_with_invalid_json_stdin_exits_silently() -> None:
|
|
637
|
+
stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("{broken")
|
|
638
|
+
assert exit_code == 0
|
|
639
|
+
assert stdout_text == ""
|
|
640
|
+
assert stderr_text == ""
|
package/hooks/hooks.json
CHANGED
|
@@ -55,6 +55,11 @@
|
|
|
55
55
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_body_arg_blocker.py",
|
|
56
56
|
"timeout": 10
|
|
57
57
|
},
|
|
58
|
+
{
|
|
59
|
+
"type": "command",
|
|
60
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/conventional_pr_title_gate.py",
|
|
61
|
+
"timeout": 10
|
|
62
|
+
},
|
|
58
63
|
{
|
|
59
64
|
"type": "command",
|
|
60
65
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/block_main_commit.py",
|
|
@@ -18,6 +18,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
18
18
|
| `code_verifier_spawn_preflight_gate_constants.py` | Subagent type, merge-tree command flags, timeouts, and deny-message text for the code-verifier spawn pre-flight gate |
|
|
19
19
|
| `command_dispatch_constants.py` | Command-word regex, command-key access pattern, tokenization pattern, and anchors for the unanchored command-dispatch meta-gate |
|
|
20
20
|
| `convergence_branch_constants.py` | Branch and worktree naming patterns for the convergence gate |
|
|
21
|
+
| `conventional_pr_title_gate_constants.py` | Bash tool name, gh executable basenames and pr create/edit subcommand tokens, title/repo flag names, semantic-title-CI workflow markers, the Conventional Commits type list and title pattern, the semantic-pull-request action `types:` input pattern, and block-message text for the conventional-PR-title gate |
|
|
21
22
|
| `dead_argparse_argument_constants.py` | Patterns for detecting unused argparse arguments |
|
|
22
23
|
| `dead_config_field_constants.py` | Patterns for detecting unused `*Config` / `*Selectors` dataclass fields |
|
|
23
24
|
| `dead_dataclass_field_constants.py` | Patterns for detecting unused dataclass fields |
|