claude-dev-env 8.6.1 → 8.8.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/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/.agents/skills/team-advisor/SKILL.md +2 -2
- package/_shared/advisor/reference/identity.md +1 -1
- package/_shared/advisor/reference/spawn-walk-log.md +1 -1
- package/_shared/advisor/reference/warm-up.md +3 -1
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +23 -5
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +85 -6
- package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
- package/bin/install.test.mjs +25 -0
- package/docs/high-trust-agent-delivery.md +132 -0
- package/docs/references/advisor-tool.md +3 -2
- package/docs/second-claude-account.md +50 -0
- package/hooks/advisory/refactor_guard.py +2 -2
- package/hooks/advisory/test_refactor_guard.py +21 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
- package/hooks/blocking/code_rules_shared.py +4 -7
- package/hooks/blocking/conftest.py +22 -0
- package/hooks/blocking/state_description_blocker.py +10 -8
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
- package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
- package/hooks/blocking/test_code_rules_shared.py +18 -0
- package/hooks/blocking/test_conftest.py +35 -0
- package/hooks/blocking/test_pii_scanner.py +8 -0
- package/hooks/blocking/test_state_description_blocker.py +16 -0
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
- package/hooks/git-hooks/pull_request_handoff.py +2 -2
- package/hooks/git-hooks/test_config.py +10 -3
- package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
- package/hooks/git-hooks/test_verification_notice_state.py +50 -0
- package/hooks/git-hooks/verification_notice_state.py +3 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
- package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
- package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
- package/hooks/validators/exempt_paths.py +3 -18
- package/hooks/validators/test_exempt_paths.py +20 -0
- package/hooks/validators/test_python_antipattern_checks.py +14 -0
- package/package.json +1 -1
- package/rules/agent-merges-its-own-green-pull-request.md +54 -0
- package/rules/archiving-agent-config.md +10 -0
- package/rules/code-standards.md +1 -0
- package/rules/correction-lens.md +134 -0
- package/rules/flag-non-breaking-findings.md +11 -0
- package/rules/long-horizon-autonomy.md +11 -0
- package/rules/no-contrast-framing.md +68 -0
- package/rules/review-closure-is-a-check.md +44 -0
- package/scripts/Invoke-PesterSuites.ps1 +42 -0
- package/scripts/agent_merge_check.py +381 -0
- package/scripts/check.ps1 +12 -1
- package/scripts/ci/install-playtest.mjs +291 -0
- package/scripts/ci/install-playtest.test.mjs +84 -0
- package/scripts/ci/installer-lifecycle.mjs +11 -30
- package/scripts/ci/installer-lifecycle.test.mjs +14 -0
- package/scripts/ci/pstack-command-shim.test.mjs +33 -0
- package/scripts/ci/scratch-home-install.mjs +73 -0
- package/scripts/ci/scratch-home-install.test.mjs +49 -0
- package/scripts/claude_account_choice.py +246 -0
- package/scripts/claude_account_profile.py +371 -0
- package/scripts/claude_chain_usage.py +49 -5
- package/scripts/codex_compat_materializer.py +33 -16
- package/scripts/contrast_framing.py +101 -0
- package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +166 -0
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
- package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +21 -0
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
- package/scripts/durable_post_lint.py +30 -4
- package/scripts/followup_cli.py +6 -10
- package/scripts/merge_gate_checks.py +236 -0
- package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
- package/scripts/policy_lint/adapters.py +30 -0
- package/scripts/policy_lint/config/constants.py +2 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/repository_checks/config/constants.py +12 -0
- package/scripts/repository_checks/config/policy_document.py +28 -2
- package/scripts/repository_checks/followups.py +54 -0
- package/scripts/repository_checks/match_exemptions.py +67 -0
- package/scripts/repository_checks/models.py +46 -2
- package/scripts/repository_checks/reporting.py +23 -7
- package/scripts/repository_checks/tracked_secrets.py +2 -2
- package/scripts/repository_policy.py +4 -2
- package/scripts/review_closure.py +134 -0
- package/scripts/review_closure_github.py +345 -0
- package/scripts/review_closure_model.py +360 -0
- package/scripts/test_agent_merge_check.py +317 -0
- package/scripts/test_claude_account_choice.py +217 -0
- package/scripts/test_claude_account_profile.py +220 -0
- package/scripts/test_claude_chain_usage.py +40 -0
- package/scripts/test_durable_post_lint.py +46 -0
- package/scripts/test_followup_cli.py +40 -0
- package/scripts/test_merge_gate_checks.py +92 -0
- package/scripts/tests/test_code_review_constants.py +78 -0
- package/scripts/tests/test_codex_compat_materializer.py +48 -4
- package/scripts/tests/test_contrast_framing.py +117 -0
- package/scripts/tests/test_followups.py +88 -0
- package/scripts/tests/test_match_exemptions.py +291 -0
- package/scripts/tests/test_models.py +67 -2
- package/scripts/tests/test_package_inventory.py +6 -4
- package/scripts/tests/test_pester_suites_run.py +53 -0
- package/scripts/tests/test_policy_document.py +39 -1
- package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
- package/scripts/tests/test_reporting.py +18 -0
- package/scripts/tests/test_repository_policy.py +43 -1
- package/scripts/tests/test_review_closure.py +126 -0
- package/scripts/tests/test_review_closure_github.py +240 -0
- package/scripts/tests/test_review_closure_model.py +299 -0
- package/scripts/tests/test_tracked_secrets.py +42 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
- package/scripts/repository_checks/email_exemptions.py +0 -39
- package/scripts/tests/test_email_exemptions.py +0 -158
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Tests for the agent merge-readiness check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
11
|
+
if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
13
|
+
|
|
14
|
+
import agent_merge_check
|
|
15
|
+
from dev_env_scripts_constants.agent_merge_check_constants import (
|
|
16
|
+
BEHIND_HOLD_REASON,
|
|
17
|
+
BLOCKED_HOLD_REASON,
|
|
18
|
+
DIRTY_HOLD_REASON,
|
|
19
|
+
DRAFT_HOLD_REASON,
|
|
20
|
+
HOLD_VERDICT_LABEL,
|
|
21
|
+
MERGE_VERDICT_LABEL,
|
|
22
|
+
SETTLE_ATTEMPT_COUNT,
|
|
23
|
+
UNSTABLE_HOLD_REASON,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
CLEAN_PULL_REQUEST = {
|
|
27
|
+
"number": 1442,
|
|
28
|
+
"draft": False,
|
|
29
|
+
"mergeable_state": "clean",
|
|
30
|
+
"head": {"sha": "ab845eb6945650055ebe852312a9057b9f067d6a"},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pull_request(**all_overrides: object) -> dict[str, object]:
|
|
35
|
+
return {**CLEAN_PULL_REQUEST, **all_overrides}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_clean_pull_request_with_no_open_thread_may_merge() -> None:
|
|
39
|
+
assert agent_merge_check.hold_reason(CLEAN_PULL_REQUEST, 0) is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize(
|
|
43
|
+
("all_overrides", "expected_reason"),
|
|
44
|
+
[
|
|
45
|
+
({"draft": True}, DRAFT_HOLD_REASON),
|
|
46
|
+
({"mergeable_state": "behind"}, BEHIND_HOLD_REASON),
|
|
47
|
+
({"mergeable_state": "dirty"}, DIRTY_HOLD_REASON),
|
|
48
|
+
({"mergeable_state": "blocked"}, BLOCKED_HOLD_REASON),
|
|
49
|
+
({"mergeable_state": "unstable"}, UNSTABLE_HOLD_REASON),
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
def test_each_unready_state_holds_with_its_own_reason(
|
|
53
|
+
all_overrides: dict[str, object],
|
|
54
|
+
expected_reason: str,
|
|
55
|
+
) -> None:
|
|
56
|
+
assert (
|
|
57
|
+
agent_merge_check.hold_reason(_pull_request(**all_overrides), 0)
|
|
58
|
+
== expected_reason
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_a_draft_holds_even_when_its_merge_state_is_clean() -> None:
|
|
63
|
+
assert (
|
|
64
|
+
agent_merge_check.hold_reason(_pull_request(draft=True), 0) == DRAFT_HOLD_REASON
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_an_unreported_merge_state_holds_and_names_what_github_said() -> None:
|
|
69
|
+
reason = agent_merge_check.hold_reason(_pull_request(mergeable_state="unknown"), 0)
|
|
70
|
+
assert reason is not None
|
|
71
|
+
assert "unknown" in reason
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_an_open_review_thread_holds_a_green_pull_request() -> None:
|
|
75
|
+
reason = agent_merge_check.hold_reason(CLEAN_PULL_REQUEST, 2)
|
|
76
|
+
assert reason is not None
|
|
77
|
+
assert "2" in reason
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_an_open_thread_count_reaches_the_verdict_line() -> None:
|
|
81
|
+
reason = agent_merge_check.hold_reason(CLEAN_PULL_REQUEST, 1)
|
|
82
|
+
line = agent_merge_check.verdict_line(
|
|
83
|
+
"jl-cmd/claude-dev-env", CLEAN_PULL_REQUEST, reason
|
|
84
|
+
)
|
|
85
|
+
assert line.startswith(HOLD_VERDICT_LABEL)
|
|
86
|
+
assert "jl-cmd/claude-dev-env#1442" in line
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_the_ready_verdict_line_names_the_repository_and_head() -> None:
|
|
90
|
+
line = agent_merge_check.verdict_line(
|
|
91
|
+
"jl-cmd/claude-dev-env", CLEAN_PULL_REQUEST, None
|
|
92
|
+
)
|
|
93
|
+
assert line.startswith(MERGE_VERDICT_LABEL)
|
|
94
|
+
assert "jl-cmd/claude-dev-env#1442" in line
|
|
95
|
+
assert "ab845eb" in line
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_a_resolved_thread_and_an_outdated_thread_hold_nothing_back() -> None:
|
|
99
|
+
all_thread_records = [
|
|
100
|
+
{"isResolved": True, "isOutdated": False},
|
|
101
|
+
{"isResolved": False, "isOutdated": True},
|
|
102
|
+
]
|
|
103
|
+
assert agent_merge_check.count_unresolved_threads(all_thread_records) == 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_an_unresolved_current_thread_counts() -> None:
|
|
107
|
+
all_thread_records = [
|
|
108
|
+
{"isResolved": False, "isOutdated": False},
|
|
109
|
+
{"isResolved": True, "isOutdated": False},
|
|
110
|
+
{"isResolved": False, "isOutdated": False},
|
|
111
|
+
]
|
|
112
|
+
assert agent_merge_check.count_unresolved_threads(all_thread_records) == 2
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_a_thread_record_of_another_shape_counts_as_nothing() -> None:
|
|
116
|
+
assert agent_merge_check.count_unresolved_threads(["", None]) == 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_read_pull_request_returns_the_fields_the_api_answered_with(
|
|
120
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
121
|
+
) -> None:
|
|
122
|
+
monkeypatch.setattr(
|
|
123
|
+
agent_merge_check,
|
|
124
|
+
"_request_json",
|
|
125
|
+
lambda url, token, all_payload_fields: CLEAN_PULL_REQUEST,
|
|
126
|
+
)
|
|
127
|
+
assert (
|
|
128
|
+
agent_merge_check.read_pull_request("jl-cmd/claude-dev-env", 1442, "token")
|
|
129
|
+
== CLEAN_PULL_REQUEST
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_read_pull_request_rejects_an_answer_of_another_shape(
|
|
134
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
135
|
+
) -> None:
|
|
136
|
+
monkeypatch.setattr(
|
|
137
|
+
agent_merge_check,
|
|
138
|
+
"_request_json",
|
|
139
|
+
lambda url, token, all_payload_fields: ["not a pull request"],
|
|
140
|
+
)
|
|
141
|
+
with pytest.raises(agent_merge_check.MergeCheckError):
|
|
142
|
+
agent_merge_check.read_pull_request("jl-cmd/claude-dev-env", 1442, "token")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_read_unresolved_thread_count_reads_the_rest_route(
|
|
146
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
147
|
+
) -> None:
|
|
148
|
+
monkeypatch.setattr(
|
|
149
|
+
agent_merge_check,
|
|
150
|
+
"_request_json",
|
|
151
|
+
lambda url, token, all_payload_fields: [
|
|
152
|
+
{"isResolved": False, "isOutdated": False},
|
|
153
|
+
{"isResolved": True, "isOutdated": False},
|
|
154
|
+
],
|
|
155
|
+
)
|
|
156
|
+
assert (
|
|
157
|
+
agent_merge_check.read_unresolved_thread_count(
|
|
158
|
+
"jl-cmd/claude-dev-env", 1442, "token"
|
|
159
|
+
)
|
|
160
|
+
== 1
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_read_unresolved_thread_count_falls_back_to_the_query(
|
|
165
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
166
|
+
) -> None:
|
|
167
|
+
def _answer(
|
|
168
|
+
url: str,
|
|
169
|
+
token: str,
|
|
170
|
+
all_payload_fields: object,
|
|
171
|
+
) -> object:
|
|
172
|
+
if all_payload_fields is None:
|
|
173
|
+
raise agent_merge_check.MergeCheckError("HTTP Error 403: Forbidden")
|
|
174
|
+
return {
|
|
175
|
+
"data": {
|
|
176
|
+
"repository": {
|
|
177
|
+
"pullRequest": {
|
|
178
|
+
"reviewThreads": {
|
|
179
|
+
"nodes": [{"isResolved": False, "isOutdated": False}]
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
monkeypatch.setattr(agent_merge_check, "_request_json", _answer)
|
|
187
|
+
assert (
|
|
188
|
+
agent_merge_check.read_unresolved_thread_count(
|
|
189
|
+
"jl-cmd/claude-dev-env", 1442, "token"
|
|
190
|
+
)
|
|
191
|
+
== 1
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_read_unresolved_thread_count_rejects_a_query_answer_of_another_shape(
|
|
196
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
197
|
+
) -> None:
|
|
198
|
+
def _answer(
|
|
199
|
+
url: str,
|
|
200
|
+
token: str,
|
|
201
|
+
all_payload_fields: object,
|
|
202
|
+
) -> object:
|
|
203
|
+
if all_payload_fields is None:
|
|
204
|
+
raise agent_merge_check.MergeCheckError("HTTP Error 403: Forbidden")
|
|
205
|
+
return {"errors": [{"message": "Bad credentials"}]}
|
|
206
|
+
|
|
207
|
+
monkeypatch.setattr(agent_merge_check, "_request_json", _answer)
|
|
208
|
+
with pytest.raises(agent_merge_check.MergeCheckError):
|
|
209
|
+
agent_merge_check.read_unresolved_thread_count(
|
|
210
|
+
"jl-cmd/claude-dev-env", 1442, "token"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_read_settled_pull_request_reads_again_while_the_state_is_unknown(
|
|
215
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
216
|
+
) -> None:
|
|
217
|
+
all_answers = [
|
|
218
|
+
_pull_request(mergeable_state="unknown"),
|
|
219
|
+
_pull_request(mergeable_state="unknown"),
|
|
220
|
+
CLEAN_PULL_REQUEST,
|
|
221
|
+
]
|
|
222
|
+
all_waits: list[float] = []
|
|
223
|
+
monkeypatch.setattr(
|
|
224
|
+
agent_merge_check,
|
|
225
|
+
"read_pull_request",
|
|
226
|
+
lambda slug, number, token: all_answers.pop(0),
|
|
227
|
+
)
|
|
228
|
+
settled = agent_merge_check.read_settled_pull_request(
|
|
229
|
+
"jl-cmd/claude-dev-env", 1442, "token", all_waits.append
|
|
230
|
+
)
|
|
231
|
+
assert settled == CLEAN_PULL_REQUEST
|
|
232
|
+
assert len(all_waits) == 2
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_read_settled_pull_request_stops_after_its_last_attempt(
|
|
236
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
237
|
+
) -> None:
|
|
238
|
+
all_waits: list[float] = []
|
|
239
|
+
read_count = 0
|
|
240
|
+
|
|
241
|
+
def _unknown(slug: str, number: int, token: str) -> dict[str, object]:
|
|
242
|
+
nonlocal read_count
|
|
243
|
+
read_count += 1
|
|
244
|
+
return _pull_request(mergeable_state="unknown")
|
|
245
|
+
|
|
246
|
+
monkeypatch.setattr(agent_merge_check, "read_pull_request", _unknown)
|
|
247
|
+
settled = agent_merge_check.read_settled_pull_request(
|
|
248
|
+
"jl-cmd/claude-dev-env", 1442, "token", all_waits.append
|
|
249
|
+
)
|
|
250
|
+
assert settled["mergeable_state"] == "unknown"
|
|
251
|
+
assert read_count == SETTLE_ATTEMPT_COUNT
|
|
252
|
+
assert len(all_waits) == SETTLE_ATTEMPT_COUNT - 1
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_read_settled_pull_request_waits_for_no_settled_state(
|
|
256
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
257
|
+
) -> None:
|
|
258
|
+
all_waits: list[float] = []
|
|
259
|
+
monkeypatch.setattr(
|
|
260
|
+
agent_merge_check,
|
|
261
|
+
"read_pull_request",
|
|
262
|
+
lambda slug, number, token: CLEAN_PULL_REQUEST,
|
|
263
|
+
)
|
|
264
|
+
agent_merge_check.read_settled_pull_request(
|
|
265
|
+
"jl-cmd/claude-dev-env", 1442, "token", all_waits.append
|
|
266
|
+
)
|
|
267
|
+
assert all_waits == []
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_a_missing_token_reports_the_error_exit_code(
|
|
271
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
272
|
+
) -> None:
|
|
273
|
+
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
274
|
+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
275
|
+
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 2
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_a_ready_pull_request_exits_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
279
|
+
monkeypatch.setenv("GH_TOKEN", "token")
|
|
280
|
+
monkeypatch.setattr(
|
|
281
|
+
agent_merge_check,
|
|
282
|
+
"read_pull_request",
|
|
283
|
+
lambda slug, number, token: CLEAN_PULL_REQUEST,
|
|
284
|
+
)
|
|
285
|
+
monkeypatch.setattr(agent_merge_check.time, "sleep", lambda seconds: None)
|
|
286
|
+
monkeypatch.setattr(
|
|
287
|
+
agent_merge_check,
|
|
288
|
+
"read_unresolved_thread_count",
|
|
289
|
+
lambda slug, number, token: 0,
|
|
290
|
+
)
|
|
291
|
+
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_a_held_pull_request_exits_one(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
295
|
+
monkeypatch.setenv("GH_TOKEN", "token")
|
|
296
|
+
monkeypatch.setattr(
|
|
297
|
+
agent_merge_check,
|
|
298
|
+
"read_pull_request",
|
|
299
|
+
lambda slug, number, token: _pull_request(mergeable_state="behind"),
|
|
300
|
+
)
|
|
301
|
+
monkeypatch.setattr(
|
|
302
|
+
agent_merge_check,
|
|
303
|
+
"read_unresolved_thread_count",
|
|
304
|
+
lambda slug, number, token: 0,
|
|
305
|
+
)
|
|
306
|
+
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 1
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_an_unreadable_pull_request_exits_two(
|
|
310
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
311
|
+
) -> None:
|
|
312
|
+
def _fail(slug: str, number: int, token: str) -> dict[str, object]:
|
|
313
|
+
raise agent_merge_check.MergeCheckError("no route to host")
|
|
314
|
+
|
|
315
|
+
monkeypatch.setenv("GH_TOKEN", "token")
|
|
316
|
+
monkeypatch.setattr(agent_merge_check, "read_pull_request", _fail)
|
|
317
|
+
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 2
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Tests for the picker that names which Claude account a runner job uses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import claude_account_choice as choice
|
|
12
|
+
from claude_chain_usage import AccountUsageMeters, WeeklyUtilizationProbeError
|
|
13
|
+
|
|
14
|
+
NOW = datetime(2026, 9, 22, 21, 0, tzinfo=timezone.utc)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def meters(
|
|
18
|
+
*,
|
|
19
|
+
session_used: float | None,
|
|
20
|
+
weekly_used: float | None,
|
|
21
|
+
weekly_resets_in: timedelta | None,
|
|
22
|
+
session_resets_in: timedelta = timedelta(hours=2),
|
|
23
|
+
) -> AccountUsageMeters:
|
|
24
|
+
return AccountUsageMeters(
|
|
25
|
+
session_utilization=session_used,
|
|
26
|
+
session_resets_at=NOW + session_resets_in,
|
|
27
|
+
weekly_utilization=weekly_used,
|
|
28
|
+
weekly_resets_at=None if weekly_resets_in is None else NOW + weekly_resets_in,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestChooseAccount:
|
|
33
|
+
def should_pick_main_in_jons_example(self) -> None:
|
|
34
|
+
main_meters = meters(
|
|
35
|
+
session_used=10.0, weekly_used=80.0, weekly_resets_in=timedelta(hours=5)
|
|
36
|
+
)
|
|
37
|
+
second_meters = meters(
|
|
38
|
+
session_used=10.0, weekly_used=82.0, weekly_resets_in=timedelta(days=4)
|
|
39
|
+
)
|
|
40
|
+
decision = choice.choose_account(
|
|
41
|
+
main_meters=main_meters, second_meters=second_meters, now=NOW
|
|
42
|
+
)
|
|
43
|
+
assert decision.account == choice.CHOICE_MAIN
|
|
44
|
+
|
|
45
|
+
def should_pick_second_when_main_week_resets_later_than_a_day(self) -> None:
|
|
46
|
+
main_meters = meters(
|
|
47
|
+
session_used=0.0, weekly_used=5.0, weekly_resets_in=timedelta(hours=30)
|
|
48
|
+
)
|
|
49
|
+
second_meters = meters(
|
|
50
|
+
session_used=10.0, weekly_used=40.0, weekly_resets_in=timedelta(days=4)
|
|
51
|
+
)
|
|
52
|
+
decision = choice.choose_account(
|
|
53
|
+
main_meters=main_meters, second_meters=second_meters, now=NOW
|
|
54
|
+
)
|
|
55
|
+
assert decision.account == choice.CHOICE_SECOND
|
|
56
|
+
|
|
57
|
+
def should_keep_main_once_it_nears_its_weekly_ceiling(self) -> None:
|
|
58
|
+
main_meters = meters(
|
|
59
|
+
session_used=0.0, weekly_used=90.0, weekly_resets_in=timedelta(hours=3)
|
|
60
|
+
)
|
|
61
|
+
second_meters = meters(
|
|
62
|
+
session_used=0.0, weekly_used=10.0, weekly_resets_in=timedelta(days=4)
|
|
63
|
+
)
|
|
64
|
+
decision = choice.choose_account(
|
|
65
|
+
main_meters=main_meters, second_meters=second_meters, now=NOW
|
|
66
|
+
)
|
|
67
|
+
assert decision.account == choice.CHOICE_SECOND
|
|
68
|
+
|
|
69
|
+
def should_keep_main_when_its_five_hour_window_is_half_spent(self) -> None:
|
|
70
|
+
main_meters = meters(
|
|
71
|
+
session_used=50.0, weekly_used=20.0, weekly_resets_in=timedelta(hours=3)
|
|
72
|
+
)
|
|
73
|
+
second_meters = meters(
|
|
74
|
+
session_used=0.0, weekly_used=10.0, weekly_resets_in=timedelta(days=4)
|
|
75
|
+
)
|
|
76
|
+
decision = choice.choose_account(
|
|
77
|
+
main_meters=main_meters, second_meters=second_meters, now=NOW
|
|
78
|
+
)
|
|
79
|
+
assert decision.account == choice.CHOICE_SECOND
|
|
80
|
+
|
|
81
|
+
def should_never_pick_main_when_its_meter_is_unread(self) -> None:
|
|
82
|
+
second_meters = meters(
|
|
83
|
+
session_used=95.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
|
|
84
|
+
)
|
|
85
|
+
decision = choice.choose_account(
|
|
86
|
+
main_meters=None, second_meters=second_meters, now=NOW
|
|
87
|
+
)
|
|
88
|
+
assert decision.account == choice.CHOICE_WAIT
|
|
89
|
+
|
|
90
|
+
def should_never_pick_main_when_its_reset_time_is_missing(self) -> None:
|
|
91
|
+
main_meters = meters(session_used=0.0, weekly_used=0.0, weekly_resets_in=None)
|
|
92
|
+
second_meters = meters(
|
|
93
|
+
session_used=95.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
|
|
94
|
+
)
|
|
95
|
+
decision = choice.choose_account(
|
|
96
|
+
main_meters=main_meters, second_meters=second_meters, now=NOW
|
|
97
|
+
)
|
|
98
|
+
assert decision.account == choice.CHOICE_WAIT
|
|
99
|
+
|
|
100
|
+
def should_pick_second_when_its_meter_is_unread(self) -> None:
|
|
101
|
+
decision = choice.choose_account(main_meters=None, second_meters=None, now=NOW)
|
|
102
|
+
assert decision.account == choice.CHOICE_SECOND
|
|
103
|
+
assert decision.reason == choice.REASON_SECOND_UNREAD
|
|
104
|
+
|
|
105
|
+
def should_wait_and_name_the_session_reset_when_the_five_hour_window_blocks(
|
|
106
|
+
self,
|
|
107
|
+
) -> None:
|
|
108
|
+
second_meters = meters(
|
|
109
|
+
session_used=90.0,
|
|
110
|
+
weekly_used=50.0,
|
|
111
|
+
weekly_resets_in=timedelta(days=4),
|
|
112
|
+
session_resets_in=timedelta(hours=1),
|
|
113
|
+
)
|
|
114
|
+
decision = choice.choose_account(
|
|
115
|
+
main_meters=None, second_meters=second_meters, now=NOW
|
|
116
|
+
)
|
|
117
|
+
assert decision.account == choice.CHOICE_WAIT
|
|
118
|
+
assert (NOW + timedelta(hours=1)).isoformat() in decision.reason
|
|
119
|
+
|
|
120
|
+
def should_wait_and_name_the_weekly_reset_when_the_week_blocks(self) -> None:
|
|
121
|
+
second_meters = meters(
|
|
122
|
+
session_used=10.0, weekly_used=95.0, weekly_resets_in=timedelta(days=2)
|
|
123
|
+
)
|
|
124
|
+
decision = choice.choose_account(
|
|
125
|
+
main_meters=None, second_meters=second_meters, now=NOW
|
|
126
|
+
)
|
|
127
|
+
assert decision.account == choice.CHOICE_WAIT
|
|
128
|
+
assert (NOW + timedelta(days=2)).isoformat() in decision.reason
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TestReadAccountMeters:
|
|
132
|
+
def should_return_none_when_the_probe_cannot_read_the_account(
|
|
133
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
134
|
+
) -> None:
|
|
135
|
+
def failing_probe(credentials_path: Path) -> AccountUsageMeters:
|
|
136
|
+
raise WeeklyUtilizationProbeError(f"no token in {credentials_path}")
|
|
137
|
+
|
|
138
|
+
monkeypatch.setattr(choice, "probe_account_meters", failing_probe)
|
|
139
|
+
assert choice.read_account_meters(tmp_path / ".credentials.json") is None
|
|
140
|
+
|
|
141
|
+
def should_return_the_probed_meters(
|
|
142
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
143
|
+
) -> None:
|
|
144
|
+
probed = meters(
|
|
145
|
+
session_used=1.0, weekly_used=2.0, weekly_resets_in=timedelta(days=1)
|
|
146
|
+
)
|
|
147
|
+
monkeypatch.setattr(choice, "probe_account_meters", lambda credentials_path: probed)
|
|
148
|
+
assert choice.read_account_meters(tmp_path / ".credentials.json") == probed
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TestDecisionPayload:
|
|
152
|
+
def should_name_no_config_directory_for_a_wait(self) -> None:
|
|
153
|
+
decision = choice.AccountDecision(account=choice.CHOICE_WAIT, reason="full")
|
|
154
|
+
assert choice.decision_payload(decision, config_directory=None) == {
|
|
155
|
+
"account": "wait",
|
|
156
|
+
"config_dir": None,
|
|
157
|
+
"reason": "full",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestMain:
|
|
162
|
+
def should_print_the_choice_and_the_config_directory_of_the_chosen_account(
|
|
163
|
+
self,
|
|
164
|
+
tmp_path: Path,
|
|
165
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
166
|
+
capsys: pytest.CaptureFixture[str],
|
|
167
|
+
) -> None:
|
|
168
|
+
main_home = tmp_path / "main"
|
|
169
|
+
second_home = tmp_path / "second"
|
|
170
|
+
meters_by_credentials_path = {
|
|
171
|
+
main_home / ".credentials.json": None,
|
|
172
|
+
second_home / ".credentials.json": meters(
|
|
173
|
+
session_used=0.0, weekly_used=10.0, weekly_resets_in=timedelta(days=4)
|
|
174
|
+
),
|
|
175
|
+
}
|
|
176
|
+
monkeypatch.setattr(
|
|
177
|
+
choice,
|
|
178
|
+
"read_account_meters",
|
|
179
|
+
lambda credentials_path: meters_by_credentials_path[credentials_path],
|
|
180
|
+
)
|
|
181
|
+
exit_code = choice.main(
|
|
182
|
+
[
|
|
183
|
+
"--main-config-dir",
|
|
184
|
+
str(main_home),
|
|
185
|
+
"--second-config-dir",
|
|
186
|
+
str(second_home),
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
printed = json.loads(capsys.readouterr().out)
|
|
190
|
+
assert exit_code == 0
|
|
191
|
+
assert printed["account"] == choice.CHOICE_SECOND
|
|
192
|
+
assert printed["config_dir"] == str(second_home)
|
|
193
|
+
|
|
194
|
+
def should_print_no_config_directory_when_the_job_waits(
|
|
195
|
+
self,
|
|
196
|
+
tmp_path: Path,
|
|
197
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
198
|
+
capsys: pytest.CaptureFixture[str],
|
|
199
|
+
) -> None:
|
|
200
|
+
blocked_meters = meters(
|
|
201
|
+
session_used=99.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
|
|
202
|
+
)
|
|
203
|
+
monkeypatch.setattr(
|
|
204
|
+
choice, "read_account_meters", lambda credentials_path: blocked_meters
|
|
205
|
+
)
|
|
206
|
+
exit_code = choice.main(
|
|
207
|
+
[
|
|
208
|
+
"--main-config-dir",
|
|
209
|
+
str(tmp_path / "main"),
|
|
210
|
+
"--second-config-dir",
|
|
211
|
+
str(tmp_path / "second"),
|
|
212
|
+
]
|
|
213
|
+
)
|
|
214
|
+
printed = json.loads(capsys.readouterr().out)
|
|
215
|
+
assert exit_code == 0
|
|
216
|
+
assert printed["account"] == choice.CHOICE_WAIT
|
|
217
|
+
assert printed["config_dir"] is None
|