claude-dev-env 1.39.0 → 1.41.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/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +10 -0
- package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +53 -3
- package/_shared/pr-loop/scripts/post_audit_thread.py +298 -3
- package/_shared/pr-loop/scripts/preflight.py +129 -2
- package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +68 -3
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +194 -1
- package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
- package/agents/pr-description-writer.md +150 -52
- package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
- package/hooks/_gh_pr_author_swap_utils.py +1211 -0
- package/hooks/blocking/gh_body_arg_blocker.py +9 -6
- package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
- package/hooks/blocking/gh_pr_author_restore.py +100 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
- package/hooks/blocking/pr_description_enforcer.py +56 -23
- package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
- package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
- package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
- package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
- package/hooks/blocking/test_pr_description_enforcer.py +69 -8
- package/hooks/config/gh_pr_author_swap_constants.py +76 -0
- package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
- package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
- package/hooks/config/pr_description_enforcer_constants.py +19 -0
- package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
- package/hooks/hooks.json +40 -0
- package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
- package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
- package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
- package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
- package/hooks/test__gh_pr_author_swap_utils.py +333 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
- package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
- package/skills/bugteam/SKILL.md +28 -10
- package/skills/bugteam/reference/audit-contract.md +22 -0
- package/skills/bugteam/reference/github-pr-reviews.md +1 -1
- package/skills/bugteam/reference/team-setup.md +5 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
- package/skills/bugteam/scripts/bugteam_preflight.py +36 -2
- package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
- package/skills/bugteam/scripts/test_bugteam_preflight.py +41 -0
- package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
- package/skills/copilot-review/SKILL.md +16 -0
- package/skills/findbugs/SKILL.md +35 -7
- package/skills/monitor-open-prs/SKILL.md +2 -1
- package/skills/pr-converge/SKILL.md +11 -3
- package/skills/pr-converge/config/constants.py +3 -1
- package/skills/pr-converge/reference/per-tick.md +17 -0
- package/skills/pr-converge/reference/state-schema.md +36 -8
- package/skills/pr-converge/scripts/check_bugbot_ci.py +113 -8
- package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
- package/skills/qbug/SKILL.md +33 -8
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Unit tests for the pr_converge_bugteam_enforcer PreToolUse hook.
|
|
2
|
+
|
|
3
|
+
Covers the Step 5 BUGTEAM contract: Agent({subagent_type: "clean-coder"})
|
|
4
|
+
calls that look like audit substitutes are blocked when
|
|
5
|
+
$CLAUDE_JOB_DIR/pr-converge-state.json (named by PR_CONVERGE_STATE_FILENAME)
|
|
6
|
+
shows phase=BUGTEAM and the formal Skill({skill: "bugteam"}) has not
|
|
7
|
+
registered at the current HEAD and tick. qbug is explicitly NOT a substitute.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import pathlib
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
from unittest import mock
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
23
|
+
_HOOKS_TREE = _HOOK_DIR.parent
|
|
24
|
+
for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
|
|
25
|
+
if each_path not in sys.path:
|
|
26
|
+
sys.path.insert(0, each_path)
|
|
27
|
+
|
|
28
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
29
|
+
"pr_converge_bugteam_enforcer",
|
|
30
|
+
_HOOK_DIR / "pr_converge_bugteam_enforcer.py",
|
|
31
|
+
)
|
|
32
|
+
assert hook_spec is not None
|
|
33
|
+
assert hook_spec.loader is not None
|
|
34
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
35
|
+
hook_spec.loader.exec_module(hook_module)
|
|
36
|
+
|
|
37
|
+
from config.pr_converge_bugteam_enforcer_constants import (
|
|
38
|
+
BUGTEAM_PHASE,
|
|
39
|
+
CLAUDE_JOB_DIR_ENV_VAR,
|
|
40
|
+
PR_CONVERGE_STATE_FILENAME,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_HEAD_SHA_CURRENT = "abc123def456abc123def456abc123def456abcd"
|
|
44
|
+
_HEAD_SHA_STALE = "deadbeef0000deadbeef0000deadbeef0000dead"
|
|
45
|
+
_TICK_CURRENT = 7
|
|
46
|
+
_AUDIT_PROMPT = "Run the bugteam audit and report findings against the A-J categories."
|
|
47
|
+
_FIX_ONLY_PROMPT = "Fix the failing test in tests/test_widget.py by adding a guard clause."
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _write_state(state_directory: pathlib.Path, state: dict[str, Any]) -> pathlib.Path:
|
|
51
|
+
state_path = state_directory / PR_CONVERGE_STATE_FILENAME
|
|
52
|
+
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
53
|
+
return state_path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _bugteam_phase_state(**overrides: Any) -> dict[str, Any]:
|
|
57
|
+
baseline_state: dict[str, Any] = {
|
|
58
|
+
"phase": BUGTEAM_PHASE,
|
|
59
|
+
"current_head": _HEAD_SHA_CURRENT,
|
|
60
|
+
"tick_count": _TICK_CURRENT,
|
|
61
|
+
"bugteam_skill_invoked_at_head": None,
|
|
62
|
+
"bugteam_skill_invoked_at_tick": None,
|
|
63
|
+
}
|
|
64
|
+
baseline_state.update(overrides)
|
|
65
|
+
return baseline_state
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _clean_coder_audit_payload(prompt: str = _AUDIT_PROMPT) -> dict[str, Any]:
|
|
69
|
+
return {
|
|
70
|
+
"tool_name": "Agent",
|
|
71
|
+
"tool_input": {
|
|
72
|
+
"subagent_type": "clean-coder",
|
|
73
|
+
"prompt": prompt,
|
|
74
|
+
"description": "Audit the PR",
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run_main_with_io(input_text: str) -> str:
|
|
80
|
+
with mock.patch("sys.stdin", io.StringIO(input_text)):
|
|
81
|
+
with mock.patch("sys.stdout", new_callable=io.StringIO) as captured_stdout:
|
|
82
|
+
try:
|
|
83
|
+
hook_module.main()
|
|
84
|
+
except SystemExit:
|
|
85
|
+
pass
|
|
86
|
+
return captured_stdout.getvalue()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.fixture()
|
|
90
|
+
def claude_job_directory(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
|
|
91
|
+
monkeypatch.setenv(CLAUDE_JOB_DIR_ENV_VAR, str(tmp_path))
|
|
92
|
+
return tmp_path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_should_allow_when_state_file_absent(claude_job_directory: pathlib.Path) -> None:
|
|
96
|
+
payload_text = json.dumps(_clean_coder_audit_payload())
|
|
97
|
+
captured_output = _run_main_with_io(payload_text)
|
|
98
|
+
assert captured_output == ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_should_allow_when_phase_not_bugteam(claude_job_directory: pathlib.Path) -> None:
|
|
102
|
+
_write_state(claude_job_directory, _bugteam_phase_state(phase="BUGBOT"))
|
|
103
|
+
payload_text = json.dumps(_clean_coder_audit_payload())
|
|
104
|
+
captured_output = _run_main_with_io(payload_text)
|
|
105
|
+
assert captured_output == ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_should_allow_when_subagent_type_not_clean_coder(
|
|
109
|
+
claude_job_directory: pathlib.Path,
|
|
110
|
+
) -> None:
|
|
111
|
+
_write_state(claude_job_directory, _bugteam_phase_state())
|
|
112
|
+
explore_payload: dict[str, Any] = {
|
|
113
|
+
"tool_name": "Agent",
|
|
114
|
+
"tool_input": {"subagent_type": "Explore", "prompt": _AUDIT_PROMPT},
|
|
115
|
+
}
|
|
116
|
+
captured_output = _run_main_with_io(json.dumps(explore_payload))
|
|
117
|
+
assert captured_output == ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_should_allow_when_prompt_lacks_audit_keywords(
|
|
121
|
+
claude_job_directory: pathlib.Path,
|
|
122
|
+
) -> None:
|
|
123
|
+
_write_state(claude_job_directory, _bugteam_phase_state())
|
|
124
|
+
payload_text = json.dumps(_clean_coder_audit_payload(prompt=_FIX_ONLY_PROMPT))
|
|
125
|
+
captured_output = _run_main_with_io(payload_text)
|
|
126
|
+
assert captured_output == ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_should_block_when_clean_coder_audit_without_bugteam_skill_invocation(
|
|
130
|
+
claude_job_directory: pathlib.Path,
|
|
131
|
+
) -> None:
|
|
132
|
+
_write_state(claude_job_directory, _bugteam_phase_state())
|
|
133
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
134
|
+
deny_payload = json.loads(captured_output)
|
|
135
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
136
|
+
assert "bugteam-enforcer" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_should_allow_after_bugteam_skill_invoked_at_current_head_and_tick(
|
|
140
|
+
claude_job_directory: pathlib.Path,
|
|
141
|
+
) -> None:
|
|
142
|
+
_write_state(
|
|
143
|
+
claude_job_directory,
|
|
144
|
+
_bugteam_phase_state(
|
|
145
|
+
bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
|
|
146
|
+
bugteam_skill_invoked_at_tick=_TICK_CURRENT,
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
150
|
+
assert captured_output == ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_should_block_when_bugteam_skill_invocation_is_stale_head(
|
|
154
|
+
claude_job_directory: pathlib.Path,
|
|
155
|
+
) -> None:
|
|
156
|
+
_write_state(
|
|
157
|
+
claude_job_directory,
|
|
158
|
+
_bugteam_phase_state(
|
|
159
|
+
bugteam_skill_invoked_at_head=_HEAD_SHA_STALE,
|
|
160
|
+
bugteam_skill_invoked_at_tick=_TICK_CURRENT,
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
164
|
+
deny_payload = json.loads(captured_output)
|
|
165
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_should_block_when_qbug_was_invoked_but_not_bugteam(
|
|
169
|
+
claude_job_directory: pathlib.Path,
|
|
170
|
+
) -> None:
|
|
171
|
+
_write_state(
|
|
172
|
+
claude_job_directory,
|
|
173
|
+
_bugteam_phase_state(
|
|
174
|
+
bugteam_skill_invoked_at_head=None,
|
|
175
|
+
bugteam_skill_invoked_at_tick=None,
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
179
|
+
deny_payload = json.loads(captured_output)
|
|
180
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_should_block_when_bugteam_invoked_at_current_head_but_previous_tick(
|
|
184
|
+
claude_job_directory: pathlib.Path,
|
|
185
|
+
) -> None:
|
|
186
|
+
_write_state(
|
|
187
|
+
claude_job_directory,
|
|
188
|
+
_bugteam_phase_state(
|
|
189
|
+
bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
|
|
190
|
+
bugteam_skill_invoked_at_tick=_TICK_CURRENT - 1,
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
194
|
+
deny_payload = json.loads(captured_output)
|
|
195
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_should_block_when_invoked_head_is_non_string_type(
|
|
199
|
+
claude_job_directory: pathlib.Path,
|
|
200
|
+
) -> None:
|
|
201
|
+
_write_state(
|
|
202
|
+
claude_job_directory,
|
|
203
|
+
_bugteam_phase_state(
|
|
204
|
+
bugteam_skill_invoked_at_head=42,
|
|
205
|
+
bugteam_skill_invoked_at_tick=_TICK_CURRENT,
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
209
|
+
deny_payload = json.loads(captured_output)
|
|
210
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_should_block_when_current_head_is_non_string_type(
|
|
214
|
+
claude_job_directory: pathlib.Path,
|
|
215
|
+
) -> None:
|
|
216
|
+
_write_state(
|
|
217
|
+
claude_job_directory,
|
|
218
|
+
_bugteam_phase_state(
|
|
219
|
+
current_head=42,
|
|
220
|
+
bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
|
|
221
|
+
bugteam_skill_invoked_at_tick=_TICK_CURRENT,
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
225
|
+
deny_payload = json.loads(captured_output)
|
|
226
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_should_block_when_invoked_tick_is_string_type(
|
|
230
|
+
claude_job_directory: pathlib.Path,
|
|
231
|
+
) -> None:
|
|
232
|
+
_write_state(
|
|
233
|
+
claude_job_directory,
|
|
234
|
+
_bugteam_phase_state(
|
|
235
|
+
bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
|
|
236
|
+
bugteam_skill_invoked_at_tick="7",
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
240
|
+
deny_payload = json.loads(captured_output)
|
|
241
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_should_block_when_current_tick_is_string_type(
|
|
245
|
+
claude_job_directory: pathlib.Path,
|
|
246
|
+
) -> None:
|
|
247
|
+
_write_state(
|
|
248
|
+
claude_job_directory,
|
|
249
|
+
_bugteam_phase_state(
|
|
250
|
+
tick_count="7",
|
|
251
|
+
bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
|
|
252
|
+
bugteam_skill_invoked_at_tick=_TICK_CURRENT,
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
256
|
+
deny_payload = json.loads(captured_output)
|
|
257
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_should_block_when_invoked_tick_is_bool_type(
|
|
261
|
+
claude_job_directory: pathlib.Path,
|
|
262
|
+
) -> None:
|
|
263
|
+
_write_state(
|
|
264
|
+
claude_job_directory,
|
|
265
|
+
_bugteam_phase_state(
|
|
266
|
+
tick_count=1,
|
|
267
|
+
bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
|
|
268
|
+
bugteam_skill_invoked_at_tick=True,
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
272
|
+
deny_payload = json.loads(captured_output)
|
|
273
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_should_block_when_current_tick_is_bool_type(
|
|
277
|
+
claude_job_directory: pathlib.Path,
|
|
278
|
+
) -> None:
|
|
279
|
+
_write_state(
|
|
280
|
+
claude_job_directory,
|
|
281
|
+
_bugteam_phase_state(
|
|
282
|
+
tick_count=True,
|
|
283
|
+
bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
|
|
284
|
+
bugteam_skill_invoked_at_tick=1,
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
288
|
+
deny_payload = json.loads(captured_output)
|
|
289
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_should_allow_when_claude_job_dir_env_var_absent(
|
|
293
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
294
|
+
) -> None:
|
|
295
|
+
monkeypatch.delenv(CLAUDE_JOB_DIR_ENV_VAR, raising=False)
|
|
296
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
297
|
+
assert captured_output == ""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_should_allow_when_state_json_is_malformed(
|
|
301
|
+
claude_job_directory: pathlib.Path,
|
|
302
|
+
) -> None:
|
|
303
|
+
state_path = claude_job_directory / PR_CONVERGE_STATE_FILENAME
|
|
304
|
+
state_path.write_text("{not valid json", encoding="utf-8")
|
|
305
|
+
captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
|
|
306
|
+
assert captured_output == ""
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_should_allow_when_payload_is_malformed_json() -> None:
|
|
310
|
+
captured_output = _run_main_with_io("not valid json {{{")
|
|
311
|
+
assert captured_output == ""
|
|
@@ -25,8 +25,21 @@ extract_body_from_command = hook_module.extract_body_from_command
|
|
|
25
25
|
validate_pr_body = hook_module.validate_pr_body
|
|
26
26
|
|
|
27
27
|
VALID_BODY = (
|
|
28
|
-
"
|
|
29
|
-
"
|
|
28
|
+
"Allow commas in branch names so PRs whose head branch was generated from "
|
|
29
|
+
"a title or external identifier no longer fail validation before any git "
|
|
30
|
+
"operation.\n\n"
|
|
31
|
+
"Fixes #1300.\n\n"
|
|
32
|
+
"## Changes\n\n"
|
|
33
|
+
"- `src/github/operations/branch.ts`: add `,` to the whitelist regex\n"
|
|
34
|
+
"- `test/branch.test.ts`: 3 new cases covering comma-bearing branch names\n\n"
|
|
35
|
+
"## Test plan\n\n"
|
|
36
|
+
"- `bun test test/branch.test.ts`\n"
|
|
37
|
+
"- `bun run typecheck`\n"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
LEGACY_DESCRIPTION_WHY_HOW_BODY = (
|
|
41
|
+
"## Description\n\nThis PR fixes a real bug in the authentication module.\n\n"
|
|
42
|
+
"## Why\n\nBecause it was broken in production and customers reported failures.\n\n"
|
|
30
43
|
"## How\n\nRefactored the auth module to handle edge cases correctly.\n"
|
|
31
44
|
)
|
|
32
45
|
|
|
@@ -109,15 +122,63 @@ def test_extract_short_flag_shell_var_returns_empty() -> None:
|
|
|
109
122
|
assert extract_body_from_command(command) == ""
|
|
110
123
|
|
|
111
124
|
|
|
112
|
-
def
|
|
125
|
+
def test_validate_passes_anthropic_standard_body() -> None:
|
|
113
126
|
assert validate_pr_body(VALID_BODY) == []
|
|
114
127
|
|
|
115
128
|
|
|
116
|
-
def
|
|
117
|
-
|
|
118
|
-
assert
|
|
119
|
-
|
|
129
|
+
def test_validate_passes_legacy_description_why_how_body() -> None:
|
|
130
|
+
"""Existing Description/Why/How bodies must still pass -- the relaxed rule only widens what's accepted."""
|
|
131
|
+
assert validate_pr_body(LEGACY_DESCRIPTION_WHY_HOW_BODY) == []
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_validate_passes_sectionless_prose_body() -> None:
|
|
135
|
+
"""Anthropic's trivial-PR shape is one sentence with no headers."""
|
|
136
|
+
body = (
|
|
137
|
+
"Pin third-party GitHub Actions references to immutable commit SHAs "
|
|
138
|
+
"so a tag move cannot redirect CI to attacker-controlled code."
|
|
139
|
+
)
|
|
140
|
+
assert validate_pr_body(body) == []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_validate_blocks_skeleton_body_with_only_headers_and_bullets() -> None:
|
|
144
|
+
"""Sections + bullets without any prose Why is rejected -- the substantive-prose check catches this."""
|
|
145
|
+
body = (
|
|
146
|
+
"## Summary\n\n"
|
|
147
|
+
"## Changes\n\n"
|
|
148
|
+
"- `a`\n"
|
|
149
|
+
"- `b`\n"
|
|
150
|
+
"- `c`\n"
|
|
151
|
+
)
|
|
152
|
+
violations = validate_pr_body(body)
|
|
153
|
+
assert any("substantive prose" in each_violation.lower() for each_violation in violations)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_validate_blocks_blockquoted_headings_with_no_real_prose() -> None:
|
|
157
|
+
"""Regression: blockquote markers must strip BEFORE heading stripping.
|
|
158
|
+
|
|
159
|
+
A line like `> ## Summary` starts with `>`, so `^#+[ \\t].*$` cannot match it
|
|
160
|
+
in heading position. If blockquote markers are stripped after, the bare
|
|
161
|
+
`## Summary` text survives into the prose stream and inflates the count.
|
|
162
|
+
Correct order strips `> ` first, then the line becomes a real heading and
|
|
163
|
+
drops out, leaving an effectively empty body below the 40-character minimum.
|
|
164
|
+
"""
|
|
165
|
+
body = "> ## Summary\n> ## Why\n> ## How"
|
|
166
|
+
violations = validate_pr_body(body)
|
|
167
|
+
assert any("substantive prose" in each_violation.lower() for each_violation in violations)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_validate_passes_prose_after_bare_hashes_with_no_space() -> None:
|
|
171
|
+
"""Bug regression: `##\\n` followed by prose must not have its prose eaten by the heading regex.
|
|
172
|
+
|
|
173
|
+
The previous pattern `^#+\\s.*$` matched `\\s` against the newline, then `.*$` greedily
|
|
174
|
+
consumed the next line. The fix restricts the whitespace class to `[ \\t]` so only true
|
|
175
|
+
headings (`## text`) match, leaving prose-after-bare-hashes intact for substantive-prose counting.
|
|
176
|
+
"""
|
|
177
|
+
body = (
|
|
178
|
+
"##\nThis is real prose that should not be eaten by the heading regex, "
|
|
179
|
+
"it should pass the 40-character minimum."
|
|
120
180
|
)
|
|
181
|
+
assert validate_pr_body(body) == []
|
|
121
182
|
|
|
122
183
|
|
|
123
184
|
def test_validate_blocks_vague_language() -> None:
|
|
@@ -128,7 +189,7 @@ def test_validate_blocks_vague_language() -> None:
|
|
|
128
189
|
|
|
129
190
|
def test_validate_blocks_short_body() -> None:
|
|
130
191
|
violations = validate_pr_body("Too short.")
|
|
131
|
-
assert any("
|
|
192
|
+
assert any("substantive prose" in each_violation.lower() for each_violation in violations)
|
|
132
193
|
|
|
133
194
|
|
|
134
195
|
def test_body_file_content_validated(tmp_path: pathlib.Path) -> None:
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Configuration constants for the gh-pr-author swap hook trio.
|
|
2
|
+
|
|
3
|
+
The PreToolUse enforcer (``gh_pr_author_enforcer.py``) auto-switches the
|
|
4
|
+
active ``gh`` CLI account to ``GITHUB_DEFAULT_ACCOUNT`` before a
|
|
5
|
+
``gh pr create`` invocation, the PostToolUse companion
|
|
6
|
+
(``gh_pr_author_restore.py``) restores the prior account afterwards, and
|
|
7
|
+
the SessionStart cleanup hook (``gh_pr_author_session_cleanup.py``)
|
|
8
|
+
sweeps any stale state files left behind when a prior session was
|
|
9
|
+
interrupted between the swap and the restore. The state file written
|
|
10
|
+
between the hooks is keyed per session so parallel Claude Code sessions
|
|
11
|
+
cannot stomp on each other's swap state.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
REQUIRED_ACCOUNT_ENV_VAR: str = "GITHUB_DEFAULT_ACCOUNT"
|
|
19
|
+
|
|
20
|
+
BASH_TOOL_NAME: str = "Bash"
|
|
21
|
+
|
|
22
|
+
GH_PR_CREATE_PATTERN: re.Pattern[str] = re.compile(
|
|
23
|
+
r"(?:^|[;&|\n`({]|\$\()[ \t]*"
|
|
24
|
+
r"(?:(?:if|then|else|elif|while|until|do|!)[ \t]+)*"
|
|
25
|
+
r"(?:[A-Za-z_][A-Za-z0-9_]*=\S+[ \t]+)*"
|
|
26
|
+
r"gh(?:[ \t]+(?:--[A-Za-z][\w-]*(?:=\S+)?|-[A-Za-z])(?:[ \t]+(?!-)\S+)?)*"
|
|
27
|
+
r"[ \t]+pr[ \t]+create\b",
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
WEB_FLAG_PATTERN: re.Pattern[str] = re.compile(r"(?<!\S)(?:--web|-w)(?!\S)")
|
|
31
|
+
COMMAND_SEPARATOR_PATTERN: re.Pattern[str] = re.compile(
|
|
32
|
+
r"(?:&&|\|\||;|(?<!\|)\|(?!\|)|(?<!&)&(?!&)|[\r\n])"
|
|
33
|
+
)
|
|
34
|
+
BASH_COMMENT_INTRODUCER_CHARACTER: str = "#"
|
|
35
|
+
COMMAND_SUBSTITUTION_OPENER_LENGTH: int = 2
|
|
36
|
+
|
|
37
|
+
ALL_GH_API_USER_COMMAND: tuple[str, ...] = ("gh", "api", "user", "--jq", ".login")
|
|
38
|
+
GH_API_USER_TIMEOUT_SECONDS: int = 5
|
|
39
|
+
|
|
40
|
+
ALL_GH_AUTH_SWITCH_COMMAND_HEAD: tuple[str, ...] = ("gh", "auth", "switch", "--user")
|
|
41
|
+
GH_AUTH_SWITCH_TIMEOUT_SECONDS: int = 10
|
|
42
|
+
|
|
43
|
+
STATE_FILE_PREFIX: str = "gh_pr_author_swap_"
|
|
44
|
+
STATE_FILE_SUFFIX: str = ".json"
|
|
45
|
+
STATE_FILE_DEFAULT_SESSION_ID: str = "default"
|
|
46
|
+
|
|
47
|
+
SESSION_ID_UNSAFE_CHARACTERS_PATTERN: re.Pattern[str] = re.compile(r"[^A-Za-z0-9_-]")
|
|
48
|
+
|
|
49
|
+
STATE_FILE_ORIGINAL_ACCOUNT_KEY: str = "original_account"
|
|
50
|
+
STATE_FILE_PRIMARY_ACCOUNT_KEY: str = "primary_account"
|
|
51
|
+
|
|
52
|
+
STATE_FILE_PERMISSION_MODE: int = 0o600
|
|
53
|
+
|
|
54
|
+
STATE_FILE_PAYLOAD_TEXT_ENCODING_NAME: str = "utf-8"
|
|
55
|
+
|
|
56
|
+
OS_O_NOFOLLOW_ATTRIBUTE_NAME: str = "O_NOFOLLOW"
|
|
57
|
+
|
|
58
|
+
STATE_FILE_STALE_AGE_SECONDS: int = 1800
|
|
59
|
+
|
|
60
|
+
ALL_SHELL_QUOTE_CHARACTERS: tuple[str, ...] = ("\"", "'")
|
|
61
|
+
SHELL_QUOTE_REPLACEMENT_CHARACTER: str = " "
|
|
62
|
+
SHELL_BACKSLASH_ESCAPE_PAIR_LENGTH: int = 2
|
|
63
|
+
SHELL_BACKTICK_CHARACTER: str = "`"
|
|
64
|
+
SHELL_DOLLAR_CHARACTER: str = "$"
|
|
65
|
+
SHELL_PAREN_OPEN_CHARACTER: str = "("
|
|
66
|
+
SHELL_PAREN_CLOSE_CHARACTER: str = ")"
|
|
67
|
+
SHELL_LESS_THAN_CHARACTER: str = "<"
|
|
68
|
+
SHELL_BACKSLASH_CHARACTER: str = "\\"
|
|
69
|
+
SHELL_NEWLINE_CHARACTER: str = "\n"
|
|
70
|
+
|
|
71
|
+
HEREDOC_OPENER_TAG_PATTERN: re.Pattern[str] = re.compile(
|
|
72
|
+
r"[ \t]*(?P<dash>-?)[ \t]*(?:'(?P<sq_tag>[A-Za-z_][A-Za-z0-9_]*)'"
|
|
73
|
+
r"|\"(?P<dq_tag>[A-Za-z_][A-Za-z0-9_]*)\""
|
|
74
|
+
r"|(?P<bare_tag>[A-Za-z_][A-Za-z0-9_]*))"
|
|
75
|
+
)
|
|
76
|
+
HEREDOC_OPENER_TOKEN_LENGTH: int = 2
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Configuration constants for the pr_converge_bugteam_enforcer hook pair.
|
|
2
|
+
|
|
3
|
+
The enforcer denies ``Agent({subagent_type: "clean-coder"})`` invocations that
|
|
4
|
+
substitute audit-shaped work for the formal ``Skill({skill: "bugteam"})`` call
|
|
5
|
+
during Step 5 BUGTEAM of the pr-converge loop. The tracker records every
|
|
6
|
+
formal ``Skill({skill: "bugteam"})`` invocation so the enforcer can confirm
|
|
7
|
+
the Skill fired this tick at the current HEAD before allowing follow-on
|
|
8
|
+
clean-coder spawns.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
AGENT_TOOL_NAME: str = "Agent"
|
|
14
|
+
SKILL_TOOL_NAME: str = "Skill"
|
|
15
|
+
|
|
16
|
+
CLEAN_CODER_SUBAGENT_TYPE: str = "clean-coder"
|
|
17
|
+
BUGTEAM_SKILL_NAME: str = "bugteam"
|
|
18
|
+
|
|
19
|
+
PR_CONVERGE_STATE_FILENAME: str = "pr-converge-state.json"
|
|
20
|
+
CLAUDE_JOB_DIR_ENV_VAR: str = "CLAUDE_JOB_DIR"
|
|
21
|
+
|
|
22
|
+
BUGTEAM_PHASE: str = "BUGTEAM"
|
|
23
|
+
|
|
24
|
+
STATE_FIELD_PHASE: str = "phase"
|
|
25
|
+
STATE_FIELD_CURRENT_HEAD: str = "current_head"
|
|
26
|
+
STATE_FIELD_TICK_COUNT: str = "tick_count"
|
|
27
|
+
STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_HEAD: str = "bugteam_skill_invoked_at_head"
|
|
28
|
+
STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_TICK: str = "bugteam_skill_invoked_at_tick"
|
|
29
|
+
|
|
30
|
+
ALL_AUDIT_PROMPT_SUBSTRINGS: tuple[str, ...] = (
|
|
31
|
+
"audit",
|
|
32
|
+
"findings",
|
|
33
|
+
"bugteam",
|
|
34
|
+
"a-j categor",
|
|
35
|
+
"code-quality",
|
|
36
|
+
"verify_clean",
|
|
37
|
+
"converge",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
ENFORCER_CORRECTIVE_MESSAGE: str = (
|
|
41
|
+
"BLOCKED [pr-converge-bugteam-enforcer]: Step 5 BUGTEAM advances ONLY after "
|
|
42
|
+
'`Skill({skill: "bugteam", args: "<PR URL>"})` fires this tick at the '
|
|
43
|
+
'current HEAD. Substituting an `Agent({subagent_type: "clean-coder"})` '
|
|
44
|
+
"audit call for the formal Skill invocation is a protocol violation — the "
|
|
45
|
+
"formal Skill writes the artifact `check_convergence.py`'s `bugteam_clean_at` "
|
|
46
|
+
"gate looks for, and a substituted audit silently bypasses that gate.\n\n"
|
|
47
|
+
"`qbug` is NOT an accepted substitute — `bugteam` is the only allowed skill "
|
|
48
|
+
"at this step.\n\n"
|
|
49
|
+
'Run `Skill({skill: "bugteam", args: "<PR URL>"})` first. Follow-on '
|
|
50
|
+
"clean-coder fix spawns are allowed once the formal Skill has registered at "
|
|
51
|
+
"the current HEAD and tick."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
STATE_FILE_ATOMIC_WRITE_SUFFIX: str = ".tmp"
|
|
55
|
+
STATE_FILE_JSON_INDENT_SPACES: int = 2
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Shared state-loading helpers for the pr_converge_bugteam_enforcer hook pair.
|
|
2
|
+
|
|
3
|
+
Both the enforcer (``pr_converge_bugteam_enforcer.py``) and the tracker
|
|
4
|
+
(``pr_converge_bugteam_skill_tracker.py``) read the per-job
|
|
5
|
+
``$CLAUDE_JOB_DIR/pr-converge-state.json`` file from the same per-job
|
|
6
|
+
directory. This module hosts the byte-identical ``load_state_dictionary``
|
|
7
|
+
and ``resolve_state_path`` helpers so each hook imports a single canonical
|
|
8
|
+
definition.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from config.pr_converge_bugteam_enforcer_constants import (
|
|
18
|
+
CLAUDE_JOB_DIR_ENV_VAR,
|
|
19
|
+
PR_CONVERGE_STATE_FILENAME,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_state_dictionary(state_path: Path) -> dict[str, object] | None:
|
|
24
|
+
"""Return the parsed pr-converge state, or None when absent or unparseable.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
state_path: Absolute path to ``pr-converge-state.json``.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The decoded state dictionary, or None when the file is missing,
|
|
31
|
+
malformed, empty, or not a JSON object at the root.
|
|
32
|
+
"""
|
|
33
|
+
if not state_path.is_file():
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
raw_text = state_path.read_text(encoding="utf-8")
|
|
37
|
+
except OSError:
|
|
38
|
+
return None
|
|
39
|
+
if not raw_text.strip():
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
parsed_state = json.loads(raw_text)
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
return None
|
|
45
|
+
if not isinstance(parsed_state, dict):
|
|
46
|
+
return None
|
|
47
|
+
return parsed_state
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_state_path() -> Path | None:
|
|
51
|
+
"""Return the absolute path to the per-job ``pr-converge-state.json``.
|
|
52
|
+
|
|
53
|
+
Reads ``$CLAUDE_JOB_DIR`` from the environment and joins
|
|
54
|
+
``PR_CONVERGE_STATE_FILENAME`` onto it. The path is returned even when
|
|
55
|
+
the file does not yet exist on disk; callers are expected to call
|
|
56
|
+
``load_state_dictionary`` (or ``state_path.is_file()``) to check for
|
|
57
|
+
presence.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Absolute path to ``$CLAUDE_JOB_DIR/pr-converge-state.json``, or
|
|
61
|
+
``None`` when the ``CLAUDE_JOB_DIR`` environment variable is unset
|
|
62
|
+
or empty (no single-PR pr-converge job is currently active).
|
|
63
|
+
"""
|
|
64
|
+
job_directory = os.environ.get(CLAUDE_JOB_DIR_ENV_VAR, "")
|
|
65
|
+
if not job_directory:
|
|
66
|
+
return None
|
|
67
|
+
return Path(job_directory) / PR_CONVERGE_STATE_FILENAME
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Configuration constants for the pr_description_enforcer PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_PLUGIN_ROOT: str = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
8
|
+
PR_GUIDE_PATH: str = os.path.join(_PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md")
|
|
9
|
+
|
|
10
|
+
MINIMUM_SUBSTANTIVE_PROSE_CHARS: int = 40
|
|
11
|
+
|
|
12
|
+
FENCED_CODE_BLOCK_PATTERN: re.Pattern[str] = re.compile(r"```.*?```", re.DOTALL)
|
|
13
|
+
INLINE_CODE_PATTERN: re.Pattern[str] = re.compile(r"`[^`]*`")
|
|
14
|
+
HEADING_LINE_PATTERN: re.Pattern[str] = re.compile(r"^#+[ \t].*$", re.MULTILINE)
|
|
15
|
+
BOLD_PAIR_PATTERN: re.Pattern[str] = re.compile(r"\*\*([^*]+?)\*\*")
|
|
16
|
+
BULLET_MARKER_PATTERN: re.Pattern[str] = re.compile(r"^\s*[-*+]\s+", re.MULTILINE)
|
|
17
|
+
BLOCKQUOTE_MARKER_PATTERN: re.Pattern[str] = re.compile(r"^\s*>\s+", re.MULTILINE)
|
|
18
|
+
LINK_TEXT_PATTERN: re.Pattern[str] = re.compile(r"\[([^\]]+)\]\([^)]+\)")
|
|
19
|
+
WHITESPACE_RUN_PATTERN: re.Pattern[str] = re.compile(r"\s+")
|