claude-dev-env 1.36.1 → 1.37.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/_shared/pr-loop/audit-contract.md +159 -0
- package/_shared/pr-loop/code-rules-gate.md +64 -0
- package/_shared/pr-loop/fix-protocol.md +37 -0
- package/_shared/pr-loop/gh-payloads.md +85 -0
- package/_shared/pr-loop/scripts/README.md +20 -0
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
- package/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
- package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
- package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
- package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
- package/_shared/pr-loop/scripts/config/preflight_constants.py +68 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
- package/_shared/pr-loop/scripts/gh_util.py +193 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
- package/_shared/pr-loop/scripts/preflight.py +449 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
- package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
- package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +670 -0
- package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +77 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/state-schema.md +81 -0
- package/hooks/blocking/code_rules_enforcer.py +269 -23
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +157 -1
- package/hooks/config/test_unused_module_import_constants.py +48 -0
- package/hooks/config/unused_module_import_constants.py +41 -0
- package/package.json +2 -1
- package/skills/bg-agent/SKILL.md +69 -0
- package/skills/bugteam/CONSTRAINTS.md +10 -19
- package/skills/bugteam/PROMPTS.md +3 -3
- package/skills/bugteam/SKILL.md +103 -202
- package/skills/bugteam/SKILL_EVALS.md +75 -114
- package/skills/bugteam/reference/README.md +2 -4
- package/skills/bugteam/reference/design-rationale.md +3 -8
- package/skills/bugteam/reference/team-setup.md +11 -19
- package/skills/bugteam/reference/teardown-publish-permissions.md +2 -14
- package/skills/bugteam/scripts/config/__init__.py +0 -0
- package/skills/bugteam/scripts/config/reflow_skill_md_constants.py +12 -0
- package/skills/bugteam/scripts/reflow_skill_md.py +51 -47
- package/skills/bugteam/sources.md +1 -25
- package/skills/bugteam/test_skill_additions.py +4 -13
- package/skills/fresh-branch/SKILL.md +71 -0
- package/skills/gotcha/SKILL.md +73 -0
- package/skills/monitor-open-prs/SKILL.md +4 -37
- package/skills/monitor-open-prs/test_skill_contract.py +0 -5
- package/skills/pr-converge/SKILL.md +60 -1298
- package/skills/pr-converge/reference/convergence-gates.md +118 -0
- package/skills/pr-converge/reference/examples.md +76 -0
- package/skills/pr-converge/reference/fix-protocol.md +54 -0
- package/skills/pr-converge/reference/ground-rules.md +13 -0
- package/skills/pr-converge/reference/multi-pr-orchestration.md +204 -0
- package/skills/pr-converge/reference/per-tick.md +201 -0
- package/skills/pr-converge/reference/state-schema.md +19 -0
- package/skills/pr-converge/reference/stop-conditions.md +26 -0
- package/skills/pr-converge/scripts/README.md +36 -9
- package/skills/pr-converge/scripts/check_pr_mergeability.py +1 -2
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +58 -5
- package/skills/pr-converge/scripts/config/reflow_skill_md_constants.py +13 -0
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -24
- package/skills/pr-converge/scripts/cursor-agents-continue.ahk +22 -2
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +19 -59
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +15 -61
- package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +70 -0
- package/skills/pr-converge/scripts/fetch_claude_reviews.py +61 -0
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +19 -61
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +14 -74
- package/skills/pr-converge/scripts/reflow_skill_md.py +71 -50
- package/skills/pr-converge/scripts/reviewer_fetch_core.py +153 -0
- package/skills/pr-converge/scripts/reviewer_specs.py +98 -0
- package/skills/pr-converge/scripts/test_cursor_agents_continue.py +65 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +107 -6
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +85 -6
- package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +485 -0
- package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +368 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +74 -6
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +94 -8
- package/skills/pr-converge/scripts/test_reflow_skill_md.py +162 -0
- package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +448 -0
- package/skills/pr-converge/scripts/test_reviewer_specs.py +107 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +24 -22
- package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +0 -113
- package/skills/bugteam/reference/workflow-path-b-task-harness.md +0 -48
- package/skills/bugteam/test_team_lifecycle.py +0 -103
- package/skills/monitor-open-prs/test_team_lifecycle.py +0 -46
- package/skills/pr-converge/scripts/open_followup_copilot_pr.py +0 -136
- package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +0 -236
- package/skills/pr-converge/test_team_lifecycle.py +0 -56
- package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +0 -108
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Grant Edit/Write/Read permissions on the current directory's .claude tree.
|
|
2
|
+
|
|
3
|
+
Run from the project root whose .claude/** you want a Claude Code session
|
|
4
|
+
(including spawned subagents) to edit without prompting. Writes idempotent
|
|
5
|
+
entries into the user-scope settings at ~/.claude/settings.json and prints
|
|
6
|
+
the changes applied. No-op when the entries already exist.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
sys.modules.pop("config", None)
|
|
13
|
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
15
|
+
|
|
16
|
+
from _claude_permissions_common import ( # noqa: E402
|
|
17
|
+
append_if_missing,
|
|
18
|
+
build_permission_rules,
|
|
19
|
+
ensure_dict_section,
|
|
20
|
+
ensure_list_entry,
|
|
21
|
+
exit_with_error,
|
|
22
|
+
get_current_project_path,
|
|
23
|
+
is_valid_project_root,
|
|
24
|
+
load_settings,
|
|
25
|
+
save_settings,
|
|
26
|
+
)
|
|
27
|
+
from config.claude_permissions_constants import ( # noqa: E402
|
|
28
|
+
ALL_PERMISSION_ALLOW_TOOLS,
|
|
29
|
+
AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
|
|
30
|
+
get_claude_user_settings_path,
|
|
31
|
+
)
|
|
32
|
+
from config.claude_settings_keys_constants import ( # noqa: E402
|
|
33
|
+
CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
|
|
34
|
+
CLAUDE_SETTINGS_ALLOW_KEY,
|
|
35
|
+
CLAUDE_SETTINGS_AUTO_MODE_KEY,
|
|
36
|
+
CLAUDE_SETTINGS_ENVIRONMENT_KEY,
|
|
37
|
+
CLAUDE_SETTINGS_PERMISSIONS_KEY,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def add_rules_to_allow_list(
|
|
42
|
+
all_settings: dict[str, object], all_rules_to_add: list[str]
|
|
43
|
+
) -> int:
|
|
44
|
+
permissions_section = ensure_dict_section(
|
|
45
|
+
all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
|
|
46
|
+
)
|
|
47
|
+
existing_allow_list = ensure_list_entry(
|
|
48
|
+
permissions_section, CLAUDE_SETTINGS_ALLOW_KEY
|
|
49
|
+
)
|
|
50
|
+
return sum(
|
|
51
|
+
1
|
|
52
|
+
for each_rule in all_rules_to_add
|
|
53
|
+
if append_if_missing(existing_allow_list, each_rule)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def add_directory_to_additional_directories(
|
|
58
|
+
all_settings: dict[str, object], directory_path: str
|
|
59
|
+
) -> int:
|
|
60
|
+
permissions_section = ensure_dict_section(
|
|
61
|
+
all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
|
|
62
|
+
)
|
|
63
|
+
existing_directories = ensure_list_entry(
|
|
64
|
+
permissions_section, CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY
|
|
65
|
+
)
|
|
66
|
+
if append_if_missing(existing_directories, directory_path):
|
|
67
|
+
return 1
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def add_auto_mode_environment_entry(
|
|
72
|
+
all_settings: dict[str, object], entry_text: str
|
|
73
|
+
) -> int:
|
|
74
|
+
auto_mode_section = ensure_dict_section(
|
|
75
|
+
all_settings, CLAUDE_SETTINGS_AUTO_MODE_KEY
|
|
76
|
+
)
|
|
77
|
+
existing_environment = ensure_list_entry(
|
|
78
|
+
auto_mode_section, CLAUDE_SETTINGS_ENVIRONMENT_KEY
|
|
79
|
+
)
|
|
80
|
+
if append_if_missing(existing_environment, entry_text):
|
|
81
|
+
return 1
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def grant_permissions_for_current_directory() -> None:
|
|
86
|
+
claude_user_settings_path: Path = get_claude_user_settings_path()
|
|
87
|
+
project_root_path = Path.cwd()
|
|
88
|
+
if not is_valid_project_root(project_root_path):
|
|
89
|
+
print(
|
|
90
|
+
f"ERROR: cwd {project_root_path} is not a project root "
|
|
91
|
+
f"(no .git or .claude). Run from a project root.",
|
|
92
|
+
file=sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
raise SystemExit(1)
|
|
95
|
+
project_path = get_current_project_path()
|
|
96
|
+
all_permission_rules = build_permission_rules(
|
|
97
|
+
project_path, ALL_PERMISSION_ALLOW_TOOLS
|
|
98
|
+
)
|
|
99
|
+
environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
|
|
100
|
+
project_path=project_path
|
|
101
|
+
)
|
|
102
|
+
settings = load_settings(claude_user_settings_path)
|
|
103
|
+
rules_added_count = add_rules_to_allow_list(settings, all_permission_rules)
|
|
104
|
+
directories_added_count = add_directory_to_additional_directories(
|
|
105
|
+
settings, project_path
|
|
106
|
+
)
|
|
107
|
+
environment_entries_added_count = add_auto_mode_environment_entry(
|
|
108
|
+
settings, environment_entry
|
|
109
|
+
)
|
|
110
|
+
total_changes_count = (
|
|
111
|
+
rules_added_count + directories_added_count + environment_entries_added_count
|
|
112
|
+
)
|
|
113
|
+
if total_changes_count == 0:
|
|
114
|
+
print(f"Project path: {project_path}")
|
|
115
|
+
print(f"Settings file: {claude_user_settings_path}")
|
|
116
|
+
print("No changes needed; settings file left untouched.")
|
|
117
|
+
return
|
|
118
|
+
save_settings(claude_user_settings_path, settings)
|
|
119
|
+
print(f"Project path: {project_path}")
|
|
120
|
+
print(f"Settings file: {claude_user_settings_path}")
|
|
121
|
+
print(f"Allow rules added: {rules_added_count} of {len(all_permission_rules)}")
|
|
122
|
+
print(f"Additional directories added: {directories_added_count}")
|
|
123
|
+
print(f"Auto-mode environment entries added: {environment_entries_added_count}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
try:
|
|
128
|
+
grant_permissions_for_current_directory()
|
|
129
|
+
except ValueError as path_error:
|
|
130
|
+
exit_with_error(str(path_error))
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
sys.modules.pop("config", None)
|
|
8
|
+
_script_directory_resolved = Path(__file__).resolve().parent
|
|
9
|
+
_script_directory_absolute = Path(__file__).absolute().parent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _entry_points_at_preflight_script_directory(each_path_entry: str) -> bool:
|
|
13
|
+
if each_path_entry in (
|
|
14
|
+
str(_script_directory_resolved),
|
|
15
|
+
str(_script_directory_absolute),
|
|
16
|
+
):
|
|
17
|
+
return True
|
|
18
|
+
try:
|
|
19
|
+
candidate_path = Path(each_path_entry)
|
|
20
|
+
except (OSError, ValueError):
|
|
21
|
+
return False
|
|
22
|
+
if candidate_path.exists():
|
|
23
|
+
try:
|
|
24
|
+
return os.path.samefile(candidate_path, _script_directory_resolved)
|
|
25
|
+
except OSError:
|
|
26
|
+
return False
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
for each_index in range(len(sys.path) - 1, -1, -1):
|
|
31
|
+
if _entry_points_at_preflight_script_directory(sys.path[each_index]):
|
|
32
|
+
sys.path.pop(each_index)
|
|
33
|
+
_preflight_scripts_path_entry = str(_script_directory_absolute)
|
|
34
|
+
if _preflight_scripts_path_entry not in sys.path:
|
|
35
|
+
sys.path.insert(0, _preflight_scripts_path_entry)
|
|
36
|
+
|
|
37
|
+
from config.fix_hookspath_constants import HOOKS_PATH_VERIFICATION_SUFFIX
|
|
38
|
+
from config.preflight_constants import (
|
|
39
|
+
ALL_GIT_CONFIG_GET_CORE_HOOKS_PATH_SUBCOMMAND,
|
|
40
|
+
ALL_GIT_DIFF_NAME_ONLY_SUBCOMMAND,
|
|
41
|
+
ALL_GIT_LS_FILES_TEST_DISCOVERY_SUBCOMMAND,
|
|
42
|
+
ALL_PRE_COMMIT_RUN_ALL_FILES_COMMAND,
|
|
43
|
+
BUGTEAM_PREFLIGHT_SKIP_ENABLED_VALUE,
|
|
44
|
+
BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME,
|
|
45
|
+
GIT_DIRECTORY_NAME,
|
|
46
|
+
PRE_COMMIT_CONFIG_YAML_FILENAME,
|
|
47
|
+
PYPROJECT_TOML_FILENAME,
|
|
48
|
+
PYTEST_FAILED_FIRST_FLAG,
|
|
49
|
+
PYTEST_INI_FILENAME,
|
|
50
|
+
ALL_PYTEST_SCOPE_CHOICES,
|
|
51
|
+
PYTEST_NO_TESTS_COLLECTED_EXIT_CODE,
|
|
52
|
+
PYTEST_SCOPE_ALL,
|
|
53
|
+
PYTEST_SCOPE_CHANGED,
|
|
54
|
+
PYTEST_TEST_FILENAME_PREFIX,
|
|
55
|
+
PYTEST_TEST_FILENAME_SUFFIX,
|
|
56
|
+
PYTEST_TOML_TABLE_PREFIX,
|
|
57
|
+
PYTHON_FILE_SUFFIX,
|
|
58
|
+
TESTS_DIRECTORY_NAME,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def verify_git_hooks_path(repository_root: Path | None = None) -> int:
|
|
63
|
+
"""Check that core.hooksPath resolves to the claude-dev-env git-hooks directory.
|
|
64
|
+
|
|
65
|
+
When *repository_root* is provided, queries the effective config for that
|
|
66
|
+
repository (``git -C <root> config --get``), which detects repo-level
|
|
67
|
+
overrides such as Husky or lefthook. Falls back to the current working
|
|
68
|
+
directory's effective config when *repository_root* is None.
|
|
69
|
+
|
|
70
|
+
Returns zero when the configured path ends with the expected hooks suffix.
|
|
71
|
+
Returns non-zero and prints a correction message when unset or pointing elsewhere.
|
|
72
|
+
"""
|
|
73
|
+
expected_hooks_path_suffix = HOOKS_PATH_VERIFICATION_SUFFIX
|
|
74
|
+
enforcement_absent_message = (
|
|
75
|
+
"Git-side CODE_RULES enforcement is not active on this host.\n"
|
|
76
|
+
"Run: npx claude-dev-env .\n"
|
|
77
|
+
"Or set core.hooksPath at any scope, e.g.:\n"
|
|
78
|
+
" git config --global core.hooksPath ~/.claude/hooks/git-hooks"
|
|
79
|
+
)
|
|
80
|
+
git_command: list[str] = ["git"]
|
|
81
|
+
if repository_root is not None:
|
|
82
|
+
git_command.extend(["-C", str(repository_root)])
|
|
83
|
+
git_command.extend(list(ALL_GIT_CONFIG_GET_CORE_HOOKS_PATH_SUBCOMMAND))
|
|
84
|
+
try:
|
|
85
|
+
query_result = subprocess.run(
|
|
86
|
+
git_command,
|
|
87
|
+
capture_output=True,
|
|
88
|
+
text=True,
|
|
89
|
+
encoding="utf-8",
|
|
90
|
+
errors="replace",
|
|
91
|
+
check=False,
|
|
92
|
+
)
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
print(
|
|
95
|
+
"bugteam_preflight: git is not installed or not available on PATH.\n"
|
|
96
|
+
f"{enforcement_absent_message}",
|
|
97
|
+
file=sys.stderr,
|
|
98
|
+
)
|
|
99
|
+
return 1
|
|
100
|
+
except OSError as os_error:
|
|
101
|
+
print(
|
|
102
|
+
f"bugteam_preflight: failed to run git: {os_error}\n"
|
|
103
|
+
f"{enforcement_absent_message}",
|
|
104
|
+
file=sys.stderr,
|
|
105
|
+
)
|
|
106
|
+
return 1
|
|
107
|
+
if query_result.returncode != 0:
|
|
108
|
+
print(
|
|
109
|
+
f"bugteam_preflight: {enforcement_absent_message}",
|
|
110
|
+
file=sys.stderr,
|
|
111
|
+
)
|
|
112
|
+
return 1
|
|
113
|
+
configured_path = query_result.stdout.strip().replace("\\", "/").rstrip("/")
|
|
114
|
+
if not configured_path.endswith(expected_hooks_path_suffix):
|
|
115
|
+
print(
|
|
116
|
+
f"bugteam_preflight: core.hooksPath is '{configured_path}' — "
|
|
117
|
+
f"expected path ending in '{expected_hooks_path_suffix}'.\n"
|
|
118
|
+
f"{enforcement_absent_message}",
|
|
119
|
+
file=sys.stderr,
|
|
120
|
+
)
|
|
121
|
+
return 1
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def find_repository_root(start: Path) -> Path:
|
|
126
|
+
resolved = start.resolve()
|
|
127
|
+
all_candidates = [resolved, *resolved.parents]
|
|
128
|
+
for each_candidate in all_candidates:
|
|
129
|
+
git_marker = each_candidate / GIT_DIRECTORY_NAME
|
|
130
|
+
if git_marker.is_dir() or git_marker.is_file():
|
|
131
|
+
return each_candidate
|
|
132
|
+
for each_candidate in all_candidates:
|
|
133
|
+
if (each_candidate / PYTEST_INI_FILENAME).is_file():
|
|
134
|
+
return each_candidate
|
|
135
|
+
return resolved
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def has_pytest_configuration(root: Path) -> bool:
|
|
139
|
+
if (root / PYTEST_INI_FILENAME).is_file():
|
|
140
|
+
return True
|
|
141
|
+
pyproject = root / PYPROJECT_TOML_FILENAME
|
|
142
|
+
if not pyproject.is_file():
|
|
143
|
+
return False
|
|
144
|
+
text = pyproject.read_text(encoding="utf-8", errors="replace")
|
|
145
|
+
return PYTEST_TOML_TABLE_PREFIX in text
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def has_discoverable_tests(root: Path) -> bool | None:
|
|
149
|
+
git_marker = root / GIT_DIRECTORY_NAME
|
|
150
|
+
if not (git_marker.is_dir() or git_marker.is_file()):
|
|
151
|
+
return True
|
|
152
|
+
command = ["git", "-C", str(root), *ALL_GIT_LS_FILES_TEST_DISCOVERY_SUBCOMMAND]
|
|
153
|
+
try:
|
|
154
|
+
completed = subprocess.run(
|
|
155
|
+
command,
|
|
156
|
+
capture_output=True,
|
|
157
|
+
text=True,
|
|
158
|
+
encoding="utf-8",
|
|
159
|
+
errors="replace",
|
|
160
|
+
check=True,
|
|
161
|
+
)
|
|
162
|
+
except FileNotFoundError:
|
|
163
|
+
print(
|
|
164
|
+
"bugteam_preflight: git is not installed or not available on PATH.",
|
|
165
|
+
file=sys.stderr,
|
|
166
|
+
)
|
|
167
|
+
return None
|
|
168
|
+
except subprocess.CalledProcessError as error:
|
|
169
|
+
error_detail = (error.stderr or "").strip()
|
|
170
|
+
print(
|
|
171
|
+
f"bugteam_preflight: git ls-files failed (exit {error.returncode}):"
|
|
172
|
+
+ (f"\n{error_detail}" if error_detail else ""),
|
|
173
|
+
file=sys.stderr,
|
|
174
|
+
)
|
|
175
|
+
return None
|
|
176
|
+
except OSError as error:
|
|
177
|
+
print(
|
|
178
|
+
f"bugteam_preflight: failed to run git ls-files: {error}",
|
|
179
|
+
file=sys.stderr,
|
|
180
|
+
)
|
|
181
|
+
return None
|
|
182
|
+
return bool(completed.stdout.strip())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _pytest_exit_code_no_tests_collected() -> int:
|
|
186
|
+
pytest_no_tests_collected_exit_code = PYTEST_NO_TESTS_COLLECTED_EXIT_CODE
|
|
187
|
+
return pytest_no_tests_collected_exit_code
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def run_pytest(
|
|
191
|
+
repository_root: Path,
|
|
192
|
+
verbose: bool,
|
|
193
|
+
all_test_paths: list[Path] | None = None,
|
|
194
|
+
) -> int:
|
|
195
|
+
command = [sys.executable, "-m", "pytest", PYTEST_FAILED_FIRST_FLAG]
|
|
196
|
+
if not verbose:
|
|
197
|
+
command.append("-q")
|
|
198
|
+
if all_test_paths is not None:
|
|
199
|
+
command.append("--")
|
|
200
|
+
command.extend(str(each_path) for each_path in all_test_paths)
|
|
201
|
+
completed = subprocess.run(
|
|
202
|
+
command,
|
|
203
|
+
cwd=str(repository_root),
|
|
204
|
+
check=False,
|
|
205
|
+
)
|
|
206
|
+
if completed.returncode == _pytest_exit_code_no_tests_collected():
|
|
207
|
+
return 0
|
|
208
|
+
return completed.returncode
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def get_changed_files(repository_root: Path, base_ref: str) -> list[Path] | None:
|
|
212
|
+
if base_ref.startswith("-"):
|
|
213
|
+
print(
|
|
214
|
+
f"bugteam_preflight: invalid base_ref '{base_ref}' starts "
|
|
215
|
+
f"with hyphen; falling back to full suite.",
|
|
216
|
+
file=sys.stderr,
|
|
217
|
+
)
|
|
218
|
+
return None
|
|
219
|
+
command = [
|
|
220
|
+
"git",
|
|
221
|
+
*ALL_GIT_DIFF_NAME_ONLY_SUBCOMMAND,
|
|
222
|
+
f"{base_ref}...HEAD",
|
|
223
|
+
]
|
|
224
|
+
try:
|
|
225
|
+
completed = subprocess.run(
|
|
226
|
+
command,
|
|
227
|
+
cwd=str(repository_root),
|
|
228
|
+
capture_output=True,
|
|
229
|
+
text=True,
|
|
230
|
+
encoding="utf-8",
|
|
231
|
+
errors="replace",
|
|
232
|
+
check=False,
|
|
233
|
+
)
|
|
234
|
+
except FileNotFoundError:
|
|
235
|
+
print(
|
|
236
|
+
"bugteam_preflight: git is not installed or not available on PATH.\n"
|
|
237
|
+
f"bugteam_preflight: cannot determine changed files against "
|
|
238
|
+
f"{base_ref}; falling back to full suite.",
|
|
239
|
+
file=sys.stderr,
|
|
240
|
+
)
|
|
241
|
+
return None
|
|
242
|
+
except OSError as os_error:
|
|
243
|
+
print(
|
|
244
|
+
f"bugteam_preflight: failed to run git: {os_error}\n"
|
|
245
|
+
f"bugteam_preflight: cannot determine changed files against "
|
|
246
|
+
f"{base_ref}; falling back to full suite.",
|
|
247
|
+
file=sys.stderr,
|
|
248
|
+
)
|
|
249
|
+
return None
|
|
250
|
+
if completed.returncode != 0:
|
|
251
|
+
print(
|
|
252
|
+
f"bugteam_preflight: git diff against {base_ref} failed "
|
|
253
|
+
f"(exit {completed.returncode}); falling back to full suite.\n"
|
|
254
|
+
f"{completed.stderr.strip()}",
|
|
255
|
+
file=sys.stderr,
|
|
256
|
+
)
|
|
257
|
+
return None
|
|
258
|
+
return [
|
|
259
|
+
Path(each_line.strip())
|
|
260
|
+
for each_line in completed.stdout.splitlines()
|
|
261
|
+
if each_line.strip()
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _find_related_test_files(changed_path: Path, repository_root: Path) -> list[Path]:
|
|
266
|
+
if changed_path.suffix != PYTHON_FILE_SUFFIX:
|
|
267
|
+
return []
|
|
268
|
+
stem = changed_path.stem
|
|
269
|
+
test_prefix = PYTEST_TEST_FILENAME_PREFIX
|
|
270
|
+
test_suffix = PYTEST_TEST_FILENAME_SUFFIX
|
|
271
|
+
if (stem.startswith(test_prefix) or stem.endswith(test_suffix)) and (
|
|
272
|
+
repository_root / changed_path
|
|
273
|
+
).is_file():
|
|
274
|
+
return [repository_root / changed_path]
|
|
275
|
+
full_path = repository_root / changed_path
|
|
276
|
+
parent = full_path.parent
|
|
277
|
+
adjacent_tests = parent / TESTS_DIRECTORY_NAME
|
|
278
|
+
top_tests = repository_root / TESTS_DIRECTORY_NAME
|
|
279
|
+
relative_parent = changed_path.parent
|
|
280
|
+
python_suffix = PYTHON_FILE_SUFFIX
|
|
281
|
+
all_candidates = [
|
|
282
|
+
parent / f"{test_prefix}{stem}{python_suffix}",
|
|
283
|
+
parent / f"{stem}{test_suffix}{python_suffix}",
|
|
284
|
+
adjacent_tests / f"{test_prefix}{stem}{python_suffix}",
|
|
285
|
+
adjacent_tests / f"{stem}{test_suffix}{python_suffix}",
|
|
286
|
+
]
|
|
287
|
+
if relative_parent != Path("."):
|
|
288
|
+
all_candidates.extend([
|
|
289
|
+
top_tests / relative_parent / f"{test_prefix}{stem}{python_suffix}",
|
|
290
|
+
top_tests / relative_parent / f"{stem}{test_suffix}{python_suffix}",
|
|
291
|
+
])
|
|
292
|
+
return sorted({each_candidate for each_candidate in all_candidates if each_candidate.is_file()})
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def discover_related_tests(
|
|
296
|
+
all_changed_files: list[Path], repository_root: Path
|
|
297
|
+
) -> list[Path]:
|
|
298
|
+
related: set[Path] = set()
|
|
299
|
+
for each_file in all_changed_files:
|
|
300
|
+
related.update(_find_related_test_files(each_file, repository_root))
|
|
301
|
+
return sorted(related)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def run_pre_commit(repository_root: Path) -> int:
|
|
305
|
+
completed = subprocess.run(
|
|
306
|
+
list(ALL_PRE_COMMIT_RUN_ALL_FILES_COMMAND),
|
|
307
|
+
cwd=str(repository_root),
|
|
308
|
+
check=False,
|
|
309
|
+
)
|
|
310
|
+
return completed.returncode
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
|
|
314
|
+
parser = argparse.ArgumentParser(
|
|
315
|
+
description="Run local checks before /bugteam (pytest, optional pre-commit).",
|
|
316
|
+
)
|
|
317
|
+
parser.add_argument(
|
|
318
|
+
"--repo-root",
|
|
319
|
+
type=Path,
|
|
320
|
+
default=None,
|
|
321
|
+
help="Repository root (default: discover from cwd).",
|
|
322
|
+
)
|
|
323
|
+
parser.add_argument(
|
|
324
|
+
"--no-pytest",
|
|
325
|
+
action="store_true",
|
|
326
|
+
help="Skip pytest.",
|
|
327
|
+
)
|
|
328
|
+
parser.add_argument(
|
|
329
|
+
"--pre-commit",
|
|
330
|
+
action="store_true",
|
|
331
|
+
help=f"Run pre-commit when {PRE_COMMIT_CONFIG_YAML_FILENAME} exists.",
|
|
332
|
+
)
|
|
333
|
+
parser.add_argument(
|
|
334
|
+
"-v",
|
|
335
|
+
"--verbose",
|
|
336
|
+
action="store_true",
|
|
337
|
+
help="Verbose pytest output.",
|
|
338
|
+
)
|
|
339
|
+
parser.add_argument(
|
|
340
|
+
"--base-ref",
|
|
341
|
+
type=str,
|
|
342
|
+
default=None,
|
|
343
|
+
help=(
|
|
344
|
+
"Git base ref for scoped test selection (e.g., origin/main). "
|
|
345
|
+
"When set, only tests related to files changed vs this ref are run."
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
parser.add_argument(
|
|
349
|
+
"--scope",
|
|
350
|
+
type=str,
|
|
351
|
+
choices=list(ALL_PYTEST_SCOPE_CHOICES),
|
|
352
|
+
default=None,
|
|
353
|
+
help=(
|
|
354
|
+
"Test selection scope. 'all' runs the full suite. "
|
|
355
|
+
"'changed' runs only tests related to changed files (requires --base-ref). "
|
|
356
|
+
"Defaults to 'changed' when --base-ref is provided, 'all' otherwise."
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
return parser.parse_args(all_arguments)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def main(all_arguments: list[str]) -> int:
|
|
363
|
+
arguments = parse_arguments(all_arguments)
|
|
364
|
+
skip_env_var_name = BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME
|
|
365
|
+
skip_enabled_value = BUGTEAM_PREFLIGHT_SKIP_ENABLED_VALUE
|
|
366
|
+
if os.environ.get(skip_env_var_name, "").strip() == skip_enabled_value:
|
|
367
|
+
print(
|
|
368
|
+
f"bugteam_preflight: skipped ({skip_env_var_name}={skip_enabled_value}).",
|
|
369
|
+
file=sys.stderr,
|
|
370
|
+
)
|
|
371
|
+
return 0
|
|
372
|
+
start = Path.cwd()
|
|
373
|
+
repository_root = (
|
|
374
|
+
arguments.repo_root.resolve()
|
|
375
|
+
if arguments.repo_root is not None
|
|
376
|
+
else find_repository_root(start)
|
|
377
|
+
)
|
|
378
|
+
hooks_path_exit_code = verify_git_hooks_path(repository_root)
|
|
379
|
+
if hooks_path_exit_code != 0:
|
|
380
|
+
return hooks_path_exit_code
|
|
381
|
+
if not arguments.no_pytest and has_pytest_configuration(repository_root):
|
|
382
|
+
discovery_result = has_discoverable_tests(repository_root)
|
|
383
|
+
if discovery_result is None:
|
|
384
|
+
print(
|
|
385
|
+
"bugteam_preflight: test discovery failed; running full suite anyway.",
|
|
386
|
+
file=sys.stderr,
|
|
387
|
+
)
|
|
388
|
+
elif not discovery_result:
|
|
389
|
+
print(
|
|
390
|
+
"bugteam_preflight: pytest configured but no tests found; skipping pytest.",
|
|
391
|
+
file=sys.stderr,
|
|
392
|
+
)
|
|
393
|
+
if discovery_result is not False:
|
|
394
|
+
effective_scope = arguments.scope
|
|
395
|
+
if discovery_result is None:
|
|
396
|
+
effective_scope = PYTEST_SCOPE_ALL
|
|
397
|
+
if effective_scope is None:
|
|
398
|
+
effective_scope = (
|
|
399
|
+
PYTEST_SCOPE_CHANGED
|
|
400
|
+
if arguments.base_ref is not None
|
|
401
|
+
else PYTEST_SCOPE_ALL
|
|
402
|
+
)
|
|
403
|
+
if effective_scope == PYTEST_SCOPE_CHANGED and arguments.base_ref is None:
|
|
404
|
+
print(
|
|
405
|
+
"bugteam_preflight: --scope changed requires --base-ref; "
|
|
406
|
+
"falling back to full suite.",
|
|
407
|
+
file=sys.stderr,
|
|
408
|
+
)
|
|
409
|
+
effective_scope = PYTEST_SCOPE_ALL
|
|
410
|
+
if effective_scope == PYTEST_SCOPE_CHANGED and arguments.base_ref is not None:
|
|
411
|
+
all_changed = get_changed_files(repository_root, arguments.base_ref)
|
|
412
|
+
if all_changed is None:
|
|
413
|
+
exit_code = run_pytest(repository_root, arguments.verbose)
|
|
414
|
+
else:
|
|
415
|
+
all_related = discover_related_tests(all_changed, repository_root)
|
|
416
|
+
if all_related:
|
|
417
|
+
print(
|
|
418
|
+
f"bugteam_preflight: running {len(all_related)} test(s) "
|
|
419
|
+
f"related to changed files (scope=changed).",
|
|
420
|
+
file=sys.stderr,
|
|
421
|
+
)
|
|
422
|
+
exit_code = run_pytest(
|
|
423
|
+
repository_root, arguments.verbose, all_related
|
|
424
|
+
)
|
|
425
|
+
else:
|
|
426
|
+
print(
|
|
427
|
+
"bugteam_preflight: no related tests found; "
|
|
428
|
+
"running full suite.",
|
|
429
|
+
file=sys.stderr,
|
|
430
|
+
)
|
|
431
|
+
exit_code = run_pytest(repository_root, arguments.verbose)
|
|
432
|
+
else:
|
|
433
|
+
exit_code = run_pytest(repository_root, arguments.verbose)
|
|
434
|
+
if exit_code != 0:
|
|
435
|
+
return exit_code
|
|
436
|
+
elif not arguments.no_pytest:
|
|
437
|
+
print(
|
|
438
|
+
"bugteam_preflight: no pytest configuration found; skipping pytest.",
|
|
439
|
+
file=sys.stderr,
|
|
440
|
+
)
|
|
441
|
+
if arguments.pre_commit and (repository_root / PRE_COMMIT_CONFIG_YAML_FILENAME).is_file():
|
|
442
|
+
exit_code = run_pre_commit(repository_root)
|
|
443
|
+
if exit_code != 0:
|
|
444
|
+
return exit_code
|
|
445
|
+
return 0
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
if __name__ == "__main__":
|
|
449
|
+
raise SystemExit(main(sys.argv[1:]))
|