claude-dev-env 1.39.0 → 1.41.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/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +10 -0
- package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +53 -3
- package/_shared/pr-loop/scripts/post_audit_thread.py +298 -3
- package/_shared/pr-loop/scripts/preflight.py +129 -2
- package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +68 -3
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +194 -1
- package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
- package/agents/pr-description-writer.md +150 -52
- package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
- package/hooks/_gh_pr_author_swap_utils.py +1211 -0
- package/hooks/blocking/gh_body_arg_blocker.py +9 -6
- package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
- package/hooks/blocking/gh_pr_author_restore.py +100 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
- package/hooks/blocking/pr_description_enforcer.py +56 -23
- package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
- package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
- package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
- package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
- package/hooks/blocking/test_pr_description_enforcer.py +69 -8
- package/hooks/config/gh_pr_author_swap_constants.py +76 -0
- package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
- package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
- package/hooks/config/pr_description_enforcer_constants.py +19 -0
- package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
- package/hooks/hooks.json +40 -0
- package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
- package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
- package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
- package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
- package/hooks/test__gh_pr_author_swap_utils.py +333 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
- package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
- package/skills/bugteam/SKILL.md +28 -10
- package/skills/bugteam/reference/audit-contract.md +22 -0
- package/skills/bugteam/reference/github-pr-reviews.md +1 -1
- package/skills/bugteam/reference/team-setup.md +5 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
- package/skills/bugteam/scripts/bugteam_preflight.py +36 -2
- package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
- package/skills/bugteam/scripts/test_bugteam_preflight.py +41 -0
- package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
- package/skills/copilot-review/SKILL.md +16 -0
- package/skills/findbugs/SKILL.md +35 -7
- package/skills/monitor-open-prs/SKILL.md +2 -1
- package/skills/pr-converge/SKILL.md +11 -3
- package/skills/pr-converge/config/constants.py +3 -1
- package/skills/pr-converge/reference/per-tick.md +17 -0
- package/skills/pr-converge/reference/state-schema.md +36 -8
- package/skills/pr-converge/scripts/check_bugbot_ci.py +113 -8
- package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
- package/skills/qbug/SKILL.md +33 -8
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Shared helper for the CLAUDE_REVIEWS_DISABLED opt-out gate.
|
|
2
|
+
|
|
3
|
+
Both ``skills/bugteam/scripts/bugteam_preflight.py`` and
|
|
4
|
+
``_shared/pr-loop/scripts/preflight.py`` consume this helper so the parsing
|
|
5
|
+
rules and disabled-token taxonomy live in exactly one place.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
for each_cached_module_name in [
|
|
15
|
+
each_module_key
|
|
16
|
+
for each_module_key in list(sys.modules)
|
|
17
|
+
if each_module_key == "config" or each_module_key.startswith("config.")
|
|
18
|
+
]:
|
|
19
|
+
sys.modules.pop(each_cached_module_name, None)
|
|
20
|
+
_shared_pr_loop_scripts_directory = str(Path(__file__).absolute().parent)
|
|
21
|
+
while _shared_pr_loop_scripts_directory in sys.path:
|
|
22
|
+
sys.path.remove(_shared_pr_loop_scripts_directory)
|
|
23
|
+
if _shared_pr_loop_scripts_directory not in sys.path:
|
|
24
|
+
sys.path.insert(0, _shared_pr_loop_scripts_directory)
|
|
25
|
+
|
|
26
|
+
from config.reviews_disabled_constants import (
|
|
27
|
+
CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN,
|
|
28
|
+
CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME,
|
|
29
|
+
CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR,
|
|
30
|
+
EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN",
|
|
36
|
+
"CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME",
|
|
37
|
+
"CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR",
|
|
38
|
+
"EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV",
|
|
39
|
+
"is_bugteam_disabled_via_env",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_bugteam_disabled_via_env() -> bool:
|
|
44
|
+
"""Check whether CLAUDE_REVIEWS_DISABLED opts the bug-audit family out of running.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
True when the env var contains the literal ``bugteam`` token
|
|
48
|
+
(comma-separated, case-insensitive, whitespace-tolerant).
|
|
49
|
+
"""
|
|
50
|
+
reviews_disabled_env_var_name = CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME
|
|
51
|
+
reviews_disabled_token_separator = CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR
|
|
52
|
+
reviews_disabled_bugteam_token = CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN
|
|
53
|
+
raw_value = os.environ.get(reviews_disabled_env_var_name, "")
|
|
54
|
+
all_disabled_tokens = frozenset(
|
|
55
|
+
each_raw_token.strip().lower()
|
|
56
|
+
for each_raw_token in raw_value.split(reviews_disabled_token_separator)
|
|
57
|
+
if each_raw_token.strip()
|
|
58
|
+
)
|
|
59
|
+
return reviews_disabled_bugteam_token in all_disabled_tokens
|
|
@@ -10,9 +10,22 @@ autoMode sections so repeated grant/revoke cycles leave no dead structure.
|
|
|
10
10
|
import sys
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
sys.path.
|
|
13
|
+
parent_directory = str(Path(__file__).absolute().parent)
|
|
14
|
+
try:
|
|
15
|
+
sys.path.remove(parent_directory)
|
|
16
|
+
except ValueError:
|
|
17
|
+
pass
|
|
18
|
+
if parent_directory not in sys.path:
|
|
19
|
+
sys.path.insert(0, parent_directory)
|
|
20
|
+
|
|
21
|
+
for each_cached_module_name in [
|
|
22
|
+
each_module_key
|
|
23
|
+
for each_module_key in list(sys.modules)
|
|
24
|
+
if each_module_key == "config"
|
|
25
|
+
or each_module_key.startswith("config.")
|
|
26
|
+
or each_module_key == "_claude_permissions_common"
|
|
27
|
+
]:
|
|
28
|
+
sys.modules.pop(each_cached_module_name, None)
|
|
16
29
|
|
|
17
30
|
from _claude_permissions_common import ( # noqa: E402
|
|
18
31
|
build_permission_rules,
|
|
@@ -40,6 +53,15 @@ from config.claude_settings_keys_constants import ( # noqa: E402
|
|
|
40
53
|
def remove_values_from_list(
|
|
41
54
|
all_target_list: list[object], all_values_to_remove: set[str]
|
|
42
55
|
) -> int:
|
|
56
|
+
"""Remove matching values from a list in place.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
all_target_list: The list to remove values from.
|
|
60
|
+
all_values_to_remove: Set of string values to remove.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Number of values removed.
|
|
64
|
+
"""
|
|
43
65
|
original_length = len(all_target_list)
|
|
44
66
|
all_target_list[:] = [
|
|
45
67
|
each_value
|
|
@@ -52,6 +74,15 @@ def remove_values_from_list(
|
|
|
52
74
|
def remove_rules_from_allow_list(
|
|
53
75
|
all_settings: dict[str, object], all_rules_to_remove: list[str]
|
|
54
76
|
) -> int:
|
|
77
|
+
"""Remove matching permission rules from the settings allow list.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
all_settings: The parsed settings dictionary.
|
|
81
|
+
all_rules_to_remove: Permission rule strings to remove.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Number of rules removed.
|
|
85
|
+
"""
|
|
55
86
|
permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
|
|
56
87
|
if not isinstance(permissions_section, dict):
|
|
57
88
|
return 0
|
|
@@ -64,6 +95,15 @@ def remove_rules_from_allow_list(
|
|
|
64
95
|
def remove_directory_from_additional_directories(
|
|
65
96
|
all_settings: dict[str, object], directory_path: str
|
|
66
97
|
) -> int:
|
|
98
|
+
"""Remove a project path from the additionalDirectories list.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
all_settings: The parsed settings dictionary.
|
|
102
|
+
directory_path: The project directory path to remove.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
1 when the entry was removed, 0 when not found.
|
|
106
|
+
"""
|
|
67
107
|
permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
|
|
68
108
|
if not isinstance(permissions_section, dict):
|
|
69
109
|
return 0
|
|
@@ -78,6 +118,15 @@ def remove_directory_from_additional_directories(
|
|
|
78
118
|
def remove_auto_mode_environment_entry(
|
|
79
119
|
all_settings: dict[str, object], entry_text: str
|
|
80
120
|
) -> int:
|
|
121
|
+
"""Remove an auto-mode environment entry for the project.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
all_settings: The parsed settings dictionary.
|
|
125
|
+
entry_text: The environment entry text to remove.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
1 when the entry was removed, 0 when not found.
|
|
129
|
+
"""
|
|
81
130
|
auto_mode_section = all_settings.get(CLAUDE_SETTINGS_AUTO_MODE_KEY)
|
|
82
131
|
if not isinstance(auto_mode_section, dict):
|
|
83
132
|
return 0
|
|
@@ -88,6 +137,11 @@ def remove_auto_mode_environment_entry(
|
|
|
88
137
|
|
|
89
138
|
|
|
90
139
|
def prune_settings_after_revoke(all_settings: dict[str, object]) -> None:
|
|
140
|
+
"""Remove empty lists and their parent sections after revoking entries.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
all_settings: The parsed settings dictionary to prune in place.
|
|
144
|
+
"""
|
|
91
145
|
prune_empty_list_then_empty_section(
|
|
92
146
|
all_settings,
|
|
93
147
|
CLAUDE_SETTINGS_PERMISSIONS_KEY,
|
|
@@ -106,6 +160,17 @@ def prune_settings_after_revoke(all_settings: dict[str, object]) -> None:
|
|
|
106
160
|
|
|
107
161
|
|
|
108
162
|
def revoke_permissions_for_current_directory() -> None:
|
|
163
|
+
"""Revoke permissions previously granted for the current project directory.
|
|
164
|
+
|
|
165
|
+
Reads the current project path, constructs the matching permission rules,
|
|
166
|
+
removes them from ~/.claude/settings.json, and prunes any newly empty
|
|
167
|
+
sections.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
SystemExit: When the current directory is not a valid project root.
|
|
171
|
+
ValueError: Propagated from get_current_project_path() when the path
|
|
172
|
+
contains glob metacharacters.
|
|
173
|
+
"""
|
|
109
174
|
claude_user_settings_path: Path = get_claude_user_settings_path()
|
|
110
175
|
project_root_path = Path.cwd()
|
|
111
176
|
if not is_valid_project_root(project_root_path):
|
|
@@ -43,7 +43,7 @@ def test_grant_module_guards_sys_path_insert_against_duplicates() -> None:
|
|
|
43
43
|
module_source = (
|
|
44
44
|
Path(__file__).parent.parent / "grant_project_claude_permissions.py"
|
|
45
45
|
).read_text(encoding="utf-8")
|
|
46
|
-
assert "if
|
|
46
|
+
assert "if parent_directory not in sys.path:" in module_source, (
|
|
47
47
|
"grant_project_claude_permissions.py must guard sys.path.insert against "
|
|
48
48
|
"duplicate entries on reload (consistent with sibling modules)"
|
|
49
49
|
)
|
|
@@ -43,7 +43,9 @@ if str(SCRIPT_DIRECTORY) not in sys.path:
|
|
|
43
43
|
from config.post_audit_thread_constants import ( # noqa: E402
|
|
44
44
|
ALL_GH_AUTH_TOKEN_COMMAND_PARTS,
|
|
45
45
|
ALL_RETRY_BACKOFF_SECONDS,
|
|
46
|
+
BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME,
|
|
46
47
|
GH_TOKEN_ENV_VAR_NAME,
|
|
48
|
+
GITHUB_TOKEN_ENV_VAR_NAME,
|
|
47
49
|
CLI_FLAG_COMMIT,
|
|
48
50
|
CLI_FLAG_FINDINGS_JSON,
|
|
49
51
|
CLI_FLAG_OWNER,
|
|
@@ -69,7 +71,15 @@ from config.post_audit_thread_constants import ( # noqa: E402
|
|
|
69
71
|
STATE_CLEAN,
|
|
70
72
|
STATE_DIRTY,
|
|
71
73
|
)
|
|
72
|
-
from post_audit_thread import
|
|
74
|
+
from post_audit_thread import ( # noqa: E402
|
|
75
|
+
UserInputError,
|
|
76
|
+
build_reviews_endpoint_url,
|
|
77
|
+
fetch_gh_token_for_account,
|
|
78
|
+
list_authenticated_gh_account_logins,
|
|
79
|
+
query_active_gh_user_login,
|
|
80
|
+
query_pull_request_author_login,
|
|
81
|
+
resolve_reviewer_token,
|
|
82
|
+
)
|
|
73
83
|
|
|
74
84
|
LIVE_TEST_OWNER = "JonEcho"
|
|
75
85
|
LIVE_TEST_REPO = "tests"
|
|
@@ -918,6 +928,189 @@ class LivePostAuditThreadTests(unittest.TestCase):
|
|
|
918
928
|
f"(1s + 4s + 16s); elapsed={elapsed_seconds:.2f}s",
|
|
919
929
|
)
|
|
920
930
|
|
|
931
|
+
def _isolate_auth_env_vars(self) -> dict[str, str | None]:
|
|
932
|
+
all_managed_env_var_names = (
|
|
933
|
+
GH_TOKEN_ENV_VAR_NAME,
|
|
934
|
+
GITHUB_TOKEN_ENV_VAR_NAME,
|
|
935
|
+
BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME,
|
|
936
|
+
)
|
|
937
|
+
previous_env_state: dict[str, str | None] = {
|
|
938
|
+
each_name: os.environ.get(each_name)
|
|
939
|
+
for each_name in all_managed_env_var_names
|
|
940
|
+
}
|
|
941
|
+
for each_name in all_managed_env_var_names:
|
|
942
|
+
os.environ.pop(each_name, None)
|
|
943
|
+
return previous_env_state
|
|
944
|
+
|
|
945
|
+
def _restore_auth_env_vars(
|
|
946
|
+
self, previous_env_state: dict[str, str | None]
|
|
947
|
+
) -> None:
|
|
948
|
+
for each_name, prior_value in previous_env_state.items():
|
|
949
|
+
if prior_value is None:
|
|
950
|
+
os.environ.pop(each_name, None)
|
|
951
|
+
else:
|
|
952
|
+
os.environ[each_name] = prior_value
|
|
953
|
+
|
|
954
|
+
def test_query_active_gh_user_login_matches_gh_api_user_login_field(self) -> None:
|
|
955
|
+
active_login = query_active_gh_user_login()
|
|
956
|
+
self.assertTrue(
|
|
957
|
+
active_login,
|
|
958
|
+
"query_active_gh_user_login() returned empty",
|
|
959
|
+
)
|
|
960
|
+
gh_api_user_response = gh_api_object_json("user")
|
|
961
|
+
self.assertEqual(active_login, gh_api_user_response.get("login"))
|
|
962
|
+
|
|
963
|
+
def test_query_pull_request_author_login_matches_throwaway_pr_author(self) -> None:
|
|
964
|
+
author_login = query_pull_request_author_login(
|
|
965
|
+
owner=LIVE_TEST_OWNER,
|
|
966
|
+
repo=LIVE_TEST_REPO,
|
|
967
|
+
pr_number=self.pr_number,
|
|
968
|
+
)
|
|
969
|
+
pr_detail_path = f"repos/{LIVE_TEST_OWNER}/{LIVE_TEST_REPO}/pulls/{self.pr_number}"
|
|
970
|
+
pr_detail_object = gh_api_object_json(pr_detail_path)
|
|
971
|
+
user_field_object = pr_detail_object.get("user")
|
|
972
|
+
self.assertIsInstance(user_field_object, dict)
|
|
973
|
+
if isinstance(user_field_object, dict):
|
|
974
|
+
self.assertEqual(author_login, user_field_object.get("login"))
|
|
975
|
+
|
|
976
|
+
def test_list_authenticated_gh_account_logins_includes_active_and_audit_accounts(
|
|
977
|
+
self,
|
|
978
|
+
) -> None:
|
|
979
|
+
all_logins = list_authenticated_gh_account_logins()
|
|
980
|
+
active_login = query_active_gh_user_login()
|
|
981
|
+
self.assertIn(active_login, all_logins)
|
|
982
|
+
self.assertIn(LIVE_TEST_AUDIT_ACCOUNT_NAME, all_logins)
|
|
983
|
+
|
|
984
|
+
def test_fetch_gh_token_for_account_returns_audit_account_cached_token(self) -> None:
|
|
985
|
+
fetched_token = fetch_gh_token_for_account(LIVE_TEST_AUDIT_ACCOUNT_NAME)
|
|
986
|
+
self.assertEqual(fetched_token, self.audit_account_token)
|
|
987
|
+
|
|
988
|
+
def test_resolve_reviewer_token_returns_env_var_when_gh_token_is_set(self) -> None:
|
|
989
|
+
sentinel_env_token = "sentinel-gh-token-from-env-var-precedence-test"
|
|
990
|
+
previous_env_state = self._isolate_auth_env_vars()
|
|
991
|
+
try:
|
|
992
|
+
os.environ[GH_TOKEN_ENV_VAR_NAME] = sentinel_env_token
|
|
993
|
+
returned_token = resolve_reviewer_token(
|
|
994
|
+
owner=LIVE_TEST_OWNER,
|
|
995
|
+
repo=LIVE_TEST_REPO,
|
|
996
|
+
pr_number=self.pr_number,
|
|
997
|
+
)
|
|
998
|
+
self.assertEqual(returned_token, sentinel_env_token)
|
|
999
|
+
finally:
|
|
1000
|
+
self._restore_auth_env_vars(previous_env_state)
|
|
1001
|
+
|
|
1002
|
+
def test_resolve_reviewer_token_toggles_to_alternate_token_on_self_pr(self) -> None:
|
|
1003
|
+
previous_env_state = self._isolate_auth_env_vars()
|
|
1004
|
+
try:
|
|
1005
|
+
returned_token = resolve_reviewer_token(
|
|
1006
|
+
owner=LIVE_TEST_OWNER,
|
|
1007
|
+
repo=LIVE_TEST_REPO,
|
|
1008
|
+
pr_number=self.pr_number,
|
|
1009
|
+
)
|
|
1010
|
+
active_login = query_active_gh_user_login()
|
|
1011
|
+
pr_author_login = query_pull_request_author_login(
|
|
1012
|
+
owner=LIVE_TEST_OWNER,
|
|
1013
|
+
repo=LIVE_TEST_REPO,
|
|
1014
|
+
pr_number=self.pr_number,
|
|
1015
|
+
)
|
|
1016
|
+
self.assertEqual(
|
|
1017
|
+
active_login.lower(),
|
|
1018
|
+
pr_author_login.lower(),
|
|
1019
|
+
"throwaway PR author must equal active gh account so the "
|
|
1020
|
+
"self-PR toggle branch is exercised",
|
|
1021
|
+
)
|
|
1022
|
+
all_alternates = [
|
|
1023
|
+
each_login
|
|
1024
|
+
for each_login in list_authenticated_gh_account_logins()
|
|
1025
|
+
if each_login.lower() != pr_author_login.lower()
|
|
1026
|
+
]
|
|
1027
|
+
self.assertTrue(
|
|
1028
|
+
all_alternates,
|
|
1029
|
+
"test setup requires at least one alternate authenticated account",
|
|
1030
|
+
)
|
|
1031
|
+
expected_first_alternate_token = fetch_gh_token_for_account(
|
|
1032
|
+
all_alternates[0]
|
|
1033
|
+
)
|
|
1034
|
+
self.assertEqual(returned_token, expected_first_alternate_token)
|
|
1035
|
+
active_account_token = resolve_gh_auth_token()
|
|
1036
|
+
self.assertNotEqual(
|
|
1037
|
+
returned_token,
|
|
1038
|
+
active_account_token,
|
|
1039
|
+
"self-PR toggle must not return the active (author) token",
|
|
1040
|
+
)
|
|
1041
|
+
finally:
|
|
1042
|
+
self._restore_auth_env_vars(previous_env_state)
|
|
1043
|
+
|
|
1044
|
+
def test_resolve_reviewer_token_honors_bugteam_reviewer_account_pin(self) -> None:
|
|
1045
|
+
previous_env_state = self._isolate_auth_env_vars()
|
|
1046
|
+
try:
|
|
1047
|
+
pr_author_login = query_pull_request_author_login(
|
|
1048
|
+
owner=LIVE_TEST_OWNER,
|
|
1049
|
+
repo=LIVE_TEST_REPO,
|
|
1050
|
+
pr_number=self.pr_number,
|
|
1051
|
+
)
|
|
1052
|
+
all_alternates_excluding_pr_author = [
|
|
1053
|
+
each_login
|
|
1054
|
+
for each_login in list_authenticated_gh_account_logins()
|
|
1055
|
+
if each_login.lower() != pr_author_login.lower()
|
|
1056
|
+
]
|
|
1057
|
+
self.assertTrue(
|
|
1058
|
+
all_alternates_excluding_pr_author,
|
|
1059
|
+
"test setup requires at least one authenticated account that "
|
|
1060
|
+
"is not the PR author so the pin has a valid target",
|
|
1061
|
+
)
|
|
1062
|
+
chosen_pin_login = all_alternates_excluding_pr_author[0]
|
|
1063
|
+
os.environ[BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME] = chosen_pin_login
|
|
1064
|
+
returned_token = resolve_reviewer_token(
|
|
1065
|
+
owner=LIVE_TEST_OWNER,
|
|
1066
|
+
repo=LIVE_TEST_REPO,
|
|
1067
|
+
pr_number=self.pr_number,
|
|
1068
|
+
)
|
|
1069
|
+
expected_pinned_token = fetch_gh_token_for_account(chosen_pin_login)
|
|
1070
|
+
self.assertEqual(returned_token, expected_pinned_token)
|
|
1071
|
+
finally:
|
|
1072
|
+
self._restore_auth_env_vars(previous_env_state)
|
|
1073
|
+
|
|
1074
|
+
def test_resolve_reviewer_token_error_excludes_pr_author_from_candidate_set(
|
|
1075
|
+
self,
|
|
1076
|
+
) -> None:
|
|
1077
|
+
unauthenticated_account_name = "intentionally-not-authenticated-account-zzz"
|
|
1078
|
+
previous_env_state = self._isolate_auth_env_vars()
|
|
1079
|
+
try:
|
|
1080
|
+
os.environ[BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME] = (
|
|
1081
|
+
unauthenticated_account_name
|
|
1082
|
+
)
|
|
1083
|
+
with self.assertRaises(UserInputError) as raised_context:
|
|
1084
|
+
resolve_reviewer_token(
|
|
1085
|
+
owner=LIVE_TEST_OWNER,
|
|
1086
|
+
repo=LIVE_TEST_REPO,
|
|
1087
|
+
pr_number=self.pr_number,
|
|
1088
|
+
)
|
|
1089
|
+
error_message_text = str(raised_context.exception)
|
|
1090
|
+
self.assertIn(unauthenticated_account_name, error_message_text)
|
|
1091
|
+
pr_author_login = query_pull_request_author_login(
|
|
1092
|
+
owner=LIVE_TEST_OWNER,
|
|
1093
|
+
repo=LIVE_TEST_REPO,
|
|
1094
|
+
pr_number=self.pr_number,
|
|
1095
|
+
)
|
|
1096
|
+
all_alternates_at_call_time = [
|
|
1097
|
+
each_login
|
|
1098
|
+
for each_login in list_authenticated_gh_account_logins()
|
|
1099
|
+
if each_login.lower() != pr_author_login.lower()
|
|
1100
|
+
]
|
|
1101
|
+
self.assertIn(
|
|
1102
|
+
repr(all_alternates_at_call_time),
|
|
1103
|
+
error_message_text,
|
|
1104
|
+
"error must show the alternate-reviewer set actually searched",
|
|
1105
|
+
)
|
|
1106
|
+
self.assertNotIn(
|
|
1107
|
+
f"authenticated set [{repr(pr_author_login)}",
|
|
1108
|
+
error_message_text,
|
|
1109
|
+
"error must not show a set whose head is the excluded PR author",
|
|
1110
|
+
)
|
|
1111
|
+
finally:
|
|
1112
|
+
self._restore_auth_env_vars(previous_env_state)
|
|
1113
|
+
|
|
921
1114
|
|
|
922
1115
|
if __name__ == "__main__":
|
|
923
1116
|
unittest.main()
|
|
@@ -690,3 +690,44 @@ def test_main_prints_no_related_tests_when_get_changed_files_returns_empty(
|
|
|
690
690
|
assert exit_code == 0
|
|
691
691
|
captured = capsys.readouterr()
|
|
692
692
|
assert "no related tests found" in captured.err
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def test_main_should_halt_when_env_var_lists_bugteam(
|
|
696
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
697
|
+
capsys: pytest.CaptureFixture[str],
|
|
698
|
+
) -> None:
|
|
699
|
+
"""CLAUDE_REVIEWS_DISABLED=bugteam must halt preflight with the dedicated exit code."""
|
|
700
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugteam")
|
|
701
|
+
monkeypatch.delenv("BUGTEAM_PREFLIGHT_SKIP", raising=False)
|
|
702
|
+
exit_code = preflight.main(["--no-pytest"])
|
|
703
|
+
assert exit_code == preflight.EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV
|
|
704
|
+
captured = capsys.readouterr()
|
|
705
|
+
assert "CLAUDE_REVIEWS_DISABLED" in captured.err
|
|
706
|
+
assert "bugteam" in captured.err
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def test_main_should_continue_when_env_var_omits_bugteam(
|
|
710
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
711
|
+
tmp_path: Path,
|
|
712
|
+
) -> None:
|
|
713
|
+
"""CLAUDE_REVIEWS_DISABLED without the bugteam token must not halt preflight."""
|
|
714
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "copilot,bugbot")
|
|
715
|
+
monkeypatch.delenv("BUGTEAM_PREFLIGHT_SKIP", raising=False)
|
|
716
|
+
claude_hooks_path = tmp_path / ".claude" / "hooks" / "git-hooks"
|
|
717
|
+
claude_hooks_path.mkdir(parents=True)
|
|
718
|
+
with patch("subprocess.run") as mock_run:
|
|
719
|
+
mock_run.return_value = _make_completed_process(
|
|
720
|
+
str(claude_hooks_path) + "\n", returncode=0
|
|
721
|
+
)
|
|
722
|
+
exit_code = preflight.main(["--no-pytest"])
|
|
723
|
+
assert exit_code != preflight.EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def test_main_should_halt_when_env_var_contains_uppercase_or_whitespace_bugteam_token(
|
|
727
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
728
|
+
) -> None:
|
|
729
|
+
"""Token matching must be case-insensitive and whitespace-tolerant."""
|
|
730
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", " BugTeam , copilot ")
|
|
731
|
+
monkeypatch.delenv("BUGTEAM_PREFLIGHT_SKIP", raising=False)
|
|
732
|
+
exit_code = preflight.main(["--no-pytest"])
|
|
733
|
+
assert exit_code == preflight.EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Direct unit tests for the shared reviews_disabled helper."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_reviews_disabled_module() -> ModuleType:
|
|
11
|
+
module_path = Path(__file__).parent.parent / "reviews_disabled.py"
|
|
12
|
+
specification = importlib.util.spec_from_file_location(
|
|
13
|
+
"reviews_disabled", module_path
|
|
14
|
+
)
|
|
15
|
+
assert specification is not None
|
|
16
|
+
assert specification.loader is not None
|
|
17
|
+
module = importlib.util.module_from_spec(specification)
|
|
18
|
+
specification.loader.exec_module(module)
|
|
19
|
+
return module
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
reviews_disabled = _load_reviews_disabled_module()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_is_bugteam_disabled_via_env_returns_true_when_env_lists_bugteam(
|
|
26
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
27
|
+
) -> None:
|
|
28
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugteam")
|
|
29
|
+
assert reviews_disabled.is_bugteam_disabled_via_env() is True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_is_bugteam_disabled_via_env_returns_false_when_env_is_empty(
|
|
33
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
34
|
+
) -> None:
|
|
35
|
+
monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
|
|
36
|
+
assert reviews_disabled.is_bugteam_disabled_via_env() is False
|
|
@@ -43,7 +43,7 @@ def test_revoke_module_guards_sys_path_insert_against_duplicates() -> None:
|
|
|
43
43
|
module_source = (
|
|
44
44
|
Path(__file__).parent.parent / "revoke_project_claude_permissions.py"
|
|
45
45
|
).read_text(encoding="utf-8")
|
|
46
|
-
assert "if
|
|
46
|
+
assert "if parent_directory not in sys.path:" in module_source, (
|
|
47
47
|
"revoke_project_claude_permissions.py must guard sys.path.insert against "
|
|
48
48
|
"duplicate entries on reload (consistent with sibling modules)"
|
|
49
49
|
)
|