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.
Files changed (119) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/.agents/skills/team-advisor/SKILL.md +2 -2
  3. package/_shared/advisor/reference/identity.md +1 -1
  4. package/_shared/advisor/reference/spawn-walk-log.md +1 -1
  5. package/_shared/advisor/reference/warm-up.md +3 -1
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -5
  8. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +85 -6
  9. package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
  10. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
  11. package/bin/install.test.mjs +25 -0
  12. package/docs/high-trust-agent-delivery.md +132 -0
  13. package/docs/references/advisor-tool.md +3 -2
  14. package/docs/second-claude-account.md +50 -0
  15. package/hooks/advisory/refactor_guard.py +2 -2
  16. package/hooks/advisory/test_refactor_guard.py +21 -0
  17. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  18. package/hooks/blocking/code_rules_shared.py +4 -7
  19. package/hooks/blocking/conftest.py +22 -0
  20. package/hooks/blocking/state_description_blocker.py +10 -8
  21. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  22. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
  23. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  24. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  25. package/hooks/blocking/test_code_rules_shared.py +18 -0
  26. package/hooks/blocking/test_conftest.py +35 -0
  27. package/hooks/blocking/test_pii_scanner.py +8 -0
  28. package/hooks/blocking/test_state_description_blocker.py +16 -0
  29. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  30. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  31. package/hooks/git-hooks/test_config.py +10 -3
  32. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  33. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  34. package/hooks/git-hooks/verification_notice_state.py +3 -1
  35. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  36. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  37. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  38. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  39. package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
  40. package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
  41. package/hooks/validators/exempt_paths.py +3 -18
  42. package/hooks/validators/test_exempt_paths.py +20 -0
  43. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  44. package/package.json +1 -1
  45. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  46. package/rules/archiving-agent-config.md +10 -0
  47. package/rules/code-standards.md +1 -0
  48. package/rules/correction-lens.md +134 -0
  49. package/rules/flag-non-breaking-findings.md +11 -0
  50. package/rules/long-horizon-autonomy.md +11 -0
  51. package/rules/no-contrast-framing.md +68 -0
  52. package/rules/review-closure-is-a-check.md +44 -0
  53. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  54. package/scripts/agent_merge_check.py +381 -0
  55. package/scripts/check.ps1 +12 -1
  56. package/scripts/ci/install-playtest.mjs +291 -0
  57. package/scripts/ci/install-playtest.test.mjs +84 -0
  58. package/scripts/ci/installer-lifecycle.mjs +11 -30
  59. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  60. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  61. package/scripts/ci/scratch-home-install.mjs +73 -0
  62. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  63. package/scripts/claude_account_choice.py +246 -0
  64. package/scripts/claude_account_profile.py +371 -0
  65. package/scripts/claude_chain_usage.py +49 -5
  66. package/scripts/codex_compat_materializer.py +33 -16
  67. package/scripts/contrast_framing.py +101 -0
  68. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  69. package/scripts/dev_env_scripts_constants/claude_account_constants.py +166 -0
  70. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  71. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  72. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  73. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +21 -0
  74. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  75. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  76. package/scripts/durable_post_lint.py +30 -4
  77. package/scripts/followup_cli.py +6 -10
  78. package/scripts/merge_gate_checks.py +236 -0
  79. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  80. package/scripts/policy_lint/adapters.py +30 -0
  81. package/scripts/policy_lint/config/constants.py +2 -0
  82. package/scripts/policy_lint/registry.py +5 -0
  83. package/scripts/repository_checks/config/constants.py +12 -0
  84. package/scripts/repository_checks/config/policy_document.py +28 -2
  85. package/scripts/repository_checks/followups.py +54 -0
  86. package/scripts/repository_checks/match_exemptions.py +67 -0
  87. package/scripts/repository_checks/models.py +46 -2
  88. package/scripts/repository_checks/reporting.py +23 -7
  89. package/scripts/repository_checks/tracked_secrets.py +2 -2
  90. package/scripts/repository_policy.py +4 -2
  91. package/scripts/review_closure.py +134 -0
  92. package/scripts/review_closure_github.py +345 -0
  93. package/scripts/review_closure_model.py +360 -0
  94. package/scripts/test_agent_merge_check.py +317 -0
  95. package/scripts/test_claude_account_choice.py +217 -0
  96. package/scripts/test_claude_account_profile.py +220 -0
  97. package/scripts/test_claude_chain_usage.py +40 -0
  98. package/scripts/test_durable_post_lint.py +46 -0
  99. package/scripts/test_followup_cli.py +40 -0
  100. package/scripts/test_merge_gate_checks.py +92 -0
  101. package/scripts/tests/test_code_review_constants.py +78 -0
  102. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  103. package/scripts/tests/test_contrast_framing.py +117 -0
  104. package/scripts/tests/test_followups.py +88 -0
  105. package/scripts/tests/test_match_exemptions.py +291 -0
  106. package/scripts/tests/test_models.py +67 -2
  107. package/scripts/tests/test_package_inventory.py +6 -4
  108. package/scripts/tests/test_pester_suites_run.py +53 -0
  109. package/scripts/tests/test_policy_document.py +39 -1
  110. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  111. package/scripts/tests/test_reporting.py +18 -0
  112. package/scripts/tests/test_repository_policy.py +43 -1
  113. package/scripts/tests/test_review_closure.py +126 -0
  114. package/scripts/tests/test_review_closure_github.py +240 -0
  115. package/scripts/tests/test_review_closure_model.py +299 -0
  116. package/scripts/tests/test_tracked_secrets.py +42 -0
  117. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  118. package/scripts/repository_checks/email_exemptions.py +0 -39
  119. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -7,6 +7,13 @@ import json
7
7
  from pathlib import Path
8
8
 
9
9
  import pytest
10
+ from repository_checks.config.policy_document import (
11
+ ALL_DOCUMENT_FIELDS,
12
+ ALL_EXEMPTION_FIELDS,
13
+ ALL_MATCH_EXEMPTION_FAMILIES,
14
+ PATH_EXEMPTIONS_FIELD,
15
+ VERSION_FIELD,
16
+ )
10
17
  from repository_checks.policy_document import (
11
18
  entry_path_and_digest,
12
19
  read_policy_entries,
@@ -92,7 +99,7 @@ def test_entry_validator_returns_the_path_and_digest_without_the_reason() -> Non
92
99
  def test_entry_validator_names_the_subject_in_every_rejection(
93
100
  entry: object, expected_message: str
94
101
  ) -> None:
95
- """One validator serves both families, so each message names its subject."""
102
+ """One validator serves every exemption subject, so each message names its own."""
96
103
  with pytest.raises(ValueError) as rejection:
97
104
  entry_path_and_digest(entry, "Path exemption")
98
105
  assert str(rejection.value) == expected_message
@@ -102,3 +109,34 @@ def test_entry_validator_uses_the_subject_it_is_given() -> None:
102
109
  with pytest.raises(ValueError) as rejection:
103
110
  entry_path_and_digest({}, "Email exemption")
104
111
  assert str(rejection.value) == "Email exemptions require path, sha256, and reason"
112
+
113
+
114
+ def test_every_match_family_reads_a_field_the_document_schema_accepts(
115
+ tmp_path: Path,
116
+ ) -> None:
117
+ for each_family in ALL_MATCH_EXEMPTION_FAMILIES:
118
+ _write_config(tmp_path, {"version": 1, each_family.field_name: []})
119
+
120
+ assert each_family.field_name in ALL_EXEMPTION_FIELDS
121
+ assert read_policy_entries(tmp_path, each_family.field_name) == []
122
+
123
+
124
+ def test_each_match_family_names_its_own_field_category_and_subject() -> None:
125
+ all_fields = {each.field_name for each in ALL_MATCH_EXEMPTION_FAMILIES}
126
+ all_categories = {each.category for each in ALL_MATCH_EXEMPTION_FAMILIES}
127
+ all_subjects = {each.subject for each in ALL_MATCH_EXEMPTION_FAMILIES}
128
+
129
+ assert len(all_fields) == len(ALL_MATCH_EXEMPTION_FAMILIES)
130
+ assert len(all_categories) == len(ALL_MATCH_EXEMPTION_FAMILIES)
131
+ assert len(all_subjects) == len(ALL_MATCH_EXEMPTION_FAMILIES)
132
+
133
+
134
+ def test_all_exemption_fields_is_derived_from_the_match_families_and_path_field() -> (
135
+ None
136
+ ):
137
+ expected_exemption_fields = {
138
+ each_family.field_name for each_family in ALL_MATCH_EXEMPTION_FAMILIES
139
+ } | {PATH_EXEMPTIONS_FIELD}
140
+
141
+ assert ALL_EXEMPTION_FIELDS == expected_exemption_fields
142
+ assert ALL_DOCUMENT_FIELDS == expected_exemption_fields | {VERSION_FIELD}
@@ -0,0 +1,68 @@
1
+ """The contrast-framing rule reaches authored Markdown through the registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path, PurePosixPath
6
+
7
+ from policy_lint import adapter_contrast_framing, registry
8
+ from policy_lint.model import ContentOrigin, Document, DocumentRule
9
+
10
+ _RULE_ID = "contrast-framing"
11
+ _DOCUMENT_PATH = "packages/claude-dev-env/rules/example.md"
12
+
13
+
14
+ def _document(prose: str, path: str = _DOCUMENT_PATH) -> Document:
15
+ return Document(
16
+ PurePosixPath(path),
17
+ prose,
18
+ None,
19
+ None,
20
+ ContentOrigin.WORKTREE,
21
+ )
22
+
23
+
24
+ def _messages_for(prose: str, repository_root: Path) -> tuple[str, ...]:
25
+ all_diagnostics = adapter_contrast_framing.contrast_framing_diagnostics(
26
+ _document(prose), repository_root
27
+ )
28
+ return tuple(each_diagnostic.message for each_diagnostic in all_diagnostics)
29
+
30
+
31
+ def _registered_rule() -> DocumentRule:
32
+ for each_rule in registry.default_registry():
33
+ if each_rule.rule_id == _RULE_ID and isinstance(each_rule, DocumentRule):
34
+ return each_rule
35
+ raise AssertionError(_RULE_ID)
36
+
37
+
38
+ def test_the_rule_is_registered_for_changed_and_repository_selections() -> None:
39
+ contrast_rule = _registered_rule()
40
+
41
+ assert contrast_rule.rule_sets == frozenset({"changed", "repository"})
42
+
43
+
44
+ def test_a_contrast_sentence_reports_with_its_location(tmp_path: Path) -> None:
45
+ all_diagnostics = adapter_contrast_framing.contrast_framing_diagnostics(
46
+ _document("Heading line.\nOne answer, not two.\n"), tmp_path
47
+ )
48
+
49
+ assert len(all_diagnostics) == 1
50
+ assert all_diagnostics[0].rule_id == _RULE_ID
51
+ assert all_diagnostics[0].location.start_line == 2
52
+ assert "trailing-comma-not" in all_diagnostics[0].message
53
+
54
+
55
+ def test_a_statement_of_what_is_passes(tmp_path: Path) -> None:
56
+ assert _messages_for("The check names the failing line.\n", tmp_path) == ()
57
+
58
+
59
+ def test_the_rule_accepts_authored_markdown_and_skips_the_changelog() -> None:
60
+ contrast_rule = _registered_rule()
61
+
62
+ assert contrast_rule.accepts(_document("text\n"))
63
+ assert not contrast_rule.accepts(
64
+ _document("text\n", "packages/claude-dev-env/CHANGELOG.md")
65
+ )
66
+ assert not contrast_rule.accepts(
67
+ _document("text\n", "packages/claude-dev-env/scripts/tool.py")
68
+ )
@@ -12,6 +12,7 @@ if str(_SCRIPTS_DIRECTORY) not in sys.path:
12
12
  if str(_TESTS_DIRECTORY) not in sys.path:
13
13
  sys.path.insert(0, str(_TESTS_DIRECTORY))
14
14
 
15
+ from repository_checks.config.constants import CHECK_ID_PACKAGE_INVENTORY
15
16
  from repository_checks.models import RepositoryCheckReport, RepositoryFinding
16
17
  from repository_checks.reporting import render_report
17
18
  from repository_policy_test_support import (
@@ -52,3 +53,20 @@ def test_should_print_repository_relative_paths_only(tmp_path: Path) -> None:
52
53
  assert absolute_root_text not in stdout_text
53
54
  assert absolute_root_text not in stderr_text
54
55
  assert "notes/CLAUDE.md" in stdout_text
56
+
57
+
58
+ def test_should_prefix_an_advisory_finding_line() -> None:
59
+ report = RepositoryCheckReport(
60
+ (
61
+ RepositoryFinding(
62
+ CHECK_ID_PACKAGE_INVENTORY,
63
+ "pipeline/check_dialer_seam_cli.py",
64
+ "production file is absent from package inventory",
65
+ ),
66
+ ),
67
+ (),
68
+ )
69
+ assert render_report(report) == (
70
+ "advisory: package-inventory: pipeline/check_dialer_seam_cli.py: "
71
+ "production file is absent from package inventory\n"
72
+ )
@@ -20,8 +20,17 @@ from repository_checks.config.constants import (
20
20
  CHECK_ID_PACKAGE_INVENTORY,
21
21
  CHECK_ID_PYTEST_TESTPATHS,
22
22
  CHECK_ID_TRACKED_PERSONAL_DATA,
23
+ FOLLOWUP_LEDGER_MODULE_NAME,
24
+ SUCCESS_EXIT_CODE,
25
+ )
26
+ from repository_checks.hook_modules import load_hooks_module
27
+ from repository_policy_test_support import (
28
+ commit_tracked_files,
29
+ initialize_repository,
30
+ run_policy,
31
+ seed_clean_repository,
32
+ write_text,
23
33
  )
24
- from repository_policy_test_support import run_policy, seed_clean_repository
25
34
 
26
35
  _POLICY_SCRIPT_PATH = _SCRIPTS_DIRECTORY / "repository_policy.py"
27
36
  _CHECK_SCRIPT_PATH = _SCRIPTS_DIRECTORY / "check.ps1"
@@ -69,3 +78,36 @@ def test_should_run_the_checker_as_a_subprocess(tmp_path: Path) -> None:
69
78
  )
70
79
  assert completed.returncode == 0
71
80
  assert completed.stdout == ""
81
+
82
+
83
+ def test_should_record_a_stale_inventory_and_leave_the_tree_passing(
84
+ tmp_path: Path,
85
+ ) -> None:
86
+ repository_root = tmp_path / "repo"
87
+ initialize_repository(repository_root)
88
+ package_directory = repository_root / "pipeline"
89
+ write_text(
90
+ package_directory / "README.md",
91
+ "# Pipeline\n\n"
92
+ "| Path | Role |\n"
93
+ "|---|---|\n"
94
+ "| `dialer_compose.py` | Composes a dialer strip. |\n"
95
+ "| `compose_dialer_cli.py` | CLI for the dialer strip. |\n",
96
+ )
97
+ write_text(package_directory / "dialer_compose.py", "x = 1\n")
98
+ write_text(package_directory / "compose_dialer_cli.py", "x = 1\n")
99
+ write_text(package_directory / "check_dialer_seam_cli.py", "x = 1\n")
100
+ commit_tracked_files(repository_root)
101
+
102
+ exit_code, stdout_text, _stderr_text = run_policy(repository_root)
103
+
104
+ assert exit_code == SUCCESS_EXIT_CODE
105
+ assert stdout_text == (
106
+ "advisory: package-inventory: pipeline/check_dialer_seam_cli.py: "
107
+ "production file is absent from package inventory\n"
108
+ )
109
+ ledger = load_hooks_module(FOLLOWUP_LEDGER_MODULE_NAME)
110
+ all_recorded = ledger.all_recorded_findings(repository_root)
111
+ assert [each_finding.message for each_finding in all_recorded] == [
112
+ "production file is absent from package inventory"
113
+ ]
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
9
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
10
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
11
+
12
+ import review_closure as command
13
+ import review_closure_model as model
14
+ from pr_verification.github_parsing import GitHubError
15
+
16
+ SLUG = "jl-cmd/claude-dev-env"
17
+ PULL_REQUEST = {
18
+ "number": 7,
19
+ "head": {"sha": "ab845eb"},
20
+ "user": {"login": "claude[bot]"},
21
+ }
22
+
23
+
24
+ def bot_comment(body: str = "This call drops the return value.") -> model.ReviewComment:
25
+ return model.ReviewComment(author_login="qodo-merge-pro[bot]", body=body)
26
+
27
+
28
+ def agent_comment() -> model.ReviewComment:
29
+ return model.ReviewComment(author_login="claude[bot]", body="Fixed and pushed.")
30
+
31
+
32
+ def unanswered_thread() -> model.ReviewThread:
33
+ return model.ReviewThread(
34
+ subject="scripts/example.py",
35
+ is_resolved=False,
36
+ is_outdated=False,
37
+ all_comments=(bot_comment(),),
38
+ )
39
+
40
+
41
+ def answered_thread() -> model.ReviewThread:
42
+ return model.ReviewThread(
43
+ subject="scripts/example.py",
44
+ is_resolved=False,
45
+ is_outdated=False,
46
+ all_comments=(bot_comment(), agent_comment()),
47
+ )
48
+
49
+
50
+ def stub_reads(
51
+ monkeypatch: pytest.MonkeyPatch,
52
+ all_threads: tuple[model.ReviewThread, ...],
53
+ conclusion: str | None = "success",
54
+ ) -> None:
55
+ monkeypatch.setattr(command, "github_token", lambda: "a-token")
56
+ monkeypatch.setattr(command, "read_pull_request", lambda *_: PULL_REQUEST)
57
+ monkeypatch.setattr(command, "read_review_threads", lambda *_: all_threads)
58
+ monkeypatch.setattr(command, "read_approvals_conclusion", lambda *_: conclusion)
59
+
60
+
61
+ def should_build_a_parser_that_collects_extra_driver_logins() -> None:
62
+ parsed = command.build_parser().parse_args(
63
+ [SLUG, "7", "--driver-login", "JonEcho", "--driver-login", "claude[bot]"]
64
+ )
65
+
66
+ assert parsed.slug == SLUG
67
+ assert parsed.number == 7
68
+ assert parsed.driver_login == ["JonEcho", "claude[bot]"]
69
+
70
+
71
+ def should_report_an_unanswered_finding(monkeypatch: pytest.MonkeyPatch) -> None:
72
+ stub_reads(monkeypatch, (unanswered_thread(),))
73
+
74
+ report = command.closure_report(SLUG, 7, [], "a-token")
75
+
76
+ assert report.verdict.startswith("OPEN")
77
+ assert [each.subject for each in report.all_findings] == ["scripts/example.py"]
78
+
79
+
80
+ def should_report_an_answered_pull_request_as_closed(
81
+ monkeypatch: pytest.MonkeyPatch,
82
+ ) -> None:
83
+ stub_reads(monkeypatch, (answered_thread(),))
84
+
85
+ report = command.closure_report(SLUG, 7, [], "a-token")
86
+
87
+ assert report.verdict.startswith("CLOSED")
88
+ assert report.all_findings == ()
89
+
90
+
91
+ def should_exit_one_while_a_finding_waits(
92
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
93
+ ) -> None:
94
+ stub_reads(monkeypatch, (unanswered_thread(),))
95
+
96
+ status = command.main([SLUG, "7"])
97
+
98
+ assert status == 1
99
+ assert "scripts/example.py" in capsys.readouterr().out
100
+
101
+
102
+ def should_exit_zero_once_the_finding_is_answered(
103
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
104
+ ) -> None:
105
+ stub_reads(monkeypatch, (answered_thread(),))
106
+
107
+ status = command.main([SLUG, "7"])
108
+
109
+ assert status == 0
110
+ assert capsys.readouterr().out.startswith("CLOSED")
111
+
112
+
113
+ def should_exit_two_when_the_state_could_not_be_read(
114
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
115
+ ) -> None:
116
+ monkeypatch.setattr(command, "github_token", lambda: "a-token")
117
+
118
+ def refuse(*_: object) -> None:
119
+ raise GitHubError("the proxy refused this read")
120
+
121
+ monkeypatch.setattr(command, "read_pull_request", refuse)
122
+
123
+ status = command.main([SLUG, "7"])
124
+
125
+ assert status == 2
126
+ assert "refused" in capsys.readouterr().err
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
13
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
14
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
15
+
16
+ import review_closure_github as reader
17
+ from dev_env_scripts_constants.review_closure_constants import APPROVALS_CHECK_NAME
18
+ from pr_verification.github_parsing import GitHubError
19
+
20
+ TOKEN = os.environ.get("REVIEW_CLOSURE_TEST_TOKEN", "test-value")
21
+
22
+
23
+ class FakeReply:
24
+ def __init__(self, status: int, document: object) -> None:
25
+ self.status = status
26
+ self._body = json.dumps(document).encode("utf-8")
27
+
28
+ def read(self) -> bytes:
29
+ return self._body
30
+
31
+
32
+ def answer_with(
33
+ monkeypatch: pytest.MonkeyPatch,
34
+ all_answers_by_url_fragment: dict[str, tuple[int, object]],
35
+ ) -> list[str]:
36
+ all_requested_urls: list[str] = []
37
+
38
+ @contextmanager
39
+ def fake_urlopen(request: object, timeout: int = 0) -> Iterator[FakeReply]:
40
+ url = request.full_url
41
+ all_requested_urls.append(url)
42
+ for each_fragment, (status, document) in all_answers_by_url_fragment.items():
43
+ if each_fragment in url:
44
+ yield FakeReply(status, document)
45
+ return
46
+ raise AssertionError(url)
47
+
48
+ monkeypatch.setattr(reader.urllib.request, "urlopen", fake_urlopen)
49
+ return all_requested_urls
50
+
51
+
52
+ def should_read_a_token_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
53
+ monkeypatch.delenv("GH_TOKEN", raising=False)
54
+ monkeypatch.setenv("GITHUB_TOKEN", TOKEN)
55
+
56
+ assert reader.github_token() == TOKEN
57
+
58
+
59
+ def should_report_a_missing_token(monkeypatch: pytest.MonkeyPatch) -> None:
60
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
61
+ monkeypatch.delenv("GH_TOKEN", raising=False)
62
+
63
+ with pytest.raises(GitHubError):
64
+ reader.github_token()
65
+
66
+
67
+ def should_send_a_read_and_decode_its_answer(monkeypatch: pytest.MonkeyPatch) -> None:
68
+ answer_with(monkeypatch, {"/pulls/7": (200, {"number": 7})})
69
+
70
+ document = reader.request_json("GET", "https://api.github.com/pulls/7", TOKEN, None)
71
+
72
+ assert document == {"number": 7}
73
+
74
+
75
+ def should_report_a_status_other_than_success(
76
+ monkeypatch: pytest.MonkeyPatch,
77
+ ) -> None:
78
+ answer_with(monkeypatch, {"/pulls/7": (404, {})})
79
+
80
+ with pytest.raises(GitHubError):
81
+ reader.request_json("GET", "https://api.github.com/pulls/7", TOKEN, None)
82
+
83
+
84
+ def should_read_one_pull_request(monkeypatch: pytest.MonkeyPatch) -> None:
85
+ answer_with(
86
+ monkeypatch,
87
+ {"/pulls/7": (200, {"number": 7, "head": {"sha": "ab845eb"}})},
88
+ )
89
+
90
+ assert reader.read_pull_request("jl-cmd/claude-dev-env", 7, TOKEN) == {
91
+ "number": 7,
92
+ "head": {"sha": "ab845eb"},
93
+ }
94
+
95
+
96
+ def should_report_a_pull_request_answer_of_the_wrong_shape(
97
+ monkeypatch: pytest.MonkeyPatch,
98
+ ) -> None:
99
+ answer_with(monkeypatch, {"/pulls/7": (200, ["not a pull request"])})
100
+
101
+ with pytest.raises(GitHubError):
102
+ reader.read_pull_request("jl-cmd/claude-dev-env", 7, TOKEN)
103
+
104
+
105
+ def should_read_review_threads_over_the_session_route(
106
+ monkeypatch: pytest.MonkeyPatch,
107
+ ) -> None:
108
+ answer_with(
109
+ monkeypatch,
110
+ {
111
+ "ccr/review_threads": (
112
+ 200,
113
+ [
114
+ {
115
+ "path": "scripts/example.py",
116
+ "resolved": False,
117
+ "comment_ids": [11],
118
+ }
119
+ ],
120
+ ),
121
+ "/comments": (
122
+ 200,
123
+ [{"id": 11, "body": "A finding.", "user": {"login": "a-bot"}}],
124
+ ),
125
+ },
126
+ )
127
+
128
+ all_threads = reader.read_review_threads("jl-cmd/claude-dev-env", 7, TOKEN)
129
+
130
+ assert len(all_threads) == 1
131
+ assert all_threads[0].subject == "scripts/example.py"
132
+ assert all_threads[0].all_comments[0].author_login == "a-bot"
133
+ assert all_threads[0].all_comments[0].body == "A finding."
134
+
135
+
136
+ def should_read_the_review_comments_keyed_by_identifier(
137
+ monkeypatch: pytest.MonkeyPatch,
138
+ ) -> None:
139
+ answer_with(
140
+ monkeypatch,
141
+ {"/comments": (200, [{"id": 11, "body": "A finding."}])},
142
+ )
143
+
144
+ all_comment_records = reader.read_review_comments_by_id(
145
+ "jl-cmd/claude-dev-env", 7, TOKEN
146
+ )
147
+
148
+ assert all_comment_records == {11: {"id": 11, "body": "A finding."}}
149
+
150
+
151
+ def should_report_a_comment_page_of_the_wrong_shape(
152
+ monkeypatch: pytest.MonkeyPatch,
153
+ ) -> None:
154
+ answer_with(monkeypatch, {"/comments": (200, {"message": "not a list"})})
155
+
156
+ with pytest.raises(GitHubError):
157
+ reader.read_review_comments_by_id("jl-cmd/claude-dev-env", 7, TOKEN)
158
+
159
+
160
+ def should_fall_back_to_the_graphql_query(monkeypatch: pytest.MonkeyPatch) -> None:
161
+ all_requested_urls = answer_with(
162
+ monkeypatch,
163
+ {
164
+ "ccr/review_threads": (404, {}),
165
+ "graphql": (
166
+ 200,
167
+ {
168
+ "data": {
169
+ "repository": {
170
+ "pullRequest": {
171
+ "reviewThreads": {
172
+ "nodes": [
173
+ {
174
+ "path": "scripts/example.py",
175
+ "isResolved": True,
176
+ "isOutdated": False,
177
+ "comments": {"nodes": []},
178
+ }
179
+ ]
180
+ }
181
+ }
182
+ }
183
+ }
184
+ },
185
+ ),
186
+ },
187
+ )
188
+
189
+ all_threads = reader.read_review_threads("jl-cmd/claude-dev-env", 7, TOKEN)
190
+
191
+ assert [each.is_resolved for each in all_threads] == [True]
192
+ assert any("graphql" in each_url for each_url in all_requested_urls)
193
+
194
+
195
+ def should_report_a_graphql_answer_carrying_no_threads(
196
+ monkeypatch: pytest.MonkeyPatch,
197
+ ) -> None:
198
+ answer_with(
199
+ monkeypatch,
200
+ {
201
+ "ccr/review_threads": (404, {}),
202
+ "graphql": (200, {"errors": [{"message": "refused"}]}),
203
+ },
204
+ )
205
+
206
+ with pytest.raises(GitHubError):
207
+ reader.read_review_threads("jl-cmd/claude-dev-env", 7, TOKEN)
208
+
209
+
210
+ def should_read_the_approvals_conclusion_on_a_head(
211
+ monkeypatch: pytest.MonkeyPatch,
212
+ ) -> None:
213
+ answer_with(
214
+ monkeypatch,
215
+ {
216
+ "check-runs": (
217
+ 200,
218
+ {
219
+ "check_runs": [
220
+ {"name": APPROVALS_CHECK_NAME, "conclusion": "failure"}
221
+ ]
222
+ },
223
+ )
224
+ },
225
+ )
226
+
227
+ conclusion = reader.read_approvals_conclusion(
228
+ "jl-cmd/claude-dev-env", "ab845eb", TOKEN
229
+ )
230
+
231
+ assert conclusion == "failure"
232
+
233
+
234
+ def should_report_a_check_run_answer_of_the_wrong_shape(
235
+ monkeypatch: pytest.MonkeyPatch,
236
+ ) -> None:
237
+ answer_with(monkeypatch, {"check-runs": (200, {"check_runs": "none"})})
238
+
239
+ with pytest.raises(GitHubError):
240
+ reader.read_approvals_conclusion("jl-cmd/claude-dev-env", "ab845eb", TOKEN)