claude-dev-env 8.79.0 → 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_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_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 +2 -2
- 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_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_support.py +10 -11
- package/scripts/codex_account_meters.py +30 -43
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +34 -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 +42 -0
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
- package/scripts/merge_review_receipt.py +56 -12
- package/scripts/test_account_broker.py +12 -0
- package/scripts/test_account_broker_support.py +32 -6
- package/scripts/test_codex_account_meters.py +26 -14
- package/scripts/test_merge_review_receipt.py +86 -0
- package/settings.json +3 -0
|
@@ -7,8 +7,14 @@ The agent loops a code review on its pull request. Each pass is recorded::
|
|
|
7
7
|
fix, push, review the new head
|
|
8
8
|
python3 merge_review_receipt.py owner/name 12 def5678 0 -> CLEAN owner/name#12 def5678
|
|
9
9
|
|
|
10
|
+
A pass whose every finding is ruled out with file:line evidence counts as
|
|
11
|
+
clean once the rulings are posted and linked::
|
|
12
|
+
|
|
13
|
+
python3 merge_review_receipt.py owner/name 12 def5678 2 --ruled-out 2 --rulings <url>
|
|
14
|
+
-> CLEAN owner/name#12 def5678 (2 findings ruled out: <url>)
|
|
15
|
+
|
|
10
16
|
``hooks/blocking/merge_review_gate.py`` allows a merge pinned to the head of
|
|
11
|
-
the newest pass only when that pass
|
|
17
|
+
the newest pass only when that pass is clean.
|
|
12
18
|
"""
|
|
13
19
|
|
|
14
20
|
from __future__ import annotations
|
|
@@ -24,14 +30,23 @@ hooks_directory = str(Path(__file__).resolve().parent.parent / "hooks")
|
|
|
24
30
|
if hooks_directory not in sys.path:
|
|
25
31
|
sys.path.insert(0, hooks_directory)
|
|
26
32
|
|
|
27
|
-
from blocking.merge_review_gate import receipt_path
|
|
33
|
+
from blocking.merge_review_gate import is_clean_receipt, receipt_path
|
|
28
34
|
from hooks_constants.merge_review_gate_constants import (
|
|
29
35
|
RECEIPT_FINDINGS_KEY,
|
|
30
36
|
RECEIPT_HEAD_KEY,
|
|
37
|
+
RECEIPT_RULED_OUT_KEY,
|
|
38
|
+
RECEIPT_RULINGS_KEY,
|
|
31
39
|
)
|
|
32
40
|
|
|
33
41
|
|
|
34
|
-
def record_review(
|
|
42
|
+
def record_review(
|
|
43
|
+
slug: str,
|
|
44
|
+
number: int,
|
|
45
|
+
head: str,
|
|
46
|
+
finding_count: int,
|
|
47
|
+
ruled_out_count: int = 0,
|
|
48
|
+
rulings: str = "",
|
|
49
|
+
) -> Path:
|
|
35
50
|
"""Write the receipt for one review pass, replacing the previous pass.
|
|
36
51
|
|
|
37
52
|
Args:
|
|
@@ -39,21 +54,34 @@ def record_review(slug: str, number: int, head: str, finding_count: int) -> Path
|
|
|
39
54
|
number: Pull request number.
|
|
40
55
|
head: The commit the pass reviewed.
|
|
41
56
|
finding_count: How many findings the pass reported.
|
|
57
|
+
ruled_out_count: How many of those findings were ruled out.
|
|
58
|
+
rulings: Link to the posted rulings, empty when none were ruled out.
|
|
42
59
|
|
|
43
60
|
Returns:
|
|
44
61
|
The receipt path written.
|
|
45
62
|
"""
|
|
63
|
+
receipt = _receipt_fields(head, finding_count, ruled_out_count, rulings)
|
|
64
|
+
return _write_receipt(slug, number, receipt)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write_receipt(slug: str, number: int, all_receipt_fields: dict[str, object]) -> Path:
|
|
46
68
|
owner, repo = slug.split("/", 1)
|
|
47
69
|
path = receipt_path(owner, repo, number)
|
|
48
70
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
-
path.write_text(
|
|
50
|
-
json.dumps({RECEIPT_HEAD_KEY: head, RECEIPT_FINDINGS_KEY: finding_count})
|
|
51
|
-
+ "\n",
|
|
52
|
-
encoding="utf-8",
|
|
53
|
-
)
|
|
71
|
+
path.write_text(json.dumps(all_receipt_fields) + "\n", encoding="utf-8")
|
|
54
72
|
return path
|
|
55
73
|
|
|
56
74
|
|
|
75
|
+
def _receipt_fields(
|
|
76
|
+
head: str, finding_count: int, ruled_out_count: int, rulings: str
|
|
77
|
+
) -> dict[str, object]:
|
|
78
|
+
receipt: dict[str, object] = {RECEIPT_HEAD_KEY: head, RECEIPT_FINDINGS_KEY: finding_count}
|
|
79
|
+
if ruled_out_count:
|
|
80
|
+
receipt[RECEIPT_RULED_OUT_KEY] = ruled_out_count
|
|
81
|
+
receipt[RECEIPT_RULINGS_KEY] = rulings
|
|
82
|
+
return receipt
|
|
83
|
+
|
|
84
|
+
|
|
57
85
|
def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
|
|
58
86
|
"""Record one pass and print its verdict line.
|
|
59
87
|
|
|
@@ -62,17 +90,33 @@ def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
|
|
|
62
90
|
stdout: Stream that receives the verdict.
|
|
63
91
|
|
|
64
92
|
Returns:
|
|
65
|
-
0 for a clean pass, and 1 for a pass with findings.
|
|
93
|
+
0 for a clean pass, and 1 for a pass with findings still open.
|
|
66
94
|
"""
|
|
67
95
|
parser = argparse.ArgumentParser(prog="merge_review_receipt")
|
|
68
96
|
parser.add_argument("slug", help="Repository as owner/name.")
|
|
69
97
|
parser.add_argument("number", type=int, help="Pull request number.")
|
|
70
98
|
parser.add_argument("head", help="The head commit the pass reviewed.")
|
|
71
99
|
parser.add_argument("findings", type=int, help="Findings the pass reported.")
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--ruled-out", type=int, default=0, help="Findings ruled out with file:line evidence."
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument("--rulings", default="", help="Link to the posted rulings.")
|
|
72
104
|
parsed = parser.parse_args(all_arguments)
|
|
73
|
-
|
|
74
|
-
if parsed.
|
|
75
|
-
|
|
105
|
+
rulings = parsed.rulings.strip()
|
|
106
|
+
if parsed.ruled_out < 0 or parsed.ruled_out > parsed.findings:
|
|
107
|
+
parser.error("--ruled-out must be between 0 and the finding count")
|
|
108
|
+
if parsed.ruled_out and not rulings:
|
|
109
|
+
parser.error("--ruled-out needs --rulings, a link to the posted rulings")
|
|
110
|
+
if rulings and not parsed.ruled_out:
|
|
111
|
+
parser.error("--rulings needs --ruled-out, the count of findings it rules out")
|
|
112
|
+
receipt = _receipt_fields(parsed.head, parsed.findings, parsed.ruled_out, rulings)
|
|
113
|
+
_write_receipt(parsed.slug, parsed.number, receipt)
|
|
114
|
+
owner, repo = parsed.slug.split("/", 1)
|
|
115
|
+
if is_clean_receipt(receipt, owner, repo, parsed.number):
|
|
116
|
+
ruled_out_note = (
|
|
117
|
+
f" ({parsed.ruled_out} findings ruled out: {rulings})" if parsed.ruled_out else ""
|
|
118
|
+
)
|
|
119
|
+
stdout.write(f"CLEAN {parsed.slug}#{parsed.number} {parsed.head}{ruled_out_note}\n")
|
|
76
120
|
return 0
|
|
77
121
|
stdout.write(
|
|
78
122
|
f"REVIEWED {parsed.slug}#{parsed.number} {parsed.head} {parsed.findings} findings\n"
|
|
@@ -226,6 +226,18 @@ def test_should_wait_for_main_5_hour_reset_while_its_week_resets_days_away() ->
|
|
|
226
226
|
assert decision.resets_at == short_reset
|
|
227
227
|
|
|
228
228
|
|
|
229
|
+
def test_should_check_main_again_in_an_hour_when_a_blocking_window_has_no_reset() -> None:
|
|
230
|
+
main = Reading(
|
|
231
|
+
_account("main", Product.CLAUDE, main=True),
|
|
232
|
+
Meters(5, None, 1, NOW + timedelta(hours=4)),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
decision = choose_from_readings(Product.CLAUDE, (main,), now=NOW)
|
|
236
|
+
|
|
237
|
+
assert decision.action == "wait"
|
|
238
|
+
assert decision.resets_at == NOW + timedelta(hours=1)
|
|
239
|
+
|
|
240
|
+
|
|
229
241
|
@pytest.mark.parametrize(
|
|
230
242
|
("short_left", "weekly_left", "weekly_reset", "expected_reset"),
|
|
231
243
|
(
|
|
@@ -16,6 +16,7 @@ import pytest
|
|
|
16
16
|
|
|
17
17
|
import account_broker_support as support
|
|
18
18
|
from account_broker_support import Account, Meters, Product, ProductAdapter
|
|
19
|
+
from codex_account_meters import CodexAccountMeters, UsageWindow
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def test_should_load_claude_roster_from_both_lists(
|
|
@@ -184,16 +185,24 @@ def test_should_read_claude_meters_through_injected_probe(
|
|
|
184
185
|
def test_should_read_codex_meters_through_injected_reader(
|
|
185
186
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
186
187
|
) -> None:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
usage =
|
|
188
|
+
short_reset = datetime(2026, 10, 3, 5, tzinfo=timezone.utc)
|
|
189
|
+
unknown_length_reset = datetime(2026, 10, 9, tzinfo=timezone.utc)
|
|
190
|
+
usage = CodexAccountMeters(
|
|
191
|
+
(
|
|
192
|
+
UsageWindow(300, 20.0, short_reset),
|
|
193
|
+
UsageWindow(10080, 30.0, None),
|
|
194
|
+
UsageWindow(None, 50.0, unknown_length_reset),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
190
197
|
monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", lambda _: tmp_path / "codex")
|
|
191
198
|
monkeypatch.setattr(support.codex_account_meters, "read_codex_meters", lambda *_: usage)
|
|
192
199
|
|
|
193
200
|
meters = support.read_codex_account_meters(Account(Product.CODEX, "one", tmp_path))
|
|
194
201
|
|
|
195
202
|
assert meters.session_percent_left == 80.0
|
|
196
|
-
assert meters.
|
|
203
|
+
assert meters.session_resets_at == short_reset
|
|
204
|
+
assert meters.weekly_percent_left == 50.0
|
|
205
|
+
assert meters.weekly_resets_at == unknown_length_reset
|
|
197
206
|
|
|
198
207
|
|
|
199
208
|
def _codex_meter_reads(
|
|
@@ -261,8 +270,7 @@ def test_should_read_claude_meters_after_transient_failures_with_growing_delays(
|
|
|
261
270
|
def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
|
|
262
271
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
263
272
|
) -> None:
|
|
264
|
-
|
|
265
|
-
usage = SimpleNamespace(all_windows=(weekly,), short_window_percent_left=None)
|
|
273
|
+
usage = CodexAccountMeters((UsageWindow(10080, 30.0, None),))
|
|
266
274
|
unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
|
|
267
275
|
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, unread, usage])
|
|
268
276
|
all_delays = _recorded_sleeps(monkeypatch)
|
|
@@ -275,6 +283,24 @@ def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
|
|
|
275
283
|
assert all_delays == [1.0, 2.0]
|
|
276
284
|
|
|
277
285
|
|
|
286
|
+
def test_should_stop_at_once_when_no_codex_executable_is_found(
|
|
287
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
288
|
+
) -> None:
|
|
289
|
+
unpatched_resolve_codex_path = support.codex_account_meters.resolve_codex_path
|
|
290
|
+
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [])
|
|
291
|
+
monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", unpatched_resolve_codex_path)
|
|
292
|
+
monkeypatch.setenv("PATH", "")
|
|
293
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
294
|
+
all_delays = _recorded_sleeps(monkeypatch)
|
|
295
|
+
|
|
296
|
+
reading = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
|
|
297
|
+
|
|
298
|
+
assert reading.meters is None
|
|
299
|
+
assert reading.unread_reason == "no codex executable on PATH or at its install path"
|
|
300
|
+
assert all_homes_read == []
|
|
301
|
+
assert all_delays == []
|
|
302
|
+
|
|
303
|
+
|
|
278
304
|
def test_should_keep_the_unread_reason_after_every_claude_read_fails(
|
|
279
305
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
280
306
|
) -> None:
|
|
@@ -11,6 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
import pytest
|
|
12
12
|
|
|
13
13
|
from codex_account_meters import (
|
|
14
|
+
CodexExecutableNotFoundError,
|
|
14
15
|
CodexMeterUnreadError,
|
|
15
16
|
exchange_with_app_server,
|
|
16
17
|
parse_rate_limits_reply,
|
|
@@ -30,7 +31,7 @@ def reply_line(rate_limits: object) -> str:
|
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
class TestParseRateLimitsReply:
|
|
33
|
-
def
|
|
34
|
+
def should_read_every_window(self) -> None:
|
|
34
35
|
meters = parse_rate_limits_reply(
|
|
35
36
|
reply_line(
|
|
36
37
|
{
|
|
@@ -47,7 +48,7 @@ class TestParseRateLimitsReply:
|
|
|
47
48
|
}
|
|
48
49
|
)
|
|
49
50
|
)
|
|
50
|
-
assert meters.
|
|
51
|
+
assert [each.used_percent for each in meters.all_windows] == [30.0, 85.0]
|
|
51
52
|
assert [each.duration_minutes for each in meters.all_windows] == [300, 10080]
|
|
52
53
|
assert meters.all_windows[0].resets_at == datetime.fromtimestamp(
|
|
53
54
|
RESET_SECONDS, tz=timezone.utc
|
|
@@ -62,11 +63,11 @@ class TestParseRateLimitsReply:
|
|
|
62
63
|
}
|
|
63
64
|
)
|
|
64
65
|
)
|
|
65
|
-
assert meters.
|
|
66
|
+
assert [each.used_percent for each in meters.all_windows] == [40.0]
|
|
66
67
|
|
|
67
68
|
def should_clamp_used_percent_past_full(self) -> None:
|
|
68
69
|
meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 130}}))
|
|
69
|
-
assert meters.
|
|
70
|
+
assert [each.used_percent for each in meters.all_windows] == [100.0]
|
|
70
71
|
|
|
71
72
|
@pytest.mark.parametrize(
|
|
72
73
|
"line",
|
|
@@ -103,20 +104,31 @@ class TestShortWindowPercentLeft:
|
|
|
103
104
|
)
|
|
104
105
|
assert meters.short_window_percent_left is None
|
|
105
106
|
|
|
107
|
+
def should_count_a_window_without_a_length_as_weekly(self) -> None:
|
|
108
|
+
meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 90}}))
|
|
109
|
+
assert meters.short_window_percent_left is None
|
|
110
|
+
assert meters.weekly_window is not None
|
|
111
|
+
assert meters.weekly_window.used_percent == 90.0
|
|
112
|
+
|
|
106
113
|
|
|
107
|
-
class
|
|
108
|
-
def
|
|
114
|
+
class TestTightestWindows:
|
|
115
|
+
def should_pick_the_most_used_window_in_each_class(self) -> None:
|
|
109
116
|
meters = parse_rate_limits_reply(
|
|
110
117
|
reply_line(
|
|
111
118
|
{
|
|
112
|
-
"primary": {"usedPercent":
|
|
113
|
-
"secondary": {"usedPercent":
|
|
119
|
+
"primary": {"usedPercent": 70, "windowDurationMins": 300, "resetsAt": RESET_SECONDS},
|
|
120
|
+
"secondary": {"usedPercent": 20, "windowDurationMins": 10080, "resetsAt": RESET_SECONDS + 3600},
|
|
114
121
|
}
|
|
115
122
|
)
|
|
116
123
|
)
|
|
117
|
-
assert meters.
|
|
118
|
-
|
|
119
|
-
|
|
124
|
+
assert meters.short_window is not None
|
|
125
|
+
assert meters.short_window.resets_at == datetime.fromtimestamp(RESET_SECONDS, tz=timezone.utc)
|
|
126
|
+
assert meters.weekly_window is not None
|
|
127
|
+
assert meters.weekly_window.resets_at == datetime.fromtimestamp(RESET_SECONDS + 3600, tz=timezone.utc)
|
|
128
|
+
|
|
129
|
+
def should_name_no_weekly_window_for_a_short_only_account(self) -> None:
|
|
130
|
+
meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 70, "windowDurationMins": 300}}))
|
|
131
|
+
assert meters.weekly_window is None
|
|
120
132
|
|
|
121
133
|
|
|
122
134
|
class TestReadCodexMeters:
|
|
@@ -141,7 +153,7 @@ class TestReadCodexMeters:
|
|
|
141
153
|
]
|
|
142
154
|
|
|
143
155
|
meters = read_codex_meters(Path("codex"), Path("profiles") / "codex-1", fake_exchange)
|
|
144
|
-
assert meters.
|
|
156
|
+
assert [each.used_percent for each in meters.all_windows] == [20.0]
|
|
145
157
|
assert all_calls == [
|
|
146
158
|
(
|
|
147
159
|
Path("codex"),
|
|
@@ -215,7 +227,7 @@ class TestExchangeWithAppServer:
|
|
|
215
227
|
|
|
216
228
|
meters = read_codex_meters(fake_codex, tmp_path / "codex-2", exchange_with_app_server)
|
|
217
229
|
|
|
218
|
-
assert meters.
|
|
230
|
+
assert [each.used_percent for each in meters.all_windows] == [12.0]
|
|
219
231
|
|
|
220
232
|
|
|
221
233
|
class TestRequestMessages:
|
|
@@ -234,5 +246,5 @@ class TestResolveCodexPath:
|
|
|
234
246
|
) -> None:
|
|
235
247
|
monkeypatch.setenv("PATH", "")
|
|
236
248
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
237
|
-
with pytest.raises(
|
|
249
|
+
with pytest.raises(CodexExecutableNotFoundError):
|
|
238
250
|
resolve_codex_path(None)
|
|
@@ -11,6 +11,7 @@ import pytest
|
|
|
11
11
|
import merge_review_receipt
|
|
12
12
|
|
|
13
13
|
HEAD = "abc1234def5678abc1234def5678abc1234def56"
|
|
14
|
+
RULINGS_LINK = "https://github.com/acme/tools/pull/12#issuecomment-1"
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
@pytest.fixture(autouse=True)
|
|
@@ -36,3 +37,88 @@ def test_record_review_replaces_the_previous_pass(receipt_folder: Path) -> None:
|
|
|
36
37
|
path = merge_review_receipt.record_review("Acme/Tools", 12, HEAD, 0)
|
|
37
38
|
assert path == receipt_folder / "acme__tools__12.json"
|
|
38
39
|
assert json.loads(path.read_text(encoding="utf-8")) == {"head": HEAD, "findings": 0}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_should_print_clean_when_every_finding_is_ruled_out_with_a_link() -> None:
|
|
43
|
+
verdict_stream = StringIO()
|
|
44
|
+
exit_code = merge_review_receipt.main(
|
|
45
|
+
["acme/tools", "12", HEAD, "2", "--ruled-out", "2", "--rulings", RULINGS_LINK],
|
|
46
|
+
verdict_stream,
|
|
47
|
+
)
|
|
48
|
+
assert exit_code == 0
|
|
49
|
+
assert verdict_stream.getvalue() == (
|
|
50
|
+
f"CLEAN acme/tools#12 {HEAD} (2 findings ruled out: {RULINGS_LINK})\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_should_record_the_rulings_link_without_surrounding_whitespace(
|
|
55
|
+
receipt_folder: Path,
|
|
56
|
+
) -> None:
|
|
57
|
+
verdict_stream = StringIO()
|
|
58
|
+
exit_code = merge_review_receipt.main(
|
|
59
|
+
["acme/tools", "12", HEAD, "2", "--ruled-out", "2", "--rulings", f" {RULINGS_LINK}\n"],
|
|
60
|
+
verdict_stream,
|
|
61
|
+
)
|
|
62
|
+
assert exit_code == 0
|
|
63
|
+
assert verdict_stream.getvalue() == (
|
|
64
|
+
f"CLEAN acme/tools#12 {HEAD} (2 findings ruled out: {RULINGS_LINK})\n"
|
|
65
|
+
)
|
|
66
|
+
(receipt_file,) = receipt_folder.rglob("*.json")
|
|
67
|
+
assert json.loads(receipt_file.read_text(encoding="utf-8"))["rulings"] == RULINGS_LINK
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_should_keep_the_raw_finding_count_beside_the_ruled_out_count(
|
|
71
|
+
receipt_folder: Path,
|
|
72
|
+
) -> None:
|
|
73
|
+
path = merge_review_receipt.record_review("acme/tools", 12, HEAD, 2, 2, RULINGS_LINK)
|
|
74
|
+
assert json.loads(path.read_text(encoding="utf-8")) == {
|
|
75
|
+
"head": HEAD,
|
|
76
|
+
"findings": 2,
|
|
77
|
+
"ruled_out": 2,
|
|
78
|
+
"rulings": RULINGS_LINK,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_should_report_reviewed_when_some_findings_stay_open() -> None:
|
|
83
|
+
verdict_stream = StringIO()
|
|
84
|
+
exit_code = merge_review_receipt.main(
|
|
85
|
+
["acme/tools", "12", HEAD, "3", "--ruled-out", "2", "--rulings", RULINGS_LINK],
|
|
86
|
+
verdict_stream,
|
|
87
|
+
)
|
|
88
|
+
assert exit_code == 1
|
|
89
|
+
assert verdict_stream.getvalue() == f"REVIEWED acme/tools#12 {HEAD} 3 findings\n"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize(
|
|
93
|
+
"extra_arguments",
|
|
94
|
+
[["--ruled-out", "2"], ["--ruled-out", "4", "--rulings", RULINGS_LINK], ["--ruled-out", "-1"]],
|
|
95
|
+
)
|
|
96
|
+
def test_should_refuse_a_ruled_out_count_it_cannot_back(extra_arguments: list[str]) -> None:
|
|
97
|
+
with pytest.raises(SystemExit):
|
|
98
|
+
merge_review_receipt.main(["acme/tools", "12", HEAD, "3", *extra_arguments], StringIO())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize(
|
|
102
|
+
"all_arguments",
|
|
103
|
+
[
|
|
104
|
+
["acme/tools", "12", HEAD, "-1"],
|
|
105
|
+
["acme/tools", "12", HEAD, "2", "--rulings", RULINGS_LINK],
|
|
106
|
+
],
|
|
107
|
+
)
|
|
108
|
+
def test_should_refuse_counts_and_links_it_cannot_record(
|
|
109
|
+
all_arguments: list[str], receipt_folder: Path
|
|
110
|
+
) -> None:
|
|
111
|
+
with pytest.raises(SystemExit):
|
|
112
|
+
merge_review_receipt.main(all_arguments, StringIO())
|
|
113
|
+
assert list(receipt_folder.iterdir()) == []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_should_report_reviewed_when_the_rulings_link_points_at_another_pull_request() -> None:
|
|
117
|
+
verdict_stream = StringIO()
|
|
118
|
+
other_link = "https://github.com/acme/tools/pull/13#issuecomment-1"
|
|
119
|
+
exit_code = merge_review_receipt.main(
|
|
120
|
+
["acme/tools", "12", HEAD, "2", "--ruled-out", "2", "--rulings", other_link],
|
|
121
|
+
verdict_stream,
|
|
122
|
+
)
|
|
123
|
+
assert exit_code == 1
|
|
124
|
+
assert verdict_stream.getvalue() == f"REVIEWED acme/tools#12 {HEAD} 2 findings\n"
|
package/settings.json
CHANGED
|
@@ -22,5 +22,8 @@
|
|
|
22
22
|
"Updating or installing the claude-dev-env package is allowed: running `npx claude-dev-env@latest --update` or `npx --yes claude-dev-env@latest --update --target <claude config dir>` is the user's own daily environment sync from the user's own public package, and its writes to the Claude config directory are the intended result. This covers only that package and command.",
|
|
23
23
|
"Developing the claude-dev-env repository is allowed: editing, testing, committing and pushing its settings, hooks, scripts and tests is the user's requested work on the user's own public package. It changes the package source in the repository; it does not change the running session's own configuration."
|
|
24
24
|
]
|
|
25
|
+
},
|
|
26
|
+
"env": {
|
|
27
|
+
"CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS": "100000"
|
|
25
28
|
}
|
|
26
29
|
}
|