claude-dev-env 8.78.3 → 8.80.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/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
- package/bin/install.settings-defaults.test.mjs +27 -2
- package/bin/merge_settings_defaults.mjs +39 -21
- package/bin/merge_settings_defaults.test.mjs +42 -0
- package/docs/account-broker.md +1 -1
- package/docs/codex-accounts.md +6 -5
- package/hooks/blocking/cloud_graphql_gate.py +213 -0
- package/hooks/blocking/headless_claude_broker_gate.py +1 -8
- package/hooks/blocking/merge_over_review_offer_gate.py +114 -0
- package/hooks/blocking/merge_review_gate.py +65 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
- package/hooks/blocking/test_merge_over_review_offer_gate.py +138 -0
- package/hooks/blocking/test_merge_review_gate.py +63 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
- package/hooks/conftest.py +1 -1
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +5 -2
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
- package/hooks/hooks_constants/cloud_session.py +17 -0
- package/hooks/hooks_constants/cloud_session_constants.py +11 -0
- package/hooks/hooks_constants/gh_command_words.py +52 -0
- package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
- package/hooks/hooks_constants/hook_block_logger.py +21 -3
- package/hooks/hooks_constants/merge_over_review_offer_gate_constants.py +34 -0
- package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
- package/hooks/hooks_constants/mod_handoff.py +2 -3
- package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
- package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
- package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
- package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/test_cloud_session.py +27 -0
- package/hooks/hooks_constants/test_gh_command_words.py +45 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
- package/hooks/hooks_constants/test_mod_handoff.py +3 -2
- package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
- package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
- package/hooks/test_conftest.py +1 -1
- package/package.json +1 -1
- package/scripts/account_broker.py +23 -11
- package/scripts/account_broker_support.py +23 -14
- package/scripts/codex_account_meters.py +30 -43
- package/scripts/conftest.py +25 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +36 -25
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +57 -14
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
- package/scripts/merge_review_receipt.py +56 -12
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +145 -3
- package/scripts/test_account_broker_support.py +88 -6
- package/scripts/test_claude_chain_usage.py +3 -11
- package/scripts/test_codex_account_meters.py +26 -14
- package/scripts/test_merge_review_receipt.py +86 -0
- package/scripts/test_shared_tree_paths.py +58 -36
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/settings.json +3 -0
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"""Deny an agent's merge until a review of the merged head came back clean.
|
|
2
2
|
|
|
3
3
|
The agent that drives a pull request loops a code review on it, fixing and
|
|
4
|
-
pushing each finding, until one pass reports zero findings
|
|
4
|
+
pushing each finding, until one pass reports zero findings or every finding
|
|
5
|
+
it reports is ruled out with a link to the posted rulings. The merge then
|
|
5
6
|
names that exact head::
|
|
6
7
|
|
|
7
8
|
merge, receipt says 0 findings at abc123, merge pinned to abc123 -> allow
|
|
9
|
+
merge, receipt rules out all 3 findings with a link, pinned -> allow
|
|
8
10
|
merge, receipt at abc123, merge pinned to def456 -> deny, review again
|
|
9
11
|
merge, receipt at abc123, merge names no head -> deny, pin it
|
|
10
|
-
merge, no receipt or the receipt counts findings
|
|
12
|
+
merge, no receipt or the receipt counts open findings -> deny, run the loop
|
|
11
13
|
auto-merge or enqueue pinned to the clean head -> allow
|
|
12
14
|
auto-merge that names no head -> deny, pin it
|
|
13
15
|
repository listed in CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES -> allow
|
|
@@ -61,10 +63,13 @@ from hooks_constants.merge_review_gate_constants import (
|
|
|
61
63
|
RECEIPT_FILE_NAME_TEMPLATE,
|
|
62
64
|
RECEIPT_FINDINGS_KEY,
|
|
63
65
|
RECEIPT_HEAD_KEY,
|
|
66
|
+
RECEIPT_RULED_OUT_KEY,
|
|
67
|
+
RECEIPT_RULINGS_KEY,
|
|
64
68
|
REPO_FIELD,
|
|
65
69
|
REPO_OPTION_PATTERN,
|
|
66
70
|
REPOSITORY_SEPARATOR,
|
|
67
71
|
REVIEW_COMMAND_ENVIRONMENT_VARIABLE,
|
|
72
|
+
RULINGS_LINK_PATTERN,
|
|
68
73
|
STALE_REVIEW_REASON_TEMPLATE,
|
|
69
74
|
UNKNOWN_TARGET_REASON,
|
|
70
75
|
UNPINNED_MERGE_REASON_TEMPLATE,
|
|
@@ -228,12 +233,69 @@ def _clean_head(request: MergeRequest) -> str | None:
|
|
|
228
233
|
)
|
|
229
234
|
except (OSError, ValueError):
|
|
230
235
|
return None
|
|
231
|
-
if not isinstance(receipt, dict) or
|
|
236
|
+
if not isinstance(receipt, dict) or not is_clean_receipt(
|
|
237
|
+
receipt, request.owner, request.repo, request.number
|
|
238
|
+
):
|
|
232
239
|
return None
|
|
233
240
|
head = receipt.get(RECEIPT_HEAD_KEY)
|
|
234
241
|
return head if isinstance(head, str) and head else None
|
|
235
242
|
|
|
236
243
|
|
|
244
|
+
def is_clean_receipt(
|
|
245
|
+
all_receipt_fields: dict[str, object], owner: str, repo: str, number: int
|
|
246
|
+
) -> bool:
|
|
247
|
+
"""Return whether a recorded pass lets the merge through.
|
|
248
|
+
|
|
249
|
+
A pass is clean when it reported zero findings, or when every finding it
|
|
250
|
+
reported is ruled out and the receipt links the rulings comment posted on
|
|
251
|
+
this same pull request::
|
|
252
|
+
|
|
253
|
+
findings 0 -> clean
|
|
254
|
+
findings 2, ruled out 2, .../acme/tools/pull/12#issuecomment-9 -> clean
|
|
255
|
+
findings 2, ruled out 2, .../acme/other/pull/12#issuecomment-9 -> not clean
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
all_receipt_fields: The parsed receipt of one review pass.
|
|
259
|
+
owner: Owner of the pull request the merge targets.
|
|
260
|
+
repo: Repository of the pull request the merge targets.
|
|
261
|
+
number: Number of the pull request the merge targets.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
True when the pass counts as clean.
|
|
265
|
+
"""
|
|
266
|
+
finding_count = all_receipt_fields.get(RECEIPT_FINDINGS_KEY)
|
|
267
|
+
if not _is_count(finding_count):
|
|
268
|
+
return False
|
|
269
|
+
return finding_count == 0 or _rules_out_every_finding(
|
|
270
|
+
all_receipt_fields, finding_count, owner, repo, number
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _is_count(candidate: object) -> bool:
|
|
275
|
+
return isinstance(candidate, int) and not isinstance(candidate, bool) and candidate >= 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _rules_out_every_finding(
|
|
279
|
+
all_receipt_fields: dict[str, object], finding_count: object, owner: str, repo: str, number: int
|
|
280
|
+
) -> bool:
|
|
281
|
+
ruled_out_count = all_receipt_fields.get(RECEIPT_RULED_OUT_KEY)
|
|
282
|
+
return (
|
|
283
|
+
_is_count(ruled_out_count)
|
|
284
|
+
and ruled_out_count == finding_count
|
|
285
|
+
and _links_rulings_on(all_receipt_fields.get(RECEIPT_RULINGS_KEY), owner, repo, number)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _links_rulings_on(rulings: object, owner: str, repo: str, number: int) -> bool:
|
|
290
|
+
rulings_match = RULINGS_LINK_PATTERN.fullmatch(rulings) if isinstance(rulings, str) else None
|
|
291
|
+
return (
|
|
292
|
+
rulings_match is not None
|
|
293
|
+
and rulings_match["owner"].casefold() == owner.casefold()
|
|
294
|
+
and rulings_match["repo"].casefold() == repo.casefold()
|
|
295
|
+
and int(rulings_match["number"]) == number
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
237
299
|
def _review_command() -> str:
|
|
238
300
|
return os.environ.get(REVIEW_COMMAND_ENVIRONMENT_VARIABLE) or DEFAULT_REVIEW_COMMAND
|
|
239
301
|
|
|
@@ -32,7 +32,6 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
32
32
|
DENY_REASON,
|
|
33
33
|
ENVIRONMENT_ASSIGNMENT_PATTERN,
|
|
34
34
|
GH_API_COMMAND,
|
|
35
|
-
GH_OPTIONS_WITH_VALUE,
|
|
36
35
|
GH_PULL_REQUEST_COMMAND,
|
|
37
36
|
GIT_ACTIONS,
|
|
38
37
|
GIT_GLOBAL_OPTIONS_WITH_VALUE,
|
|
@@ -49,6 +48,7 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
49
48
|
SUBAGENT_TRANSCRIPT_DIRECTORY_NAME,
|
|
50
49
|
SUBAGENT_TRANSCRIPT_FILE_TEMPLATE,
|
|
51
50
|
)
|
|
51
|
+
from hooks_constants.gh_command_words import gh_command_words
|
|
52
52
|
from hooks_constants.hook_specific_output_keys import (
|
|
53
53
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
54
54
|
PERMISSION_DECISION_KEY,
|
|
@@ -113,13 +113,12 @@ def _is_git_action(all_words: list[str]) -> bool:
|
|
|
113
113
|
|
|
114
114
|
|
|
115
115
|
def _is_gh_action(all_words: list[str]) -> bool:
|
|
116
|
-
|
|
117
|
-
if
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if action != GH_API_COMMAND:
|
|
116
|
+
all_gh_words = gh_command_words(all_words[1:])
|
|
117
|
+
if all_gh_words[:1] == [GH_PULL_REQUEST_COMMAND]:
|
|
118
|
+
return len(all_gh_words) > 1
|
|
119
|
+
if all_gh_words[:1] != [GH_API_COMMAND]:
|
|
121
120
|
return False
|
|
122
|
-
all_api_arguments = all_words[
|
|
121
|
+
all_api_arguments = all_words[all_words.index(GH_API_COMMAND, 1) + 1 :]
|
|
123
122
|
if any(each_name in each_word for each_word in all_api_arguments for each_name in ALL_API_ACTION_NAMES):
|
|
124
123
|
return True
|
|
125
124
|
return any(
|
|
@@ -22,7 +22,7 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def
|
|
25
|
+
def test_bash_roster_starts_only_the_msys_rewriter_and_the_three_gates() -> None:
|
|
26
26
|
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
27
27
|
BashHostedHookEntry(
|
|
28
28
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
@@ -38,4 +38,8 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_two_gates() -> None:
|
|
|
38
38
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
39
39
|
replaced_by_plugin_name="shell-guards",
|
|
40
40
|
),
|
|
41
|
+
BashHostedHookEntry(
|
|
42
|
+
script_relative_path="blocking/cloud_graphql_gate.py",
|
|
43
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
44
|
+
),
|
|
41
45
|
)
|
|
@@ -270,7 +270,10 @@ def test_should_drop_the_gates_the_shell_guards_mod_holds_while_it_is_on(
|
|
|
270
270
|
) -> None:
|
|
271
271
|
_enable_shell_guards(tmp_path, monkeypatch)
|
|
272
272
|
all_script_paths = [each_entry.script_relative_path for each_entry in select_applicable_entries(BASH_TOOL_NAME)]
|
|
273
|
-
assert all_script_paths == [
|
|
273
|
+
assert all_script_paths == [
|
|
274
|
+
"blocking/msys_rev_path_rewriter.py",
|
|
275
|
+
"blocking/cloud_graphql_gate.py",
|
|
276
|
+
]
|
|
274
277
|
|
|
275
278
|
|
|
276
279
|
def test_should_pass_a_headless_claude_command_while_the_shell_guards_mod_is_on(
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Tests for cloud_graphql_gate, which stops cloud sessions from calling GitHub GraphQL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from io import StringIO
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import cloud_graphql_gate as gate
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _stdout_from_main(payload: dict[str, object]) -> str:
|
|
16
|
+
"""Return what main() wrote to stdout for one hook payload."""
|
|
17
|
+
captured_stdout = StringIO()
|
|
18
|
+
with (
|
|
19
|
+
patch("sys.stdin", StringIO(json.dumps(payload))),
|
|
20
|
+
patch("sys.stdout", captured_stdout),
|
|
21
|
+
patch.object(gate, "log_hook_block"),
|
|
22
|
+
):
|
|
23
|
+
gate.main()
|
|
24
|
+
return captured_stdout.getvalue()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_ENCODED_GH_PR_LIST = base64.b64encode("gh pr list".encode("utf-16-le")).decode()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def cloud_session(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
32
|
+
"""Mark the hook process as running inside a Claude Code cloud session."""
|
|
33
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize(
|
|
37
|
+
"command",
|
|
38
|
+
[
|
|
39
|
+
"gh api graphql -f query='query{viewer{login}}'",
|
|
40
|
+
"gh.exe api graphql -F owner=o -f query=@q.graphql",
|
|
41
|
+
"cd repo && gh api graphql --paginate -f query=@q.graphql",
|
|
42
|
+
'bash -c "gh api graphql -f query=@q.graphql"',
|
|
43
|
+
"curl -sS -X POST https://api.github.com/graphql -d @q.json",
|
|
44
|
+
"curl -H 'Authorization: Bearer x' HTTPS://API.GITHUB.COM/GRAPHQL",
|
|
45
|
+
"wget --post-data=@q.json https://api.github.com/graphql",
|
|
46
|
+
"Invoke-RestMethod -Method Post -Uri https://api.github.com/graphql -Body $q",
|
|
47
|
+
"gh pr create --draft --title t --body-file b.md",
|
|
48
|
+
"gh pr view 12 --json title",
|
|
49
|
+
"gh pr checks 12",
|
|
50
|
+
"gh issue list -L 1",
|
|
51
|
+
"gh repo clone o/r",
|
|
52
|
+
"gh repo view o/r",
|
|
53
|
+
"gh label list",
|
|
54
|
+
"gh search prs repo:o/r",
|
|
55
|
+
"gh ruleset list",
|
|
56
|
+
"gh gist list",
|
|
57
|
+
"gh api -X POST graphql -f query='mutation{x}'",
|
|
58
|
+
"gh api --method POST graphql -f query=@q.graphql",
|
|
59
|
+
"gh api -H 'Accept: application/json' graphql -f query=@q",
|
|
60
|
+
"gh api /graphql -f query=@q",
|
|
61
|
+
"gh api https://api.github.com/graphql -f query=@q",
|
|
62
|
+
"gh -R o/r pr list",
|
|
63
|
+
"gh pr -R o/r list",
|
|
64
|
+
"gh pr --repo o/r view 12",
|
|
65
|
+
"gh pr merge 12 --body -h",
|
|
66
|
+
"gh pr view 12 --web",
|
|
67
|
+
"gh issue list --web",
|
|
68
|
+
"gh project item-list 1 --owner o",
|
|
69
|
+
"gh discussion list",
|
|
70
|
+
"gh status",
|
|
71
|
+
"gh status -o o",
|
|
72
|
+
"gh org list",
|
|
73
|
+
"gh repo archive o/r --yes",
|
|
74
|
+
"gh repo unarchive o/r --yes",
|
|
75
|
+
"curl -X POST https://api.github.com:443/graphql -d @q.json",
|
|
76
|
+
"if gh pr view 12; then echo ok; fi",
|
|
77
|
+
"if true; then gh api graphql -f query=@q; fi",
|
|
78
|
+
"while gh pr list; do sleep 1; done",
|
|
79
|
+
"! gh pr list",
|
|
80
|
+
"command gh pr list",
|
|
81
|
+
"exec gh issue list",
|
|
82
|
+
'echo "$(gh pr list)"',
|
|
83
|
+
"echo `gh pr view 12`",
|
|
84
|
+
"PR_URL=$(gh pr create --title t --body-file b.md)",
|
|
85
|
+
"(gh pr view 12)",
|
|
86
|
+
"python3 -c \"import requests; requests.post('https://api.github.com/graphql')\"",
|
|
87
|
+
"node -e \"fetch('https://api.github.com/graphql', {method: 'POST'})\"",
|
|
88
|
+
"gh release list -L 1",
|
|
89
|
+
"gh release view v1 --json name",
|
|
90
|
+
"gh workflow run ci.yml",
|
|
91
|
+
"gh workflow run ci.yml -f -r",
|
|
92
|
+
"gh pr list --search -w",
|
|
93
|
+
"echo \"$(printf ')'; gh pr list)\"",
|
|
94
|
+
"x=`echo \\`gh pr list\\``",
|
|
95
|
+
'echo $(echo "$(gh api graphql -f query=@q)")',
|
|
96
|
+
"python3.12 -c \"u('https://api.github.com/graphql')\"",
|
|
97
|
+
"pypy3 -c \"u('https://api.github.com/graphql')\"",
|
|
98
|
+
'eval "gh api graphql -f query=@q"',
|
|
99
|
+
"find . -maxdepth 0 -exec gh api graphql -f query=@q \\;",
|
|
100
|
+
"Invoke-Expression 'gh pr list'",
|
|
101
|
+
'iex "gh issue list"',
|
|
102
|
+
"find . -exec true \\; -exec gh api graphql -f query=@q \\;",
|
|
103
|
+
"gh repo sync o/r",
|
|
104
|
+
"gh repo create n --template o/t",
|
|
105
|
+
"gh repo create n -p o/t --private",
|
|
106
|
+
"gh repo edit o/r --enable-secret-scanning",
|
|
107
|
+
"gh repo edit --enable-advanced-security=false",
|
|
108
|
+
'Start-Process gh -ArgumentList "api graphql -f query=@q"',
|
|
109
|
+
"Start-Process -FilePath gh -ArgumentList 'pr','list' -Wait",
|
|
110
|
+
"Start-Process -FilePath:gh -ArgumentList:'api graphql -f query=@q'",
|
|
111
|
+
"GH.EXE pr list",
|
|
112
|
+
"pwsh -EncodedCommand " + _ENCODED_GH_PR_LIST,
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
def test_should_detect_a_github_graphql_call(command: str) -> None:
|
|
116
|
+
assert gate.calls_github_graphql(command) is True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize(
|
|
120
|
+
"command",
|
|
121
|
+
[
|
|
122
|
+
"gh api repos/o/r/pulls/1/ccr/review_threads",
|
|
123
|
+
"gh api user",
|
|
124
|
+
"gh pr diff 12",
|
|
125
|
+
"gh pr create --help",
|
|
126
|
+
"gh issue view -h",
|
|
127
|
+
"gh pr list -R o/r --help",
|
|
128
|
+
"gh pr list --web",
|
|
129
|
+
"gh pr list -w -R o/r",
|
|
130
|
+
"gh project",
|
|
131
|
+
"gh api repos/o/r/contents/docs/graphql.md",
|
|
132
|
+
"gh api repos/o/r/issues -f title=graphql",
|
|
133
|
+
"gh repo fork o/r",
|
|
134
|
+
"gh repo rename new-name",
|
|
135
|
+
"echo '$(gh pr list)'",
|
|
136
|
+
"command -v gh",
|
|
137
|
+
"gh pr view -w -h",
|
|
138
|
+
"gh pr list --state open --web",
|
|
139
|
+
"gh release view --json name",
|
|
140
|
+
"gh workflow run ci.yml --ref main",
|
|
141
|
+
"gh workflow run ci.yml -r main",
|
|
142
|
+
"gh workflow run ci.yml --ref=main",
|
|
143
|
+
"gh search repos claude-dev-env",
|
|
144
|
+
"gh run view 12",
|
|
145
|
+
"python3 scripts/review_closure.py o/r 1",
|
|
146
|
+
'eval "gh api repos/o/r/pulls/1"',
|
|
147
|
+
"gh repo sync o/r --branch main",
|
|
148
|
+
"gh repo sync -b main",
|
|
149
|
+
"gh repo create n --private",
|
|
150
|
+
"gh repo edit o/r --description d",
|
|
151
|
+
"gh api repos/o/r --preview graphql",
|
|
152
|
+
"gh api repos/o/r/issues --jq graphql",
|
|
153
|
+
"Start-Process notepad",
|
|
154
|
+
"find . -name '*.py' -exec grep -l graphql {} +",
|
|
155
|
+
"gh pr",
|
|
156
|
+
"gh run list -L 1",
|
|
157
|
+
"gh workflow list",
|
|
158
|
+
"gh label create bug",
|
|
159
|
+
"curl -sS https://api.github.com/repos/o/r",
|
|
160
|
+
'grep -rn "api.github.com/graphql" scripts',
|
|
161
|
+
'echo "gh api graphql"',
|
|
162
|
+
'git commit -m "drop gh api graphql calls"',
|
|
163
|
+
],
|
|
164
|
+
)
|
|
165
|
+
def test_should_pass_rest_calls_and_text_that_only_mentions_graphql(command: str) -> None:
|
|
166
|
+
assert gate.calls_github_graphql(command) is False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_should_deny_gh_api_graphql_in_a_cloud_session_and_name_the_rest_routes(
|
|
170
|
+
cloud_session: None,
|
|
171
|
+
) -> None:
|
|
172
|
+
decision = json.loads(
|
|
173
|
+
_stdout_from_main(
|
|
174
|
+
{"tool_name": "Bash", "tool_input": {"command": "gh api graphql -f query=@q"}}
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
specific_output = decision["hookSpecificOutput"]
|
|
178
|
+
assert specific_output["permissionDecision"] == "deny"
|
|
179
|
+
reason = specific_output["permissionDecisionReason"]
|
|
180
|
+
assert "GET /repos/{owner}/{repo}/pulls/{n}/ccr/review_threads" in reason
|
|
181
|
+
assert "gh api repos/{owner}/{repo}/..." in reason
|
|
182
|
+
assert "mcp__github__create_pull_request" in reason
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_should_deny_a_powershell_endpoint_call_in_a_cloud_session(cloud_session: None) -> None:
|
|
186
|
+
command = "Invoke-RestMethod -Uri https://api.github.com/graphql -Method Post"
|
|
187
|
+
decision = json.loads(
|
|
188
|
+
_stdout_from_main({"tool_name": "PowerShell", "tool_input": {"command": command}})
|
|
189
|
+
)
|
|
190
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_should_stay_quiet_outside_a_cloud_session() -> None:
|
|
194
|
+
assert (
|
|
195
|
+
_stdout_from_main(
|
|
196
|
+
{"tool_name": "Bash", "tool_input": {"command": "gh api graphql -f query=@q"}}
|
|
197
|
+
)
|
|
198
|
+
== ""
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_should_stay_quiet_for_a_rest_call_in_a_cloud_session(cloud_session: None) -> None:
|
|
203
|
+
rest_command = "gh api repos/o/r/pulls/1/ccr/review_threads"
|
|
204
|
+
assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": rest_command}}) == ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_should_stay_quiet_for_another_tool_in_a_cloud_session(cloud_session: None) -> None:
|
|
208
|
+
assert (
|
|
209
|
+
_stdout_from_main({"tool_name": "Write", "tool_input": {"command": "gh api graphql"}}) == ""
|
|
210
|
+
)
|
|
@@ -88,13 +88,18 @@ def test_other_tool_payload_emits_nothing() -> None:
|
|
|
88
88
|
assert _stdout_from_main({"tool_name": "Write", "tool_input": {"command": 'claude -p "task"'}}) == ""
|
|
89
89
|
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
@pytest.mark.parametrize("marker_value", ["true", " TRUE "])
|
|
92
|
+
def test_should_pass_bare_headless_claude_in_a_cloud_session(
|
|
93
|
+
monkeypatch: pytest.MonkeyPatch, marker_value: str
|
|
94
|
+
) -> None:
|
|
95
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", marker_value)
|
|
93
96
|
assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": 'claude -p "task"'}}) == ""
|
|
94
97
|
|
|
95
98
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
@pytest.mark.parametrize("marker_value", ["false", "1", ""])
|
|
100
|
+
def test_should_deny_bare_headless_claude_when_the_marker_is_not_true(
|
|
101
|
+
monkeypatch: pytest.MonkeyPatch, marker_value: str
|
|
102
|
+
) -> None:
|
|
103
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", marker_value)
|
|
104
|
+
decision = json.loads(_stdout_from_main({"tool_name": "Bash", "tool_input": {"command": 'claude -p "task"'}}))
|
|
105
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Behavior tests for the merge-over-review decision card gate."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
9
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
11
|
+
|
|
12
|
+
from blocking import merge_over_review_offer_gate as gate
|
|
13
|
+
|
|
14
|
+
GATE_SCRIPT = Path(gate.__file__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _card(context: str, *options: tuple[str, str]) -> dict[str, object]:
|
|
18
|
+
return {
|
|
19
|
+
"question": "What next for the pull request?",
|
|
20
|
+
"context": context,
|
|
21
|
+
"options": [{"label": label, "consequence": consequence} for label, consequence in options],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_merge_option_over_open_findings_should_be_offered_over_review() -> None:
|
|
26
|
+
card = _card("The review found 9 findings.", ("Merge now", "It lands today."), ("Keep looping", "I rerun."))
|
|
27
|
+
assert gate.offers_merge_over_review(card)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_merge_option_that_overrides_a_status_should_be_offered_over_review() -> None:
|
|
31
|
+
card = _card("Tests pass.", ("Merge now", "You override the status."))
|
|
32
|
+
assert gate.offers_merge_over_review(card)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_card_without_a_merge_option_should_pass() -> None:
|
|
36
|
+
card = _card("The review found 9 findings.", ("Keep looping", "I rerun."), ("Drop shell checks", "Smaller diff."))
|
|
37
|
+
assert not gate.offers_merge_over_review(card)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_merge_option_after_a_clean_review_should_pass() -> None:
|
|
41
|
+
card = _card("The review pass on the head came back clean.", ("Merge", "It lands."), ("Hold", "It waits."))
|
|
42
|
+
assert not gate.offers_merge_over_review(card)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_merge_question_with_yes_no_options_over_unresolved_threads_should_be_offered_over_review() -> None:
|
|
46
|
+
card = {"question": "Merge PR 41 now?", "context": "3 unresolved review threads.", "options": ["Yes", "No"]}
|
|
47
|
+
assert gate.offers_merge_over_review(card)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_string_options_should_be_read() -> None:
|
|
51
|
+
card = {"question": "What next?", "context": "9 findings remain.", "options": ["Merge now", "Keep looping"]}
|
|
52
|
+
assert gate.offers_merge_over_review(card)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_merge_queue_option_over_findings_should_be_offered_over_review() -> None:
|
|
56
|
+
card = _card("9 findings remain.", ("Join the merge queue", "It waits for checks."), ("Wait", "It waits."))
|
|
57
|
+
assert gate.offers_merge_over_review(card)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_merge_offer_in_context_over_findings_should_be_offered_over_review() -> None:
|
|
61
|
+
card = {
|
|
62
|
+
"question": "Go ahead?",
|
|
63
|
+
"context": "The PR is green and I can merge it now, but the review reports 9 findings.",
|
|
64
|
+
"options": ["Yes", "No"],
|
|
65
|
+
}
|
|
66
|
+
assert gate.offers_merge_over_review(card)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_offer_and_findings_in_separate_questions_should_be_offered_over_review() -> None:
|
|
70
|
+
card = {
|
|
71
|
+
"questions": [
|
|
72
|
+
{"question": "The review reports 9 findings. Which next?", "options": ["Keep fixing", "Split it"]},
|
|
73
|
+
{"question": "Merge it now?", "options": ["Yes", "No"]},
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
assert gate.offers_merge_over_review(card)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_merge_option_over_unaddressed_review_comments_should_be_offered_over_review() -> None:
|
|
80
|
+
card = _card("3 review comments are unaddressed; 2 changes requested.", ("Merge now", "It lands."))
|
|
81
|
+
assert gate.offers_merge_over_review(card)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_ship_card_about_a_pending_translation_should_pass() -> None:
|
|
85
|
+
card = {
|
|
86
|
+
"question": "Ship the README update now? The pending translation can follow.",
|
|
87
|
+
"options": ["Ship it", "Wait"],
|
|
88
|
+
}
|
|
89
|
+
assert not gate.offers_merge_over_review(card)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_ask_user_question_merge_option_over_findings_should_be_offered_over_review() -> None:
|
|
93
|
+
card = {
|
|
94
|
+
"questions": [
|
|
95
|
+
{
|
|
96
|
+
"question": "The review failed with 2 findings. What next?",
|
|
97
|
+
"header": "Next step",
|
|
98
|
+
"options": [
|
|
99
|
+
{"label": "Squash and merge", "description": "It lands now."},
|
|
100
|
+
{"label": "Fix first", "description": "I fix both."},
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
assert gate.offers_merge_over_review(card)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_card_about_the_merge_review_check_without_a_merge_offer_should_pass() -> None:
|
|
109
|
+
card = _card(
|
|
110
|
+
"The Merge review check reports 11 findings.", ("Keep fixing", "I rerun."), ("Split the PR", "Smaller diff.")
|
|
111
|
+
)
|
|
112
|
+
assert not gate.offers_merge_over_review(card)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_merge_option_after_a_review_with_no_findings_should_pass() -> None:
|
|
116
|
+
card = _card("The review on the head found no findings.", ("Merge", "It lands."), ("Hold", "It waits."))
|
|
117
|
+
assert not gate.offers_merge_over_review(card)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_decision_for_should_return_none_for_a_clean_card() -> None:
|
|
121
|
+
payload = {"tool_name": "AskUserQuestion", "tool_input": _card("All checks pass.", ("Merge", "It lands."))}
|
|
122
|
+
assert gate.decision_for(payload) is None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_script_should_print_a_deny_for_a_merge_over_findings_card() -> None:
|
|
126
|
+
payload = {
|
|
127
|
+
"tool_name": "mcp__hearthbot__ask_decision",
|
|
128
|
+
"tool_input": _card("9 findings remain.", ("Merge now", "It lands.")),
|
|
129
|
+
}
|
|
130
|
+
completed = subprocess.run(
|
|
131
|
+
[sys.executable, str(GATE_SCRIPT)],
|
|
132
|
+
input=json.dumps(payload),
|
|
133
|
+
capture_output=True,
|
|
134
|
+
text=True,
|
|
135
|
+
check=False,
|
|
136
|
+
)
|
|
137
|
+
assert completed.returncode == 0
|
|
138
|
+
assert json.loads(completed.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
@@ -16,6 +16,7 @@ from blocking import merge_review_gate as gate
|
|
|
16
16
|
|
|
17
17
|
CLEAN_HEAD = "abc1234def5678abc1234def5678abc1234def56"
|
|
18
18
|
NEW_HEAD = "9999999aaaaaaa9999999aaaaaaa9999999aaaaa"
|
|
19
|
+
RULINGS_LINK = "https://github.com/acme/tools/pull/12#issuecomment-1"
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
@pytest.fixture(autouse=True)
|
|
@@ -58,6 +59,68 @@ def test_should_allow_a_merge_pinned_to_the_clean_head() -> None:
|
|
|
58
59
|
assert _reason(_merge_tool(CLEAN_HEAD)) is None
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def test_should_allow_a_merge_whose_findings_are_all_ruled_out_with_a_link() -> None:
|
|
63
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 2, 2, RULINGS_LINK)
|
|
64
|
+
assert _reason(_merge_tool(CLEAN_HEAD)) is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_should_deny_a_merge_with_a_finding_left_open() -> None:
|
|
68
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 3, 2, RULINGS_LINK)
|
|
69
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize(
|
|
73
|
+
"receipt",
|
|
74
|
+
[
|
|
75
|
+
{"head": CLEAN_HEAD, "findings": 2, "ruled_out": 2},
|
|
76
|
+
{"head": CLEAN_HEAD, "findings": 2, "ruled_out": 2, "rulings": " "},
|
|
77
|
+
{"head": CLEAN_HEAD, "findings": 2, "ruled_out": 2, "rulings": "x"},
|
|
78
|
+
{
|
|
79
|
+
"head": CLEAN_HEAD,
|
|
80
|
+
"findings": 2,
|
|
81
|
+
"ruled_out": 2,
|
|
82
|
+
"rulings": "https://github.com/acme/other/pull/12#issuecomment-1",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"head": CLEAN_HEAD,
|
|
86
|
+
"findings": 2,
|
|
87
|
+
"ruled_out": 2,
|
|
88
|
+
"rulings": "https://github.com/acme/tools/pull/13#issuecomment-1",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"head": CLEAN_HEAD,
|
|
92
|
+
"findings": 2,
|
|
93
|
+
"ruled_out": 2,
|
|
94
|
+
"rulings": "https://evil.example/?https://github.com/acme/tools/pull/12#issuecomment-1",
|
|
95
|
+
},
|
|
96
|
+
{"head": CLEAN_HEAD, "findings": True},
|
|
97
|
+
{"head": CLEAN_HEAD, "findings": "0"},
|
|
98
|
+
{"head": CLEAN_HEAD, "findings": 1, "ruled_out": True, "rulings": RULINGS_LINK},
|
|
99
|
+
{"head": CLEAN_HEAD, "findings": -1, "ruled_out": -1, "rulings": RULINGS_LINK},
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
def test_is_clean_receipt_should_reject_a_receipt_it_cannot_back(receipt: dict[str, object]) -> None:
|
|
103
|
+
assert gate.is_clean_receipt(receipt, "acme", "tools", 12) is False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_is_clean_receipt_should_accept_zero_findings() -> None:
|
|
107
|
+
assert gate.is_clean_receipt({"head": CLEAN_HEAD, "findings": 0}, "acme", "tools", 12) is True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize(
|
|
111
|
+
"rulings", ["x", "https://github.com/acme/tools/pull/13#issuecomment-1"]
|
|
112
|
+
)
|
|
113
|
+
def test_should_deny_a_merge_whose_rulings_link_is_not_a_comment_on_this_pull_request(
|
|
114
|
+
rulings: str,
|
|
115
|
+
) -> None:
|
|
116
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 2, 2, rulings)
|
|
117
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_should_name_the_ruled_out_route_in_the_deny_reason() -> None:
|
|
121
|
+
assert "--ruled-out" in _reason(_merge_tool(CLEAN_HEAD))
|
|
122
|
+
|
|
123
|
+
|
|
61
124
|
def test_should_deny_a_merge_pinned_to_a_head_pushed_after_the_review() -> None:
|
|
62
125
|
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
63
126
|
assert "Review the new head" in _reason(_merge_tool(NEW_HEAD))
|
|
@@ -156,6 +156,7 @@ def test_action_command_shapes(tmp_path: Path) -> None:
|
|
|
156
156
|
"git --git-dir=.git push origin head",
|
|
157
157
|
"gh pr create",
|
|
158
158
|
"gh -R owner/repo pr --repo owner/repo merge",
|
|
159
|
+
"gh -R owner/repo api -X PUT repos/owner/repo/pulls/1/merge",
|
|
159
160
|
"gh api -X PUT repos/owner/repo/pulls/1/merge",
|
|
160
161
|
"gh api graphql -f query='mutation { enablePullRequestAutoMerge }'",
|
|
161
162
|
"gh api graphql -f name=auto-merge",
|
|
@@ -180,6 +181,8 @@ def test_non_action_command_shapes(tmp_path: Path) -> None:
|
|
|
180
181
|
"gh api repos/owner/repo/pulls/1/mergeability",
|
|
181
182
|
"gh issue create --body-file message.txt",
|
|
182
183
|
"gh pr",
|
|
184
|
+
"gh pr --repo owner/repo",
|
|
185
|
+
"gh api -H 'Accept: x' repos/owner/repo/pulls/1/mergeability",
|
|
183
186
|
"GH_TOKEN=x gh issue list",
|
|
184
187
|
"env -u GH_TOKEN git status",
|
|
185
188
|
)
|
package/hooks/conftest.py
CHANGED
|
@@ -23,7 +23,7 @@ _CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
|
|
|
23
23
|
).CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME
|
|
24
24
|
|
|
25
25
|
_CLOUD_SESSION_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
|
|
26
|
-
"hooks_constants.
|
|
26
|
+
"hooks_constants.cloud_session_constants"
|
|
27
27
|
).CLOUD_SESSION_ENV_VAR
|
|
28
28
|
|
|
29
29
|
_ROOT_ANCHORED_PROBE_PATH = "/tmp/scratch.py"
|