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.
Files changed (60) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +10 -0
  3. package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +53 -3
  5. package/_shared/pr-loop/scripts/post_audit_thread.py +298 -3
  6. package/_shared/pr-loop/scripts/preflight.py +129 -2
  7. package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +68 -3
  9. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
  10. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +194 -1
  11. package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
  12. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  14. package/agents/pr-description-writer.md +150 -52
  15. package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
  16. package/hooks/_gh_pr_author_swap_utils.py +1211 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +9 -6
  18. package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
  19. package/hooks/blocking/gh_pr_author_restore.py +100 -0
  20. package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
  21. package/hooks/blocking/pr_description_enforcer.py +56 -23
  22. package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
  23. package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
  24. package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
  25. package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
  26. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
  27. package/hooks/blocking/test_pr_description_enforcer.py +69 -8
  28. package/hooks/config/gh_pr_author_swap_constants.py +76 -0
  29. package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
  30. package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
  31. package/hooks/config/pr_description_enforcer_constants.py +19 -0
  32. package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
  33. package/hooks/hooks.json +40 -0
  34. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
  35. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
  36. package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
  37. package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
  38. package/hooks/test__gh_pr_author_swap_utils.py +333 -0
  39. package/package.json +1 -1
  40. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  41. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  42. package/skills/bugteam/SKILL.md +28 -10
  43. package/skills/bugteam/reference/audit-contract.md +22 -0
  44. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  45. package/skills/bugteam/reference/team-setup.md +5 -0
  46. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
  47. package/skills/bugteam/scripts/bugteam_preflight.py +36 -2
  48. package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
  49. package/skills/bugteam/scripts/test_bugteam_preflight.py +41 -0
  50. package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
  51. package/skills/copilot-review/SKILL.md +16 -0
  52. package/skills/findbugs/SKILL.md +35 -7
  53. package/skills/monitor-open-prs/SKILL.md +2 -1
  54. package/skills/pr-converge/SKILL.md +11 -3
  55. package/skills/pr-converge/config/constants.py +3 -1
  56. package/skills/pr-converge/reference/per-tick.md +17 -0
  57. package/skills/pr-converge/reference/state-schema.md +36 -8
  58. package/skills/pr-converge/scripts/check_bugbot_ci.py +113 -8
  59. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
  60. 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
- sys.modules.pop("config", None)
14
- if str(Path(__file__).resolve().parent) not in sys.path:
15
- sys.path.insert(0, str(Path(__file__).resolve().parent))
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 str(Path(__file__).resolve().parent) not in sys.path:" in module_source, (
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 build_reviews_endpoint_url # noqa: E402
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 str(Path(__file__).resolve().parent) not in sys.path:" in module_source, (
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
  )