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
|
@@ -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
|
|
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)
|