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,82 @@
|
|
|
1
|
+
"""Behavior tests for pr_description_enforcer_constants module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
10
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
12
|
+
|
|
13
|
+
from config import pr_description_enforcer_constants as constants_module
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_plugin_root_is_private_module_attribute() -> None:
|
|
17
|
+
assert hasattr(constants_module, "_PLUGIN_ROOT")
|
|
18
|
+
assert isinstance(constants_module._PLUGIN_ROOT, str)
|
|
19
|
+
assert os.path.isabs(constants_module._PLUGIN_ROOT)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_plugin_root_public_name_is_not_exported() -> None:
|
|
23
|
+
assert not hasattr(constants_module, "PLUGIN_ROOT")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_pr_guide_path_resolves_under_plugin_root_docs() -> None:
|
|
27
|
+
expected_pr_guide_path = os.path.join(
|
|
28
|
+
constants_module._PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md"
|
|
29
|
+
)
|
|
30
|
+
assert constants_module.PR_GUIDE_PATH == expected_pr_guide_path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_minimum_substantive_prose_chars_is_positive_integer() -> None:
|
|
34
|
+
assert isinstance(constants_module.MINIMUM_SUBSTANTIVE_PROSE_CHARS, int)
|
|
35
|
+
assert constants_module.MINIMUM_SUBSTANTIVE_PROSE_CHARS > 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_fenced_code_block_pattern_matches_triple_backtick_block() -> None:
|
|
39
|
+
sample_markdown = "before ```python\ncode\n``` after"
|
|
40
|
+
match = constants_module.FENCED_CODE_BLOCK_PATTERN.search(sample_markdown)
|
|
41
|
+
assert match is not None
|
|
42
|
+
assert match.group(0).startswith("```")
|
|
43
|
+
assert match.group(0).endswith("```")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_inline_code_pattern_matches_single_backtick_span() -> None:
|
|
47
|
+
match = constants_module.INLINE_CODE_PATTERN.search("see `value` here")
|
|
48
|
+
assert match is not None
|
|
49
|
+
assert match.group(0) == "`value`"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_heading_line_pattern_matches_atx_heading() -> None:
|
|
53
|
+
match = constants_module.HEADING_LINE_PATTERN.search("## Description\n")
|
|
54
|
+
assert match is not None
|
|
55
|
+
assert match.group(0).strip() == "## Description"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_bold_pair_pattern_captures_inner_text() -> None:
|
|
59
|
+
match = constants_module.BOLD_PAIR_PATTERN.search("this is **bold** text")
|
|
60
|
+
assert match is not None
|
|
61
|
+
assert match.group(1) == "bold"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_bullet_marker_pattern_strips_dash_bullet_from_line() -> None:
|
|
65
|
+
stripped_line = constants_module.BULLET_MARKER_PATTERN.sub("", "- first item")
|
|
66
|
+
assert stripped_line == "first item"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_blockquote_marker_pattern_strips_quote_marker_from_line() -> None:
|
|
70
|
+
stripped_line = constants_module.BLOCKQUOTE_MARKER_PATTERN.sub("", "> quoted line")
|
|
71
|
+
assert stripped_line == "quoted line"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_link_text_pattern_captures_anchor_text() -> None:
|
|
75
|
+
match = constants_module.LINK_TEXT_PATTERN.search("See [the docs](https://example.com) now")
|
|
76
|
+
assert match is not None
|
|
77
|
+
assert match.group(1) == "the docs"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_whitespace_run_pattern_collapses_multiple_spaces() -> None:
|
|
81
|
+
collapsed_text = constants_module.WHITESPACE_RUN_PATTERN.sub(" ", "a b\t\tc\n\nd")
|
|
82
|
+
assert collapsed_text == "a b c d"
|
package/hooks/hooks.json
CHANGED
|
@@ -124,6 +124,11 @@
|
|
|
124
124
|
"type": "command",
|
|
125
125
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
|
|
126
126
|
"timeout": 10
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"type": "command",
|
|
130
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_pr_author_enforcer.py",
|
|
131
|
+
"timeout": 30
|
|
127
132
|
}
|
|
128
133
|
]
|
|
129
134
|
},
|
|
@@ -136,6 +141,26 @@
|
|
|
136
141
|
"timeout": 10
|
|
137
142
|
}
|
|
138
143
|
]
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"matcher": "Agent",
|
|
147
|
+
"hooks": [
|
|
148
|
+
{
|
|
149
|
+
"type": "command",
|
|
150
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pr_converge_bugteam_enforcer.py",
|
|
151
|
+
"timeout": 10
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"matcher": "Skill",
|
|
157
|
+
"hooks": [
|
|
158
|
+
{
|
|
159
|
+
"type": "command",
|
|
160
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py",
|
|
161
|
+
"timeout": 10
|
|
162
|
+
}
|
|
163
|
+
]
|
|
139
164
|
}
|
|
140
165
|
],
|
|
141
166
|
"SessionStart": [
|
|
@@ -156,6 +181,11 @@
|
|
|
156
181
|
"type": "command",
|
|
157
182
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/untracked_repo_detector.py",
|
|
158
183
|
"timeout": 10
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"type": "command",
|
|
187
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/gh_pr_author_session_cleanup.py",
|
|
188
|
+
"timeout": 30
|
|
159
189
|
}
|
|
160
190
|
]
|
|
161
191
|
}
|
|
@@ -241,6 +271,16 @@
|
|
|
241
271
|
"timeout": 10
|
|
242
272
|
}
|
|
243
273
|
]
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"matcher": "Bash",
|
|
277
|
+
"hooks": [
|
|
278
|
+
{
|
|
279
|
+
"type": "command",
|
|
280
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_pr_author_restore.py",
|
|
281
|
+
"timeout": 20
|
|
282
|
+
}
|
|
283
|
+
]
|
|
244
284
|
}
|
|
245
285
|
],
|
|
246
286
|
"InstructionsLoaded": [
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: record formal bugteam Skill invocations into pr-converge state.
|
|
3
|
+
|
|
4
|
+
Companion to ``pr_converge_bugteam_enforcer``. On every
|
|
5
|
+
``Skill({skill: "bugteam"})`` invocation, this hook stamps
|
|
6
|
+
``$CLAUDE_JOB_DIR/pr-converge-state.json`` with
|
|
7
|
+
``bugteam_skill_invoked_at_head = current_head`` and
|
|
8
|
+
``bugteam_skill_invoked_at_tick = tick_count`` so the enforcer can confirm
|
|
9
|
+
the formal Skill fired this tick at the current HEAD before allowing any
|
|
10
|
+
follow-on clean-coder audit-shaped Agent spawn.
|
|
11
|
+
|
|
12
|
+
``qbug`` invocations are deliberately ignored — qbug is not an accepted
|
|
13
|
+
substitute for the formal bugteam Skill at Step 5.
|
|
14
|
+
|
|
15
|
+
The hook never blocks: it returns exit 0 in every branch so the Skill call
|
|
16
|
+
proceeds unchanged.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TextIO
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _insert_hooks_tree_for_imports() -> None:
|
|
30
|
+
hooks_tree = Path(__file__).resolve().parent.parent
|
|
31
|
+
hooks_tree_string = str(hooks_tree)
|
|
32
|
+
if hooks_tree_string not in sys.path:
|
|
33
|
+
sys.path.insert(0, hooks_tree_string)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_insert_hooks_tree_for_imports()
|
|
37
|
+
|
|
38
|
+
from config.pr_converge_bugteam_enforcer_constants import (
|
|
39
|
+
BUGTEAM_SKILL_NAME,
|
|
40
|
+
SKILL_TOOL_NAME,
|
|
41
|
+
STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_HEAD,
|
|
42
|
+
STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_TICK,
|
|
43
|
+
STATE_FIELD_CURRENT_HEAD,
|
|
44
|
+
STATE_FIELD_TICK_COUNT,
|
|
45
|
+
STATE_FILE_ATOMIC_WRITE_SUFFIX,
|
|
46
|
+
STATE_FILE_JSON_INDENT_SPACES,
|
|
47
|
+
)
|
|
48
|
+
from config.pr_converge_bugteam_enforcer_state import (
|
|
49
|
+
load_state_dictionary,
|
|
50
|
+
resolve_state_path,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _atomic_write_state(state_path: Path, state_by_field: dict[str, object]) -> None:
|
|
55
|
+
"""Serialize state to disk atomically via tempfile + rename.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
state_path: Destination ``pr-converge-state.json`` path.
|
|
59
|
+
state_by_field: Updated state mapping each field name to its value.
|
|
60
|
+
"""
|
|
61
|
+
parent_directory = state_path.parent
|
|
62
|
+
parent_directory.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
encoded_text = json.dumps(state_by_field, indent=STATE_FILE_JSON_INDENT_SPACES, sort_keys=True)
|
|
64
|
+
with tempfile.NamedTemporaryFile(
|
|
65
|
+
mode="w",
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
dir=str(parent_directory),
|
|
68
|
+
delete=False,
|
|
69
|
+
suffix=STATE_FILE_ATOMIC_WRITE_SUFFIX,
|
|
70
|
+
) as temporary_handle:
|
|
71
|
+
temporary_handle.write(encoded_text)
|
|
72
|
+
temporary_path = Path(temporary_handle.name)
|
|
73
|
+
try:
|
|
74
|
+
os.replace(str(temporary_path), str(state_path))
|
|
75
|
+
except OSError:
|
|
76
|
+
Path(temporary_path).unlink(missing_ok=True)
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _emit_missing_state_warning(output_stream: TextIO) -> None:
|
|
81
|
+
"""Write the missing-state warning to the provided stream.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
output_stream: Writable text stream — production code passes
|
|
85
|
+
``sys.stderr``; tests pass a ``StringIO`` to capture the message.
|
|
86
|
+
"""
|
|
87
|
+
output_stream.write(
|
|
88
|
+
"pr_converge_bugteam_skill_tracker: state file lacks current_head or "
|
|
89
|
+
"tick_count; bugteam invocation NOT recorded\n"
|
|
90
|
+
)
|
|
91
|
+
output_stream.flush()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _record_bugteam_skill_invocation(
|
|
95
|
+
state_by_field: dict[str, object],
|
|
96
|
+
) -> dict[str, object] | None:
|
|
97
|
+
"""Return a copy of state with bugteam-Skill invocation fields stamped, or None on no-op.
|
|
98
|
+
|
|
99
|
+
The two stamp fields are owned exclusively by this tracker. Concurrent
|
|
100
|
+
writes from the orchestrator never touch them, so the read-modify-write
|
|
101
|
+
window cannot lose an orchestrator update on these specific keys.
|
|
102
|
+
|
|
103
|
+
When ``current_head`` (str) or ``tick_count`` (int) is missing or wrong-typed,
|
|
104
|
+
the function emits a stderr warning via ``_emit_missing_state_warning`` and
|
|
105
|
+
returns ``None`` so the caller skips the disk write entirely. Skipping the
|
|
106
|
+
no-op write narrows the read-modify-write window against the orchestrator —
|
|
107
|
+
concurrent updates to non-stamp fields (``phase``, ``tick_count``, etc.)
|
|
108
|
+
cannot be silently lost by a tracker rewrite that changes nothing.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
state_by_field: Existing pr-converge state mapping each field name to
|
|
112
|
+
its value.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
New dictionary identical to ``state_by_field`` plus
|
|
116
|
+
``bugteam_skill_invoked_at_head`` set to ``current_head`` and
|
|
117
|
+
``bugteam_skill_invoked_at_tick`` set to ``tick_count`` when both
|
|
118
|
+
source fields are present and well-typed; ``None`` when either source
|
|
119
|
+
field is missing or wrong-typed, signaling the caller to skip the
|
|
120
|
+
atomic write.
|
|
121
|
+
"""
|
|
122
|
+
current_head = state_by_field.get(STATE_FIELD_CURRENT_HEAD)
|
|
123
|
+
current_tick = state_by_field.get(STATE_FIELD_TICK_COUNT)
|
|
124
|
+
if not isinstance(current_head, str) or not isinstance(current_tick, int):
|
|
125
|
+
_emit_missing_state_warning(sys.stderr)
|
|
126
|
+
return None
|
|
127
|
+
updated_state: dict[str, object] = dict(state_by_field)
|
|
128
|
+
updated_state[STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_HEAD] = current_head
|
|
129
|
+
updated_state[STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_TICK] = current_tick
|
|
130
|
+
return updated_state
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_formal_bugteam_skill_invocation(payload_by_field: dict[str, object]) -> bool:
|
|
134
|
+
"""Return True when this hook invocation matches the formal bugteam Skill.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
payload_by_field: The full PreToolUse hook payload (already JSON-parsed),
|
|
138
|
+
keyed by top-level field name.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
True when ``tool_name == "Skill"`` and ``tool_input["skill"]
|
|
142
|
+
== "bugteam"``. Returns False for qbug and every other skill.
|
|
143
|
+
"""
|
|
144
|
+
if payload_by_field.get("tool_name", "") != SKILL_TOOL_NAME:
|
|
145
|
+
return False
|
|
146
|
+
tool_input = payload_by_field.get("tool_input", {})
|
|
147
|
+
if not isinstance(tool_input, dict):
|
|
148
|
+
return False
|
|
149
|
+
return tool_input.get("skill", "") == BUGTEAM_SKILL_NAME
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _emit_state_write_error(state_write_error: OSError, output_stream: TextIO) -> None:
|
|
153
|
+
"""Write the state-write failure message to the provided stream.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
state_write_error: The OSError raised by ``_atomic_write_state``.
|
|
157
|
+
output_stream: Writable text stream — production code passes
|
|
158
|
+
``sys.stderr``; tests pass a ``StringIO`` to capture the message.
|
|
159
|
+
"""
|
|
160
|
+
output_stream.write(
|
|
161
|
+
f"pr_converge_bugteam_skill_tracker: state write failed; "
|
|
162
|
+
f"stamp not recorded: {state_write_error}\n"
|
|
163
|
+
)
|
|
164
|
+
output_stream.flush()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main() -> None:
|
|
168
|
+
"""Tracker entry point for the PreToolUse:Skill hook.
|
|
169
|
+
|
|
170
|
+
Reads the PreToolUse payload from stdin, records a formal
|
|
171
|
+
``Skill({skill: "bugteam"})`` invocation in the pr-converge state file,
|
|
172
|
+
and always returns 0 — including on a state-write failure, since a
|
|
173
|
+
non-zero PreToolUse exit would block the very Skill invocation this
|
|
174
|
+
hook exists to record. State-write failures are surfaced via
|
|
175
|
+
``_emit_state_write_error`` to stderr so the operator still sees the
|
|
176
|
+
protocol-corruption signal.
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
hook_payload = json.load(sys.stdin)
|
|
180
|
+
except json.JSONDecodeError:
|
|
181
|
+
return
|
|
182
|
+
if not isinstance(hook_payload, dict):
|
|
183
|
+
return
|
|
184
|
+
if not _is_formal_bugteam_skill_invocation(hook_payload):
|
|
185
|
+
return
|
|
186
|
+
state_path = resolve_state_path()
|
|
187
|
+
if state_path is None:
|
|
188
|
+
return
|
|
189
|
+
parsed_state = load_state_dictionary(state_path)
|
|
190
|
+
if parsed_state is None:
|
|
191
|
+
return
|
|
192
|
+
updated_state = _record_bugteam_skill_invocation(parsed_state)
|
|
193
|
+
if updated_state is None:
|
|
194
|
+
return
|
|
195
|
+
try:
|
|
196
|
+
_atomic_write_state(state_path, updated_state)
|
|
197
|
+
except OSError as state_write_error:
|
|
198
|
+
_emit_state_write_error(state_write_error, sys.stderr)
|
|
199
|
+
return
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
main()
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Unit tests for the pr_converge_bugteam_skill_tracker PreToolUse hook.
|
|
2
|
+
|
|
3
|
+
Covers the bugteam-only update path: only Skill({skill: "bugteam"}) updates
|
|
4
|
+
the invocation fields in $CLAUDE_JOB_DIR/pr-converge-state.json (the file
|
|
5
|
+
named by PR_CONVERGE_STATE_FILENAME). qbug, other skills, and missing-state
|
|
6
|
+
cases all return exit 0 without modifying state.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import pathlib
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
from unittest import mock
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
22
|
+
_HOOKS_TREE = _HOOK_DIR.parent
|
|
23
|
+
for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
|
|
24
|
+
if each_path not in sys.path:
|
|
25
|
+
sys.path.insert(0, each_path)
|
|
26
|
+
|
|
27
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
28
|
+
"pr_converge_bugteam_skill_tracker",
|
|
29
|
+
_HOOK_DIR / "pr_converge_bugteam_skill_tracker.py",
|
|
30
|
+
)
|
|
31
|
+
assert hook_spec is not None
|
|
32
|
+
assert hook_spec.loader is not None
|
|
33
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
34
|
+
hook_spec.loader.exec_module(hook_module)
|
|
35
|
+
|
|
36
|
+
from config.pr_converge_bugteam_enforcer_constants import (
|
|
37
|
+
CLAUDE_JOB_DIR_ENV_VAR,
|
|
38
|
+
PR_CONVERGE_STATE_FILENAME,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_HEAD_SHA = "abc123def456abc123def456abc123def456abcd"
|
|
42
|
+
_TICK_COUNT = 4
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _write_state(state_directory: pathlib.Path, state: dict[str, Any]) -> pathlib.Path:
|
|
46
|
+
state_path = state_directory / PR_CONVERGE_STATE_FILENAME
|
|
47
|
+
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
48
|
+
return state_path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _baseline_state() -> dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"phase": "BUGTEAM",
|
|
54
|
+
"current_head": _HEAD_SHA,
|
|
55
|
+
"tick_count": _TICK_COUNT,
|
|
56
|
+
"bugteam_skill_invoked_at_head": None,
|
|
57
|
+
"bugteam_skill_invoked_at_tick": None,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_state(state_directory: pathlib.Path) -> dict[str, Any]:
|
|
62
|
+
state_path = state_directory / PR_CONVERGE_STATE_FILENAME
|
|
63
|
+
return json.loads(state_path.read_text(encoding="utf-8"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _run_main_with_io(input_text: str) -> None:
|
|
67
|
+
with mock.patch("sys.stdin", io.StringIO(input_text)):
|
|
68
|
+
with mock.patch("sys.stdout", new_callable=io.StringIO):
|
|
69
|
+
try:
|
|
70
|
+
hook_module.main()
|
|
71
|
+
except SystemExit:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run_main_capturing_stderr(input_text: str) -> str:
|
|
76
|
+
with mock.patch("sys.stdin", io.StringIO(input_text)):
|
|
77
|
+
with mock.patch("sys.stdout", new_callable=io.StringIO):
|
|
78
|
+
with mock.patch("sys.stderr", new_callable=io.StringIO) as captured_stderr:
|
|
79
|
+
try:
|
|
80
|
+
hook_module.main()
|
|
81
|
+
except SystemExit:
|
|
82
|
+
pass
|
|
83
|
+
return captured_stderr.getvalue()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.fixture()
|
|
87
|
+
def claude_job_directory(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
|
|
88
|
+
monkeypatch.setenv(CLAUDE_JOB_DIR_ENV_VAR, str(tmp_path))
|
|
89
|
+
return tmp_path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_should_record_invocation_when_bugteam_skill_fires(
|
|
93
|
+
claude_job_directory: pathlib.Path,
|
|
94
|
+
) -> None:
|
|
95
|
+
_write_state(claude_job_directory, _baseline_state())
|
|
96
|
+
skill_payload: dict[str, Any] = {
|
|
97
|
+
"tool_name": "Skill",
|
|
98
|
+
"tool_input": {"skill": "bugteam", "args": "https://github.com/o/r/pull/1"},
|
|
99
|
+
}
|
|
100
|
+
_run_main_with_io(json.dumps(skill_payload))
|
|
101
|
+
updated_state = _read_state(claude_job_directory)
|
|
102
|
+
assert updated_state["bugteam_skill_invoked_at_head"] == _HEAD_SHA
|
|
103
|
+
assert updated_state["bugteam_skill_invoked_at_tick"] == _TICK_COUNT
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_should_not_record_invocation_when_qbug_skill_fires(
|
|
107
|
+
claude_job_directory: pathlib.Path,
|
|
108
|
+
) -> None:
|
|
109
|
+
_write_state(claude_job_directory, _baseline_state())
|
|
110
|
+
qbug_payload: dict[str, Any] = {
|
|
111
|
+
"tool_name": "Skill",
|
|
112
|
+
"tool_input": {"skill": "qbug"},
|
|
113
|
+
}
|
|
114
|
+
_run_main_with_io(json.dumps(qbug_payload))
|
|
115
|
+
updated_state = _read_state(claude_job_directory)
|
|
116
|
+
assert updated_state["bugteam_skill_invoked_at_head"] is None
|
|
117
|
+
assert updated_state["bugteam_skill_invoked_at_tick"] is None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_should_ignore_unrelated_skills(
|
|
121
|
+
claude_job_directory: pathlib.Path,
|
|
122
|
+
) -> None:
|
|
123
|
+
_write_state(claude_job_directory, _baseline_state())
|
|
124
|
+
other_payload: dict[str, Any] = {
|
|
125
|
+
"tool_name": "Skill",
|
|
126
|
+
"tool_input": {"skill": "agent-prompt"},
|
|
127
|
+
}
|
|
128
|
+
_run_main_with_io(json.dumps(other_payload))
|
|
129
|
+
updated_state = _read_state(claude_job_directory)
|
|
130
|
+
assert updated_state["bugteam_skill_invoked_at_head"] is None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_should_ignore_non_skill_tools(
|
|
134
|
+
claude_job_directory: pathlib.Path,
|
|
135
|
+
) -> None:
|
|
136
|
+
_write_state(claude_job_directory, _baseline_state())
|
|
137
|
+
agent_payload: dict[str, Any] = {
|
|
138
|
+
"tool_name": "Agent",
|
|
139
|
+
"tool_input": {"subagent_type": "clean-coder", "prompt": "fix this"},
|
|
140
|
+
}
|
|
141
|
+
_run_main_with_io(json.dumps(agent_payload))
|
|
142
|
+
updated_state = _read_state(claude_job_directory)
|
|
143
|
+
assert updated_state["bugteam_skill_invoked_at_head"] is None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_should_no_op_when_state_file_absent(
|
|
147
|
+
claude_job_directory: pathlib.Path,
|
|
148
|
+
) -> None:
|
|
149
|
+
skill_payload: dict[str, Any] = {
|
|
150
|
+
"tool_name": "Skill",
|
|
151
|
+
"tool_input": {"skill": "bugteam"},
|
|
152
|
+
}
|
|
153
|
+
_run_main_with_io(json.dumps(skill_payload))
|
|
154
|
+
assert not (claude_job_directory / PR_CONVERGE_STATE_FILENAME).exists()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_should_no_op_when_state_json_is_malformed(
|
|
158
|
+
claude_job_directory: pathlib.Path,
|
|
159
|
+
) -> None:
|
|
160
|
+
state_path = claude_job_directory / PR_CONVERGE_STATE_FILENAME
|
|
161
|
+
state_path.write_text("{not valid json", encoding="utf-8")
|
|
162
|
+
skill_payload: dict[str, Any] = {
|
|
163
|
+
"tool_name": "Skill",
|
|
164
|
+
"tool_input": {"skill": "bugteam"},
|
|
165
|
+
}
|
|
166
|
+
_run_main_with_io(json.dumps(skill_payload))
|
|
167
|
+
assert state_path.read_text(encoding="utf-8") == "{not valid json"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_should_preserve_other_state_fields_on_update(
|
|
171
|
+
claude_job_directory: pathlib.Path,
|
|
172
|
+
) -> None:
|
|
173
|
+
baseline_state = _baseline_state()
|
|
174
|
+
baseline_state["bugbot_clean_at"] = _HEAD_SHA
|
|
175
|
+
baseline_state["copilot_wait_count"] = 2
|
|
176
|
+
_write_state(claude_job_directory, baseline_state)
|
|
177
|
+
skill_payload: dict[str, Any] = {
|
|
178
|
+
"tool_name": "Skill",
|
|
179
|
+
"tool_input": {"skill": "bugteam"},
|
|
180
|
+
}
|
|
181
|
+
_run_main_with_io(json.dumps(skill_payload))
|
|
182
|
+
updated_state = _read_state(claude_job_directory)
|
|
183
|
+
assert updated_state["bugbot_clean_at"] == _HEAD_SHA
|
|
184
|
+
assert updated_state["copilot_wait_count"] == 2
|
|
185
|
+
assert updated_state["bugteam_skill_invoked_at_head"] == _HEAD_SHA
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_should_no_op_when_claude_job_dir_env_var_absent(
|
|
189
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
190
|
+
) -> None:
|
|
191
|
+
monkeypatch.delenv(CLAUDE_JOB_DIR_ENV_VAR, raising=False)
|
|
192
|
+
skill_payload: dict[str, Any] = {
|
|
193
|
+
"tool_name": "Skill",
|
|
194
|
+
"tool_input": {"skill": "bugteam"},
|
|
195
|
+
}
|
|
196
|
+
_run_main_with_io(json.dumps(skill_payload))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_should_no_op_when_payload_is_malformed_json(
|
|
200
|
+
claude_job_directory: pathlib.Path,
|
|
201
|
+
) -> None:
|
|
202
|
+
baseline_state = _baseline_state()
|
|
203
|
+
_write_state(claude_job_directory, baseline_state)
|
|
204
|
+
_run_main_with_io("not valid json {{{")
|
|
205
|
+
updated_state = _read_state(claude_job_directory)
|
|
206
|
+
assert updated_state["bugteam_skill_invoked_at_head"] is None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_should_leave_state_unchanged_when_current_head_is_missing(
|
|
210
|
+
claude_job_directory: pathlib.Path,
|
|
211
|
+
) -> None:
|
|
212
|
+
state_without_head = _baseline_state()
|
|
213
|
+
del state_without_head["current_head"]
|
|
214
|
+
_write_state(claude_job_directory, state_without_head)
|
|
215
|
+
skill_payload: dict[str, Any] = {
|
|
216
|
+
"tool_name": "Skill",
|
|
217
|
+
"tool_input": {"skill": "bugteam"},
|
|
218
|
+
}
|
|
219
|
+
captured_stderr = _run_main_capturing_stderr(json.dumps(skill_payload))
|
|
220
|
+
updated_state = _read_state(claude_job_directory)
|
|
221
|
+
assert "current_head" not in updated_state
|
|
222
|
+
assert updated_state.get("bugteam_skill_invoked_at_head") is None
|
|
223
|
+
assert updated_state.get("bugteam_skill_invoked_at_tick") is None
|
|
224
|
+
assert "current_head or tick_count" in captured_stderr
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_should_leave_state_unchanged_when_tick_count_is_missing(
|
|
228
|
+
claude_job_directory: pathlib.Path,
|
|
229
|
+
) -> None:
|
|
230
|
+
state_without_tick = _baseline_state()
|
|
231
|
+
del state_without_tick["tick_count"]
|
|
232
|
+
_write_state(claude_job_directory, state_without_tick)
|
|
233
|
+
skill_payload: dict[str, Any] = {
|
|
234
|
+
"tool_name": "Skill",
|
|
235
|
+
"tool_input": {"skill": "bugteam"},
|
|
236
|
+
}
|
|
237
|
+
captured_stderr = _run_main_capturing_stderr(json.dumps(skill_payload))
|
|
238
|
+
updated_state = _read_state(claude_job_directory)
|
|
239
|
+
assert "tick_count" not in updated_state
|
|
240
|
+
assert updated_state.get("bugteam_skill_invoked_at_head") is None
|
|
241
|
+
assert updated_state.get("bugteam_skill_invoked_at_tick") is None
|
|
242
|
+
assert "current_head or tick_count" in captured_stderr
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_should_preserve_prior_stamp_when_current_head_becomes_missing(
|
|
246
|
+
claude_job_directory: pathlib.Path,
|
|
247
|
+
) -> None:
|
|
248
|
+
prior_head = "feedface0000feedface0000feedface0000feed"
|
|
249
|
+
prior_tick = 3
|
|
250
|
+
state_with_prior_stamp = _baseline_state()
|
|
251
|
+
state_with_prior_stamp["bugteam_skill_invoked_at_head"] = prior_head
|
|
252
|
+
state_with_prior_stamp["bugteam_skill_invoked_at_tick"] = prior_tick
|
|
253
|
+
del state_with_prior_stamp["current_head"]
|
|
254
|
+
_write_state(claude_job_directory, state_with_prior_stamp)
|
|
255
|
+
skill_payload: dict[str, Any] = {
|
|
256
|
+
"tool_name": "Skill",
|
|
257
|
+
"tool_input": {"skill": "bugteam"},
|
|
258
|
+
}
|
|
259
|
+
_run_main_capturing_stderr(json.dumps(skill_payload))
|
|
260
|
+
updated_state = _read_state(claude_job_directory)
|
|
261
|
+
assert updated_state["bugteam_skill_invoked_at_head"] == prior_head
|
|
262
|
+
assert updated_state["bugteam_skill_invoked_at_tick"] == prior_tick
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_should_skip_atomic_write_when_state_missing_required_fields(
|
|
266
|
+
claude_job_directory: pathlib.Path,
|
|
267
|
+
) -> None:
|
|
268
|
+
state_without_head = _baseline_state()
|
|
269
|
+
del state_without_head["current_head"]
|
|
270
|
+
_write_state(claude_job_directory, state_without_head)
|
|
271
|
+
skill_payload: dict[str, Any] = {
|
|
272
|
+
"tool_name": "Skill",
|
|
273
|
+
"tool_input": {"skill": "bugteam"},
|
|
274
|
+
}
|
|
275
|
+
with mock.patch.object(hook_module, "_atomic_write_state") as patched_atomic_write:
|
|
276
|
+
with mock.patch("sys.stdin", io.StringIO(json.dumps(skill_payload))):
|
|
277
|
+
with mock.patch("sys.stdout", new_callable=io.StringIO):
|
|
278
|
+
with mock.patch("sys.stderr", new_callable=io.StringIO):
|
|
279
|
+
try:
|
|
280
|
+
hook_module.main()
|
|
281
|
+
except SystemExit:
|
|
282
|
+
pass
|
|
283
|
+
assert patched_atomic_write.call_count == 0
|