claude-dev-env 8.84.0 → 8.84.1
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/hooks/blocking/pull_request_proof.py +133 -17
- package/hooks/blocking/test_pull_request_proof.py +144 -0
- package/hooks/hooks_constants/pull_request_proof_constants.py +18 -5
- package/package.json +1 -1
- package/scripts/conftest.py +22 -13
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +12 -0
- package/scripts/test_account_broker.py +0 -6
- package/scripts/test_account_broker_support.py +6 -7
- package/scripts/test_broker_state_isolation.py +7 -8
- package/scripts/test_conftest.py +54 -0
|
@@ -21,10 +21,20 @@ from hooks_constants.pull_request_proof_constants import (
|
|
|
21
21
|
ALL_VISIBLE_FILE_SUFFIXES,
|
|
22
22
|
CHANGED_FILES_SEPARATOR,
|
|
23
23
|
COMMAND_MARKER,
|
|
24
|
+
ALL_BASE_FLAGS,
|
|
24
25
|
ALL_DEFAULT_BRANCH_REFERENCES,
|
|
25
26
|
ALL_HEAD_FLAGS,
|
|
27
|
+
ALL_REPOSITORY_FLAGS,
|
|
28
|
+
BASE_FLAG_ASSIGNMENT_PREFIX,
|
|
29
|
+
COMPARE_ENDPOINT_TEMPLATE,
|
|
30
|
+
COMPARE_FILE_LIST_LIMIT,
|
|
31
|
+
COMPARE_FILE_NAMES_QUERY,
|
|
32
|
+
DEFAULT_BRANCH_QUERY,
|
|
33
|
+
GH_TIMEOUT_SECONDS,
|
|
26
34
|
HEAD_FLAG_ASSIGNMENT_PREFIX,
|
|
27
35
|
REMOTE_BRANCH_PREFIX,
|
|
36
|
+
REPOSITORY_ENDPOINT_TEMPLATE,
|
|
37
|
+
REPOSITORY_FLAG_ASSIGNMENT_PREFIX,
|
|
28
38
|
UNREADABLE_CHANGES_REASON,
|
|
29
39
|
EVAL_HEADING_PATTERN,
|
|
30
40
|
EXISTING_WORK_HEADING_PATTERN,
|
|
@@ -174,28 +184,55 @@ def _shell_create_calls(command: str, working_directory: str) -> list[CreateCall
|
|
|
174
184
|
]
|
|
175
185
|
|
|
176
186
|
|
|
177
|
-
def
|
|
187
|
+
def _flag_value(all_create_arguments: list[str], all_flags: frozenset[str], assignment_prefix: str) -> str | None:
|
|
178
188
|
for each_position, each_argument in enumerate(all_create_arguments):
|
|
179
|
-
if each_argument in
|
|
180
|
-
return all_create_arguments[each_position + 1]
|
|
181
|
-
if each_argument.startswith(
|
|
182
|
-
return each_argument[len(
|
|
189
|
+
if each_argument in all_flags and each_position + 1 < len(all_create_arguments):
|
|
190
|
+
return unquoted_token(all_create_arguments[each_position + 1])
|
|
191
|
+
if each_argument.startswith(assignment_prefix):
|
|
192
|
+
return unquoted_token(each_argument[len(assignment_prefix) :])
|
|
183
193
|
return None
|
|
184
194
|
|
|
185
195
|
|
|
186
|
-
def
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if isinstance(head_branch, str):
|
|
190
|
-
return head_branch
|
|
196
|
+
def _shell_flag_value(
|
|
197
|
+
all_payload_fields: Mapping[str, object], all_flags: frozenset[str], assignment_prefix: str
|
|
198
|
+
) -> str | None:
|
|
191
199
|
command = _shell_command(all_payload_fields)
|
|
192
200
|
if command is None:
|
|
193
201
|
return None
|
|
194
|
-
|
|
195
|
-
|
|
202
|
+
all_flag_values = [
|
|
203
|
+
_flag_value(all_create_arguments, all_flags, assignment_prefix)
|
|
196
204
|
for all_create_arguments in _all_create_argument_lists(command)
|
|
197
205
|
]
|
|
198
|
-
return next((each_value for each_value in
|
|
206
|
+
return next((each_value for each_value in all_flag_values if each_value), None)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _named_create_field(
|
|
210
|
+
all_payload_fields: Mapping[str, object], field_name: str, all_flags: frozenset[str], assignment_prefix: str
|
|
211
|
+
) -> str | None:
|
|
212
|
+
create_fields = create_tool_fields(all_payload_fields)
|
|
213
|
+
field_value = None if create_fields is None else create_fields.get(field_name)
|
|
214
|
+
if isinstance(field_value, str) and field_value:
|
|
215
|
+
return field_value
|
|
216
|
+
return _shell_flag_value(all_payload_fields, all_flags, assignment_prefix)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
220
|
+
return _named_create_field(all_payload_fields, "head", ALL_HEAD_FLAGS, HEAD_FLAG_ASSIGNMENT_PREFIX)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _named_base_branch(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
224
|
+
return _named_create_field(all_payload_fields, "base", ALL_BASE_FLAGS, BASE_FLAG_ASSIGNMENT_PREFIX)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _named_repository(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
228
|
+
create_fields = create_tool_fields(all_payload_fields)
|
|
229
|
+
if create_fields is not None:
|
|
230
|
+
owner = create_fields.get("owner")
|
|
231
|
+
repository_name = create_fields.get("repo")
|
|
232
|
+
if isinstance(owner, str) and owner and isinstance(repository_name, str) and repository_name:
|
|
233
|
+
return f"{owner}/{repository_name}"
|
|
234
|
+
return None
|
|
235
|
+
return _shell_flag_value(all_payload_fields, ALL_REPOSITORY_FLAGS, REPOSITORY_FLAG_ASSIGNMENT_PREFIX)
|
|
199
236
|
|
|
200
237
|
|
|
201
238
|
def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
|
|
@@ -485,9 +522,83 @@ def visible_changed_files(working_directory: str, head_branch: object) -> list[s
|
|
|
485
522
|
all_changed_paths = _git_lines(working_directory, "diff", "--name-only", merge_base, head_reference)
|
|
486
523
|
if all_changed_paths is None:
|
|
487
524
|
return None
|
|
525
|
+
return _visible_paths(all_changed_paths)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _visible_paths(all_changed_paths: list[str]) -> list[str]:
|
|
488
529
|
return [each_path for each_path in all_changed_paths if each_path.lower().endswith(ALL_VISIBLE_FILE_SUFFIXES)]
|
|
489
530
|
|
|
490
531
|
|
|
532
|
+
def _gh_api_lines(endpoint: str, query: str) -> list[str] | None:
|
|
533
|
+
try:
|
|
534
|
+
completed = subprocess.run(
|
|
535
|
+
["gh", "api", endpoint, "--jq", query],
|
|
536
|
+
capture_output=True,
|
|
537
|
+
text=True,
|
|
538
|
+
timeout=GH_TIMEOUT_SECONDS,
|
|
539
|
+
check=False,
|
|
540
|
+
)
|
|
541
|
+
except (OSError, subprocess.SubprocessError):
|
|
542
|
+
return None
|
|
543
|
+
return completed.stdout.splitlines() if completed.returncode == 0 else None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _compare_base_branch(repository: str, base_branch: str | None) -> str | None:
|
|
547
|
+
if base_branch:
|
|
548
|
+
return base_branch
|
|
549
|
+
all_default_branch_lines = _gh_api_lines(
|
|
550
|
+
REPOSITORY_ENDPOINT_TEMPLATE.format(repository=repository), DEFAULT_BRANCH_QUERY
|
|
551
|
+
)
|
|
552
|
+
if not all_default_branch_lines:
|
|
553
|
+
return None
|
|
554
|
+
return all_default_branch_lines[0]
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _visible_compare_paths(repository: str, base_branch: str, head_branch: str) -> list[str] | None:
|
|
558
|
+
all_changed_paths = _gh_api_lines(
|
|
559
|
+
COMPARE_ENDPOINT_TEMPLATE.format(
|
|
560
|
+
repository=repository, base_branch=base_branch, head_branch=head_branch
|
|
561
|
+
),
|
|
562
|
+
COMPARE_FILE_NAMES_QUERY,
|
|
563
|
+
)
|
|
564
|
+
if all_changed_paths is None:
|
|
565
|
+
return None
|
|
566
|
+
all_visible_paths = _visible_paths(all_changed_paths)
|
|
567
|
+
if all_visible_paths or len(all_changed_paths) < COMPARE_FILE_LIST_LIMIT:
|
|
568
|
+
return all_visible_paths
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def remote_visible_changed_files(
|
|
573
|
+
repository: str | None, base_branch: str | None, head_branch: str | None
|
|
574
|
+
) -> list[str] | None:
|
|
575
|
+
"""Return the changed files people see, read from the GitHub compare API, or None when it cannot tell.
|
|
576
|
+
|
|
577
|
+
::
|
|
578
|
+
|
|
579
|
+
compare lists tool.py -> []
|
|
580
|
+
compare lists web/page.html -> ["web/page.html"]
|
|
581
|
+
compare returns 300 paths and none are visible -> None
|
|
582
|
+
repository or head missing -> None
|
|
583
|
+
|
|
584
|
+
The base is the named base branch, else the repository's default branch.
|
|
585
|
+
A page of 300 paths is the compare API's file cap. A visible file on that
|
|
586
|
+
page is returned. A full page with none is None, because a later file can
|
|
587
|
+
still be one people see.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
repository: The owner/name the create call names, or None.
|
|
591
|
+
base_branch: The base branch the create call names, or None.
|
|
592
|
+
head_branch: The head branch the create call names, or None.
|
|
593
|
+
"""
|
|
594
|
+
if not repository or not head_branch:
|
|
595
|
+
return None
|
|
596
|
+
resolved_base_branch = _compare_base_branch(repository, base_branch)
|
|
597
|
+
if resolved_base_branch is None:
|
|
598
|
+
return None
|
|
599
|
+
return _visible_compare_paths(repository, resolved_base_branch, head_branch)
|
|
600
|
+
|
|
601
|
+
|
|
491
602
|
def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
492
603
|
"""Return a deny reason when a pull request changing visible files shows no picture, else None.
|
|
493
604
|
|
|
@@ -496,7 +607,9 @@ def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
|
496
607
|
branch changes page.html, proof has  -> None
|
|
497
608
|
branch changes page.html, proof quotes only command output -> MISSING_LOOK_REASON
|
|
498
609
|
branch changes only .py files -> None
|
|
499
|
-
git cannot read the branch,
|
|
610
|
+
git cannot read the branch, compare API lists only .py files -> None
|
|
611
|
+
git cannot read the branch, compare API returns 300 .py files -> UNREADABLE_CHANGES_REASON
|
|
612
|
+
neither git nor the compare API can read it, no picture -> UNREADABLE_CHANGES_REASON
|
|
500
613
|
|
|
501
614
|
Args:
|
|
502
615
|
all_payload_fields: The parsed PreToolUse input.
|
|
@@ -505,9 +618,12 @@ def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
|
505
618
|
if not all_bodies:
|
|
506
619
|
return None
|
|
507
620
|
working_directory = all_payload_fields.get("cwd")
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
621
|
+
head_branch = _named_head_branch(all_payload_fields)
|
|
622
|
+
all_visible_files = visible_changed_files(str(working_directory or ""), head_branch)
|
|
623
|
+
if all_visible_files is None:
|
|
624
|
+
all_visible_files = remote_visible_changed_files(
|
|
625
|
+
_named_repository(all_payload_fields), _named_base_branch(all_payload_fields), head_branch
|
|
626
|
+
)
|
|
511
627
|
if all_visible_files == []:
|
|
512
628
|
return None
|
|
513
629
|
for each_body in all_bodies:
|
|
@@ -4,12 +4,15 @@ import subprocess
|
|
|
4
4
|
import sys
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
7
9
|
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
8
10
|
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
9
11
|
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
10
12
|
|
|
11
13
|
from blocking import pull_request_proof as gate
|
|
12
14
|
from hooks_constants.pull_request_proof_constants import (
|
|
15
|
+
COMPARE_FILE_LIST_LIMIT,
|
|
13
16
|
MISSING_BODY_REASON,
|
|
14
17
|
MISSING_EVAL_SECTION_REASON,
|
|
15
18
|
MISSING_EXISTING_WORK_REASON,
|
|
@@ -306,6 +309,147 @@ def test_should_deny_when_git_cannot_read_the_branch_and_proof_shows_no_picture(
|
|
|
306
309
|
assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
|
|
307
310
|
|
|
308
311
|
|
|
312
|
+
def _fake_gh_api(all_responses: dict[str, list[str] | None], all_endpoints: list[str]):
|
|
313
|
+
def fake_gh_api_lines(endpoint: str, query: str) -> list[str] | None:
|
|
314
|
+
all_endpoints.append(endpoint)
|
|
315
|
+
return all_responses.get(endpoint)
|
|
316
|
+
|
|
317
|
+
return fake_gh_api_lines
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _shell_create_elsewhere(tmp_path: Path, extra_flags: str) -> dict[str, object]:
|
|
321
|
+
body_path = tmp_path / "body.md"
|
|
322
|
+
body_path.write_text(PROVEN_BODY, encoding="utf-8")
|
|
323
|
+
return _shell(f"gh pr create --title t --body-file {body_path} {extra_flags}", tmp_path)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_should_allow_a_python_only_change_the_compare_api_lists_when_git_cannot_read_it(
|
|
327
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
328
|
+
) -> None:
|
|
329
|
+
all_endpoints: list[str] = []
|
|
330
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
331
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["tool.py"]}, all_endpoints))
|
|
332
|
+
payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
|
|
333
|
+
assert gate.missing_look_reason(payload) is None
|
|
334
|
+
assert all_endpoints == [compare_endpoint]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_should_deny_a_page_change_the_compare_api_lists_without_a_picture(
|
|
338
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
339
|
+
) -> None:
|
|
340
|
+
all_responses = {"repos/o/r": ["trunk"], "repos/o/r/compare/trunk...feature": ["web/page.html", "tool.py"]}
|
|
341
|
+
all_endpoints: list[str] = []
|
|
342
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api(all_responses, all_endpoints))
|
|
343
|
+
reason = gate.missing_look_reason(_shell_create_elsewhere(tmp_path, "-R o/r --head=feature"))
|
|
344
|
+
assert reason is not None
|
|
345
|
+
assert "web/page.html" in reason
|
|
346
|
+
assert "tool.py" not in reason
|
|
347
|
+
assert all_endpoints == ["repos/o/r", "repos/o/r/compare/trunk...feature"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_should_deny_as_unreadable_when_the_compare_api_fails(
|
|
351
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
352
|
+
) -> None:
|
|
353
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({}, []))
|
|
354
|
+
payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
|
|
355
|
+
reason = gate.missing_look_reason(payload)
|
|
356
|
+
assert reason == UNREADABLE_CHANGES_REASON
|
|
357
|
+
assert reason is not None
|
|
358
|
+
assert "compare API" in reason
|
|
359
|
+
assert "--repo" in reason
|
|
360
|
+
assert "--head" in reason
|
|
361
|
+
assert "origin fetched" in reason
|
|
362
|
+
assert "Artifact" in reason
|
|
363
|
+
assert "before any change to this gate" in reason
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _python_paths(count: int) -> list[str]:
|
|
367
|
+
return [f"pkg/module_{each_index}.py" for each_index in range(count)]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_should_deny_as_unreadable_when_a_full_compare_page_lists_no_visible_file(
|
|
371
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
372
|
+
) -> None:
|
|
373
|
+
all_endpoints: list[str] = []
|
|
374
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
375
|
+
monkeypatch.setattr(
|
|
376
|
+
gate,
|
|
377
|
+
"_gh_api_lines",
|
|
378
|
+
_fake_gh_api({compare_endpoint: _python_paths(COMPARE_FILE_LIST_LIMIT)}, all_endpoints),
|
|
379
|
+
)
|
|
380
|
+
assert COMPARE_FILE_LIST_LIMIT == 300
|
|
381
|
+
payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
|
|
382
|
+
assert gate.remote_visible_changed_files("o/r", "main", "feature") is None
|
|
383
|
+
assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
|
|
384
|
+
assert all_endpoints == [compare_endpoint, compare_endpoint]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_should_allow_a_short_compare_page_that_lists_only_python(
|
|
388
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
389
|
+
) -> None:
|
|
390
|
+
all_endpoints: list[str] = []
|
|
391
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
392
|
+
monkeypatch.setattr(
|
|
393
|
+
gate,
|
|
394
|
+
"_gh_api_lines",
|
|
395
|
+
_fake_gh_api({compare_endpoint: _python_paths(COMPARE_FILE_LIST_LIMIT - 1)}, all_endpoints),
|
|
396
|
+
)
|
|
397
|
+
payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
|
|
398
|
+
assert gate.remote_visible_changed_files("o/r", "main", "feature") == []
|
|
399
|
+
assert gate.missing_look_reason(payload) is None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_should_deny_a_visible_file_on_a_full_compare_page(
|
|
403
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
404
|
+
) -> None:
|
|
405
|
+
all_endpoints: list[str] = []
|
|
406
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
407
|
+
all_paths = [*_python_paths(COMPARE_FILE_LIST_LIMIT - 1), "web/page.html"]
|
|
408
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: all_paths}, all_endpoints))
|
|
409
|
+
reason = gate.missing_look_reason(_shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature"))
|
|
410
|
+
assert reason is not None
|
|
411
|
+
assert "web/page.html" in reason
|
|
412
|
+
assert "module_0.py" not in reason
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_should_allow_a_page_the_compare_api_lists_when_the_proof_links_an_artifact(
|
|
416
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
417
|
+
) -> None:
|
|
418
|
+
all_endpoints: list[str] = []
|
|
419
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
420
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["page.html"]}, all_endpoints))
|
|
421
|
+
body_path = tmp_path / "body.md"
|
|
422
|
+
body_path.write_text(PROVEN_BODY + "https://claude.ai/code/artifact/abc-123\n", encoding="utf-8")
|
|
423
|
+
payload = _shell(
|
|
424
|
+
f"gh pr create --title t --body-file {body_path} --repo o/r --base main --head feature",
|
|
425
|
+
tmp_path,
|
|
426
|
+
)
|
|
427
|
+
assert gate.missing_look_reason(payload) is None
|
|
428
|
+
assert all_endpoints == [compare_endpoint]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def test_should_skip_the_compare_api_without_a_named_repository(
|
|
432
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
433
|
+
) -> None:
|
|
434
|
+
all_endpoints: list[str] = []
|
|
435
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({}, all_endpoints))
|
|
436
|
+
payload = _shell_create_elsewhere(tmp_path, "--base main --head feature")
|
|
437
|
+
assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
|
|
438
|
+
assert all_endpoints == []
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_should_read_owner_and_repo_of_an_mcp_create_for_the_compare_api(
|
|
442
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
443
|
+
) -> None:
|
|
444
|
+
all_endpoints: list[str] = []
|
|
445
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
446
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["tool.py"]}, all_endpoints))
|
|
447
|
+
payload = _mcp_create_on_branch(PROVEN_BODY, tmp_path)
|
|
448
|
+
payload["tool_input"].update(owner="o", repo="r", base="main")
|
|
449
|
+
assert gate.missing_look_reason(payload) is None
|
|
450
|
+
assert all_endpoints == [compare_endpoint]
|
|
451
|
+
|
|
452
|
+
|
|
309
453
|
def test_visible_changed_files_falls_back_to_origin_main_without_origin_head(tmp_path: Path) -> None:
|
|
310
454
|
clone_path = _repository_with_change(tmp_path, "page.html")
|
|
311
455
|
subprocess.run(["git", "-C", str(clone_path), "remote", "set-head", "origin", "-d"], check=True)
|
|
@@ -85,7 +85,17 @@ ALL_DEFAULT_BRANCH_REFERENCES = ("origin/HEAD", "origin/main", "main")
|
|
|
85
85
|
REMOTE_BRANCH_PREFIX = "origin/"
|
|
86
86
|
ALL_HEAD_FLAGS = frozenset({"--head", "-H"})
|
|
87
87
|
HEAD_FLAG_ASSIGNMENT_PREFIX = "--head="
|
|
88
|
+
ALL_BASE_FLAGS = frozenset({"--base", "-B"})
|
|
89
|
+
BASE_FLAG_ASSIGNMENT_PREFIX = "--base="
|
|
90
|
+
ALL_REPOSITORY_FLAGS = frozenset({"--repo", "-R"})
|
|
91
|
+
REPOSITORY_FLAG_ASSIGNMENT_PREFIX = "--repo="
|
|
88
92
|
GIT_TIMEOUT_SECONDS = 5
|
|
93
|
+
GH_TIMEOUT_SECONDS = 15
|
|
94
|
+
COMPARE_ENDPOINT_TEMPLATE = "repos/{repository}/compare/{base_branch}...{head_branch}"
|
|
95
|
+
COMPARE_FILE_NAMES_QUERY = ".files[].filename"
|
|
96
|
+
COMPARE_FILE_LIST_LIMIT = 300
|
|
97
|
+
REPOSITORY_ENDPOINT_TEMPLATE = "repos/{repository}"
|
|
98
|
+
DEFAULT_BRANCH_QUERY = ".default_branch"
|
|
89
99
|
MISSING_LOOK_REASON = (
|
|
90
100
|
"This pull request changes files people see ({changed_files}), and its 'Proof in"
|
|
91
101
|
" practice' section shows no picture. Render the change, open the screenshot with the"
|
|
@@ -96,11 +106,14 @@ MISSING_LOOK_REASON = (
|
|
|
96
106
|
)
|
|
97
107
|
CHANGED_FILES_SEPARATOR = ", "
|
|
98
108
|
UNREADABLE_CHANGES_REASON = (
|
|
99
|
-
"
|
|
100
|
-
" picture check cannot tell whether people see the change.
|
|
101
|
-
"
|
|
102
|
-
"
|
|
103
|
-
"
|
|
109
|
+
"Neither git in this directory nor the GitHub compare API can list the files this"
|
|
110
|
+
" pull request changes, so the picture check cannot tell whether people see the change."
|
|
111
|
+
" Name the repository and head branch with --repo and --head, run the create call"
|
|
112
|
+
" from a checkout of the head branch with origin fetched, or put a screenshot or"
|
|
113
|
+
" Artifact link in the 'Proof in practice' section and run the create call again."
|
|
114
|
+
" Take one of these routes now, before any change to this gate. Read "
|
|
115
|
+
+ PROOF_GUIDE_PATH
|
|
116
|
+
+ " for the full rule."
|
|
104
117
|
)
|
|
105
118
|
FEATURE_TITLE_PATTERN = r"^\s*feat(\([^)]*\))?!?:"
|
|
106
119
|
EVAL_HEADING_PATTERN = r"^(#{1,6})[ \t]+evals?[ \t]*#*[ \t]*$"
|
package/package.json
CHANGED
package/scripts/conftest.py
CHANGED
|
@@ -18,6 +18,7 @@ is in place by the time any test module runs its top-level imports.
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import importlib
|
|
21
|
+
import site
|
|
21
22
|
import subprocess
|
|
22
23
|
import sys
|
|
23
24
|
from collections.abc import Callable
|
|
@@ -35,25 +36,33 @@ for each_import_directory in (
|
|
|
35
36
|
if str(each_import_directory) not in sys.path:
|
|
36
37
|
sys.path.insert(0, str(each_import_directory))
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
from dev_env_scripts_constants.account_broker_constants import ALL_ACCOUNT_ENVIRONMENT_VARIABLES
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
@pytest.fixture(autouse=True)
|
|
42
|
-
def
|
|
43
|
-
"""Point
|
|
44
|
-
|
|
45
|
-
``
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
44
|
+
"""Point the home directory at a per-test folder for every test here.
|
|
45
|
+
|
|
46
|
+
``Path.home()`` reads ``HOME`` on POSIX and ``USERPROFILE`` on Windows, so
|
|
47
|
+
redirecting both keeps call-time home lookups, such as
|
|
48
|
+
``broker_state_path``, off the user's home directory. The variables in
|
|
49
|
+
``ALL_ACCOUNT_ENVIRONMENT_VARIABLES`` are cleared, so account roots fall
|
|
50
|
+
back to the per-test home and no developer roster applies. A value built
|
|
51
|
+
from ``Path.home()`` at import time keeps the user's home.
|
|
52
|
+
``PYTHONUSERBASE`` stays on the user base captured before the redirect, so
|
|
53
|
+
a new interpreter still imports packages installed for this user.
|
|
48
54
|
|
|
49
55
|
Returns:
|
|
50
|
-
The
|
|
56
|
+
The per-test home directory.
|
|
51
57
|
"""
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
user_base = site.getuserbase()
|
|
59
|
+
isolated_home_directory = tmp_path / "home"
|
|
60
|
+
monkeypatch.setenv("PYTHONUSERBASE", user_base)
|
|
61
|
+
monkeypatch.setenv("HOME", str(isolated_home_directory))
|
|
62
|
+
monkeypatch.setenv("USERPROFILE", str(isolated_home_directory))
|
|
63
|
+
for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
|
|
64
|
+
monkeypatch.delenv(each_account_variable, raising=False)
|
|
65
|
+
return isolated_home_directory
|
|
57
66
|
|
|
58
67
|
|
|
59
68
|
def _link_directory(*, from_link: Path, to_target: Path) -> None:
|
|
@@ -16,12 +16,15 @@ from dev_env_scripts_constants.claude_account_constants import (
|
|
|
16
16
|
FULL_PERCENT,
|
|
17
17
|
MAIN_SESSION_USED_CEILING_PERCENT,
|
|
18
18
|
MAIN_WEEKLY_USED_CEILING_PERCENT,
|
|
19
|
+
PROFILES_ROOT_ENVIRONMENT_VARIABLE,
|
|
19
20
|
SECOND_SESSION_USED_CEILING_PERCENT,
|
|
20
21
|
SECOND_WEEKLY_USED_CEILING_PERCENT,
|
|
21
22
|
)
|
|
22
23
|
from dev_env_scripts_constants.codex_account_constants import (
|
|
23
24
|
ALL_PRO_PLAN_TYPES,
|
|
25
|
+
CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
|
|
24
26
|
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
27
|
+
CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
|
|
25
28
|
LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
|
|
26
29
|
LUNA_TIER_STOP_PERCENT_LEFT,
|
|
27
30
|
MAIN_CODEX_HOME_DIRECTORY_NAME,
|
|
@@ -30,6 +33,7 @@ from dev_env_scripts_constants.codex_account_constants import (
|
|
|
30
33
|
TIER_NORMAL,
|
|
31
34
|
)
|
|
32
35
|
from dev_env_scripts_constants.shared_tree_constants import (
|
|
36
|
+
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
33
37
|
CODEX_CLASSIFIER_CONSTANTS_RELATIVE_PATH,
|
|
34
38
|
CONSTANTS_PACKAGE_ANCHOR_DEPTH,
|
|
35
39
|
PR_LOOP_DIRECTORY_NAME,
|
|
@@ -64,6 +68,14 @@ BROKER_STATE_DIRECTORY_NAME = "account-broker"
|
|
|
64
68
|
BROKER_STATE_FILE_NAME = "state.json"
|
|
65
69
|
BROKER_STATE_TEMP_SUFFIX = ".json"
|
|
66
70
|
BROKER_STATE_LOCK_SUFFIX = ".lock"
|
|
71
|
+
ALL_ACCOUNT_ENVIRONMENT_VARIABLES = (
|
|
72
|
+
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
73
|
+
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
74
|
+
CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
|
|
75
|
+
CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
|
|
76
|
+
PROFILES_ROOT_ENVIRONMENT_VARIABLE,
|
|
77
|
+
)
|
|
78
|
+
"""Variables that point the account tools at a developer's own account roots, Codex home and Codex roster."""
|
|
67
79
|
ALL_BATCH_FILE_EXTENSIONS = frozenset({".bat", ".cmd"})
|
|
68
80
|
CMD_SHELL_METACHARACTERS = "&|<>^%!\"\r\n"
|
|
69
81
|
CLAUDE_BINARY_NAME = "claude"
|
|
@@ -15,7 +15,9 @@ from pathlib import Path
|
|
|
15
15
|
import pytest
|
|
16
16
|
|
|
17
17
|
from dev_env_scripts_constants import account_broker_constants as broker_constants
|
|
18
|
+
from dev_env_scripts_constants import claude_account_constants, codex_account_constants, shared_tree_constants
|
|
18
19
|
from dev_env_scripts_constants.account_broker_constants import (
|
|
20
|
+
ALL_ACCOUNT_ENVIRONMENT_VARIABLES,
|
|
19
21
|
ALL_BATCH_FILE_EXTENSIONS,
|
|
20
22
|
ALL_CLAUDE_FLOORS,
|
|
21
23
|
ALL_CODEX_FLOORS,
|
|
@@ -57,6 +59,16 @@ def test_should_keep_codex_floors_tied_to_the_tier_limits() -> None:
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
|
|
62
|
+
def test_should_list_every_account_environment_variable() -> None:
|
|
63
|
+
all_declared_variables = {
|
|
64
|
+
each_value
|
|
65
|
+
for each_module in (claude_account_constants, codex_account_constants, shared_tree_constants)
|
|
66
|
+
for each_name, each_value in vars(each_module).items()
|
|
67
|
+
if each_name.endswith(("_ENVIRONMENT_VARIABLE", "_ENV_VAR"))
|
|
68
|
+
}
|
|
69
|
+
assert all_declared_variables == set(ALL_ACCOUNT_ENVIRONMENT_VARIABLES)
|
|
70
|
+
|
|
71
|
+
|
|
60
72
|
def test_batch_command_boundaries_include_shell_metacharacters() -> None:
|
|
61
73
|
assert ALL_BATCH_FILE_EXTENSIONS == frozenset({".bat", ".cmd"})
|
|
62
74
|
assert set(CMD_SHELL_METACHARACTERS) == set('&|<>^%!"\r\n')
|
|
@@ -42,12 +42,6 @@ def _freeze_clock(monkeypatch: pytest.MonkeyPatch, instant: datetime) -> None:
|
|
|
42
42
|
monkeypatch.setattr(account_broker, "datetime", _FrozenClock)
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
@pytest.fixture(autouse=True)
|
|
46
|
-
def isolated_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
47
|
-
monkeypatch.setattr(account_broker, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
48
|
-
monkeypatch.setattr(account_broker.support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
49
|
-
|
|
50
|
-
|
|
51
45
|
def _account(name: str, product: Product = Product.CODEX, main: bool = False) -> Account:
|
|
52
46
|
return Account(product, name, Path("/profiles") / name, main, name)
|
|
53
47
|
|
|
@@ -256,8 +256,7 @@ def _recorded_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
|
|
256
256
|
return all_delays
|
|
257
257
|
|
|
258
258
|
|
|
259
|
-
def _first_reading(
|
|
260
|
-
monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
259
|
+
def _first_reading(account: Account, read_meters: object) -> object:
|
|
261
260
|
adapter = ProductAdapter(lambda: (account,), read_meters, "HOME", ())
|
|
262
261
|
state = support._load_state(support.broker_state_path())
|
|
263
262
|
now = datetime(2026, 10, 3, tzinfo=timezone.utc)
|
|
@@ -272,7 +271,7 @@ def test_should_read_claude_meters_after_transient_failures_with_growing_delays(
|
|
|
272
271
|
all_paths_read = _claude_meter_reads(monkeypatch, [unread, unread, usage])
|
|
273
272
|
all_delays = _recorded_sleeps(monkeypatch)
|
|
274
273
|
|
|
275
|
-
reading = _first_reading(
|
|
274
|
+
reading = _first_reading(Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)[0]
|
|
276
275
|
|
|
277
276
|
assert reading.meters is not None
|
|
278
277
|
assert reading.meters.weekly_percent_left == 70.0
|
|
@@ -289,7 +288,7 @@ def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
|
|
|
289
288
|
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, unread, usage])
|
|
290
289
|
all_delays = _recorded_sleeps(monkeypatch)
|
|
291
290
|
|
|
292
|
-
reading = _first_reading(
|
|
291
|
+
reading = _first_reading(Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
|
|
293
292
|
|
|
294
293
|
assert reading.meters is not None
|
|
295
294
|
assert reading.meters.weekly_percent_left == 70.0
|
|
@@ -307,7 +306,7 @@ def test_should_stop_at_once_when_no_codex_executable_is_found(
|
|
|
307
306
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
308
307
|
all_delays = _recorded_sleeps(monkeypatch)
|
|
309
308
|
|
|
310
|
-
reading = _first_reading(
|
|
309
|
+
reading = _first_reading(Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
|
|
311
310
|
|
|
312
311
|
assert reading.meters is None
|
|
313
312
|
assert reading.unread_reason == "no codex executable on PATH or at its install path"
|
|
@@ -322,7 +321,7 @@ def test_should_keep_the_unread_reason_after_every_claude_read_fails(
|
|
|
322
321
|
all_paths_read = _claude_meter_reads(monkeypatch, all_failures)
|
|
323
322
|
all_delays = _recorded_sleeps(monkeypatch)
|
|
324
323
|
|
|
325
|
-
reading, adapter, state, now = _first_reading(
|
|
324
|
+
reading, adapter, state, now = _first_reading(Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)
|
|
326
325
|
cached_reading = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=now)[0]
|
|
327
326
|
|
|
328
327
|
assert reading.meters is None
|
|
@@ -340,7 +339,7 @@ def test_should_keep_the_unread_reason_after_every_codex_read_fails(
|
|
|
340
339
|
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [first_unread, first_unread, last_unread])
|
|
341
340
|
_recorded_sleeps(monkeypatch)
|
|
342
341
|
|
|
343
|
-
reading, adapter, state, now = _first_reading(
|
|
342
|
+
reading, adapter, state, now = _first_reading(Account(Product.CODEX, "codex-3", tmp_path), support.read_codex_account_meters)
|
|
344
343
|
cached_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
|
|
345
344
|
|
|
346
345
|
assert reading.meters is None
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Broker state written during a test
|
|
1
|
+
"""Broker state written during a test lands in the per-test home directory."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -18,14 +18,11 @@ from dev_env_scripts_constants.account_broker_constants import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def
|
|
21
|
+
def test_meter_cache_write_lands_in_the_isolated_home(
|
|
22
22
|
monkeypatch: pytest.MonkeyPatch,
|
|
23
23
|
tmp_path: Path,
|
|
24
|
-
|
|
24
|
+
isolated_home: Path,
|
|
25
25
|
) -> None:
|
|
26
|
-
fake_home = tmp_path / "home"
|
|
27
|
-
monkeypatch.setenv("HOME", str(fake_home))
|
|
28
|
-
monkeypatch.setenv("USERPROFILE", str(fake_home))
|
|
29
26
|
account = Account(Product.CLAUDE, "only", tmp_path / "only")
|
|
30
27
|
reset_at = datetime(2026, 10, 5, tzinfo=timezone.utc)
|
|
31
28
|
adapter = ProductAdapter(
|
|
@@ -39,10 +36,12 @@ def test_meter_cache_write_lands_outside_the_home_directory(
|
|
|
39
36
|
def runner(argv: list[str], **options: object) -> subprocess.CompletedProcess[str]:
|
|
40
37
|
return subprocess.CompletedProcess(argv, 0, "", "")
|
|
41
38
|
|
|
39
|
+
isolated_broker_state_path = account_broker.broker_state_path()
|
|
40
|
+
assert Path.home() == isolated_home
|
|
41
|
+
assert isolated_broker_state_path.is_relative_to(isolated_home)
|
|
42
|
+
assert not isolated_broker_state_path.exists()
|
|
42
43
|
with account_broker.override_subprocess_runner(runner):
|
|
43
44
|
account_broker.run_job(Product.CLAUDE, ["claude"])
|
|
44
45
|
|
|
45
|
-
home_state_path = fake_home / ".claude" / "account-broker" / "state.json"
|
|
46
|
-
assert not home_state_path.exists()
|
|
47
46
|
state = json.loads(isolated_broker_state_path.read_text(encoding="utf-8"))
|
|
48
47
|
assert f"claude:only:{account.home}" in state["meters"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import account_broker_support
|
|
10
|
+
from dev_env_scripts_constants.account_broker_constants import ALL_ACCOUNT_ENVIRONMENT_VARIABLES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(scope="module")
|
|
14
|
+
def exported_account_variables(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]:
|
|
15
|
+
exported_root = tmp_path_factory.mktemp("exported-overrides")
|
|
16
|
+
with pytest.MonkeyPatch.context() as module_monkeypatch:
|
|
17
|
+
for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
|
|
18
|
+
module_monkeypatch.setenv(each_account_variable, str(exported_root / each_account_variable))
|
|
19
|
+
yield
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_broker_state_path_follows_the_redirected_home(
|
|
23
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
24
|
+
) -> None:
|
|
25
|
+
assert Path.home() == isolated_home
|
|
26
|
+
assert account_broker_support.broker_state_path().is_relative_to(isolated_home)
|
|
27
|
+
moved_home = tmp_path / "moved-home"
|
|
28
|
+
monkeypatch.setenv("HOME", str(moved_home))
|
|
29
|
+
monkeypatch.setenv("USERPROFILE", str(moved_home))
|
|
30
|
+
assert account_broker_support.broker_state_path().is_relative_to(moved_home)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_isolated_home_clears_the_exported_account_variables(
|
|
34
|
+
exported_account_variables: None, isolated_home: Path
|
|
35
|
+
) -> None:
|
|
36
|
+
for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
|
|
37
|
+
assert each_account_variable not in os.environ
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_isolated_home_keeps_the_python_user_base_for_a_new_interpreter(
|
|
41
|
+
isolated_home: Path,
|
|
42
|
+
) -> None:
|
|
43
|
+
completed = subprocess.run(
|
|
44
|
+
[sys.executable, "-c", "import site; print(site.getuserbase())"],
|
|
45
|
+
check=False,
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
assert completed.returncode == 0
|
|
51
|
+
child_user_base = Path(completed.stdout.strip())
|
|
52
|
+
assert str(child_user_base) == os.environ["PYTHONUSERBASE"]
|
|
53
|
+
assert child_user_base != isolated_home
|
|
54
|
+
assert isolated_home not in child_user_base.parents
|