claude-dev-env 1.39.0 → 1.40.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 (27) hide show
  1. package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +10 -0
  2. package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
  3. package/_shared/pr-loop/scripts/post_audit_thread.py +296 -1
  4. package/_shared/pr-loop/scripts/preflight.py +129 -2
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
  6. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +194 -1
  7. package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
  8. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
  9. package/agents/pr-description-writer.md +150 -52
  10. package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
  11. package/hooks/blocking/pr_description_enforcer.py +57 -22
  12. package/hooks/blocking/test_pr_description_enforcer.py +69 -8
  13. package/hooks/config/pr_description_enforcer_constants.py +14 -0
  14. package/package.json +1 -1
  15. package/skills/bugteam/SKILL.md +28 -10
  16. package/skills/bugteam/reference/team-setup.md +5 -0
  17. package/skills/bugteam/scripts/bugteam_preflight.py +36 -2
  18. package/skills/bugteam/scripts/test_bugteam_preflight.py +41 -0
  19. package/skills/copilot-review/SKILL.md +16 -0
  20. package/skills/findbugs/SKILL.md +35 -7
  21. package/skills/monitor-open-prs/SKILL.md +2 -1
  22. package/skills/pr-converge/SKILL.md +3 -1
  23. package/skills/pr-converge/config/constants.py +1 -0
  24. package/skills/pr-converge/reference/per-tick.md +17 -0
  25. package/skills/pr-converge/scripts/check_bugbot_ci.py +113 -8
  26. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
  27. package/skills/qbug/SKILL.md +33 -8
@@ -69,6 +69,15 @@ EXIT_CODE_RETRY_EXHAUSTED: int = 2
69
69
  SHORT_SHA_LENGTH: int = 7
70
70
 
71
71
  ALL_GH_AUTH_TOKEN_COMMAND_PARTS: tuple[str, ...] = ("gh", "auth", "token")
72
+ ALL_GH_API_USER_COMMAND_PARTS: tuple[str, ...] = ("gh", "api", "user")
73
+ ALL_GH_AUTH_STATUS_COMMAND_PARTS: tuple[str, ...] = ("gh", "auth", "status")
74
+ ALL_GH_API_COMMAND_PARTS: tuple[str, ...] = ("gh", "api")
75
+ GH_AUTH_TOKEN_USER_FLAG: str = "--user"
76
+ GH_USER_LOGIN_FIELD: str = "login"
77
+ GH_PR_USER_FIELD: str = "user"
78
+ GH_API_PR_PATH_TEMPLATE: str = "repos/{owner}/{repo}/pulls/{pr_number}"
79
+ GH_AUTH_STATUS_ACCOUNT_LINE_MARKER: str = "Logged in to github.com account"
80
+ GH_AUTH_STATUS_ACCOUNT_LINE_TOKEN_SEPARATOR: str = " "
72
81
 
73
82
  GH_TOKEN_ENV_VAR_NAME: str = "GH_TOKEN"
74
83
  GITHUB_TOKEN_ENV_VAR_NAME: str = "GITHUB_TOKEN"
@@ -76,6 +85,7 @@ ALL_GH_TOKEN_ENV_VAR_NAMES: tuple[str, ...] = (
76
85
  GH_TOKEN_ENV_VAR_NAME,
77
86
  GITHUB_TOKEN_ENV_VAR_NAME,
78
87
  )
88
+ BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME: str = "BUGTEAM_REVIEWER_ACCOUNT"
79
89
 
80
90
  JSON_FIELD_PATH: str = "path"
81
91
  JSON_FIELD_LINE: str = "line"
@@ -0,0 +1,8 @@
1
+ """Configuration constants for the CLAUDE_REVIEWS_DISABLED opt-out gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME: str = "CLAUDE_REVIEWS_DISABLED"
6
+ CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR: str = ","
7
+ CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN: str = "bugteam"
8
+ EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV: int = 7
@@ -37,6 +37,9 @@ if str(Path(__file__).resolve().parent) not in sys.path:
37
37
  sys.path.insert(0, str(Path(__file__).resolve().parent))
38
38
 
39
39
  from config.post_audit_thread_constants import (
40
+ ALL_GH_API_COMMAND_PARTS,
41
+ ALL_GH_API_USER_COMMAND_PARTS,
42
+ ALL_GH_AUTH_STATUS_COMMAND_PARTS,
40
43
  ALL_GH_AUTH_TOKEN_COMMAND_PARTS,
41
44
  ALL_GH_TOKEN_ENV_VAR_NAMES,
42
45
  ALL_REQUIRED_FINDING_FIELDS,
@@ -47,6 +50,7 @@ from config.post_audit_thread_constants import (
47
50
  ALL_SUPPORTED_STATES,
48
51
  AUDIT_BODY_SKELETON_CLOSE_MARKER,
49
52
  AUDIT_BODY_SKELETON_OPEN_MARKER,
53
+ BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME,
50
54
  CLI_FLAG_COMMIT,
51
55
  CLI_FLAG_FINDINGS_JSON,
52
56
  CLI_FLAG_OWNER,
@@ -60,6 +64,12 @@ from config.post_audit_thread_constants import (
60
64
  ERROR_RESPONSE_PREVIEW_CHARS,
61
65
  EXIT_CODE_RETRY_EXHAUSTED,
62
66
  EXIT_CODE_USER_ERROR,
67
+ GH_API_PR_PATH_TEMPLATE,
68
+ GH_AUTH_STATUS_ACCOUNT_LINE_MARKER,
69
+ GH_AUTH_STATUS_ACCOUNT_LINE_TOKEN_SEPARATOR,
70
+ GH_AUTH_TOKEN_USER_FLAG,
71
+ GH_PR_USER_FIELD,
72
+ GH_USER_LOGIN_FIELD,
63
73
  GITHUB_API_ACCEPT_HEADER,
64
74
  GITHUB_API_BASE_URL,
65
75
  GITHUB_API_USER_AGENT,
@@ -682,6 +692,287 @@ def resolve_github_token() -> str:
682
692
  return token_text
683
693
 
684
694
 
695
+ def query_active_gh_user_login() -> str:
696
+ """Return the login of the gh account that owns the current ``gh auth token``.
697
+
698
+ Calls ``gh api /user`` and reads ``.login`` off the response. The result
699
+ is the gh CLI's currently active account — the one whose token a default
700
+ ``gh auth token`` call would emit.
701
+
702
+ Returns:
703
+ Login string of the active github.com account.
704
+
705
+ Raises:
706
+ UserInputError: ``gh`` not on PATH, the ``gh api /user`` call fails,
707
+ or the response is missing a string ``login`` field.
708
+ """
709
+ try:
710
+ completion = subprocess.run(
711
+ list(ALL_GH_API_USER_COMMAND_PARTS),
712
+ capture_output=True,
713
+ text=True,
714
+ encoding="utf-8",
715
+ errors="replace",
716
+ check=False,
717
+ )
718
+ except FileNotFoundError as missing_gh_error:
719
+ raise UserInputError(
720
+ "`gh` CLI not installed or not on PATH; cannot query the active "
721
+ "github.com account login"
722
+ ) from missing_gh_error
723
+ if completion.returncode != 0:
724
+ raise UserInputError(
725
+ f"`gh api /user` failed (exit {completion.returncode}): "
726
+ f"{completion.stderr.strip()}"
727
+ )
728
+ try:
729
+ parsed_value: object = json.loads(completion.stdout)
730
+ except json.JSONDecodeError as decode_error:
731
+ raise UserInputError(
732
+ f"`gh api /user` response not parseable as JSON: {decode_error}"
733
+ ) from decode_error
734
+ if not isinstance(parsed_value, dict):
735
+ raise UserInputError(
736
+ f"`gh api /user` response root must be an object; "
737
+ f"got {type(parsed_value).__name__}"
738
+ )
739
+ typed_response: dict[str, object] = parsed_value
740
+ login_value = typed_response.get(GH_USER_LOGIN_FIELD)
741
+ if not isinstance(login_value, str) or not login_value:
742
+ raise UserInputError(
743
+ f"`gh api /user` response missing string {GH_USER_LOGIN_FIELD!r}"
744
+ )
745
+ return login_value
746
+
747
+
748
+ def query_pull_request_author_login(owner: str, repo: str, pr_number: int) -> str:
749
+ """Return the login of the user who authored a pull request.
750
+
751
+ Calls ``gh api /repos/{owner}/{repo}/pulls/{N}`` and reads ``.user.login``
752
+ off the response.
753
+
754
+ Args:
755
+ owner: Repository owner slug.
756
+ repo: Repository name slug.
757
+ pr_number: Pull request number.
758
+
759
+ Returns:
760
+ Login string of the PR author.
761
+
762
+ Raises:
763
+ UserInputError: ``gh api`` call fails, response malformed, or the
764
+ nested ``user.login`` field is missing.
765
+ """
766
+ pull_request_api_path = GH_API_PR_PATH_TEMPLATE.format(
767
+ owner=owner, repo=repo, pr_number=pr_number,
768
+ )
769
+ try:
770
+ completion = subprocess.run(
771
+ list(ALL_GH_API_COMMAND_PARTS) + [pull_request_api_path],
772
+ capture_output=True,
773
+ text=True,
774
+ encoding="utf-8",
775
+ errors="replace",
776
+ check=False,
777
+ )
778
+ except FileNotFoundError as missing_gh_error:
779
+ raise UserInputError(
780
+ "`gh` CLI not installed or not on PATH; cannot query the PR "
781
+ "author login"
782
+ ) from missing_gh_error
783
+ if completion.returncode != 0:
784
+ raise UserInputError(
785
+ f"`gh api {pull_request_api_path}` failed (exit "
786
+ f"{completion.returncode}): {completion.stderr.strip()}"
787
+ )
788
+ try:
789
+ parsed_value: object = json.loads(completion.stdout)
790
+ except json.JSONDecodeError as decode_error:
791
+ raise UserInputError(
792
+ f"`gh api {pull_request_api_path}` response not parseable as "
793
+ f"JSON: {decode_error}"
794
+ ) from decode_error
795
+ if not isinstance(parsed_value, dict):
796
+ raise UserInputError(
797
+ f"`gh api {pull_request_api_path}` response root must be an "
798
+ f"object; got {type(parsed_value).__name__}"
799
+ )
800
+ typed_response: dict[str, object] = parsed_value
801
+ user_field = typed_response.get(GH_PR_USER_FIELD)
802
+ if not isinstance(user_field, dict):
803
+ raise UserInputError(
804
+ f"PR response missing object {GH_PR_USER_FIELD!r}"
805
+ )
806
+ typed_user: dict[str, object] = user_field
807
+ login_value = typed_user.get(GH_USER_LOGIN_FIELD)
808
+ if not isinstance(login_value, str) or not login_value:
809
+ raise UserInputError(
810
+ f"PR author missing string {GH_USER_LOGIN_FIELD!r} field"
811
+ )
812
+ return login_value
813
+
814
+
815
+ def list_authenticated_gh_account_logins() -> list[str]:
816
+ """Return every github.com account login currently authenticated via gh.
817
+
818
+ Parses ``gh auth status`` output line-by-line. The CLI writes its
819
+ human-readable status to stderr by default; the function reads both
820
+ stdout and stderr to be resilient to the gh version in use.
821
+
822
+ Returns:
823
+ List of login strings in the order ``gh auth status`` reports them.
824
+ Empty list when no accounts are logged in.
825
+
826
+ Raises:
827
+ UserInputError: ``gh`` not on PATH.
828
+ """
829
+ try:
830
+ completion = subprocess.run(
831
+ list(ALL_GH_AUTH_STATUS_COMMAND_PARTS),
832
+ capture_output=True,
833
+ text=True,
834
+ encoding="utf-8",
835
+ errors="replace",
836
+ check=False,
837
+ )
838
+ except FileNotFoundError as missing_gh_error:
839
+ raise UserInputError(
840
+ "`gh` CLI not installed or not on PATH; cannot list "
841
+ "authenticated github.com accounts"
842
+ ) from missing_gh_error
843
+ output_text = (completion.stdout or "") + (completion.stderr or "")
844
+ parsed_logins: list[str] = []
845
+ for each_line in output_text.splitlines():
846
+ marker_index = each_line.find(GH_AUTH_STATUS_ACCOUNT_LINE_MARKER)
847
+ if marker_index < 0:
848
+ continue
849
+ remainder = each_line[marker_index + len(GH_AUTH_STATUS_ACCOUNT_LINE_MARKER):].strip()
850
+ space_index = remainder.find(GH_AUTH_STATUS_ACCOUNT_LINE_TOKEN_SEPARATOR)
851
+ login_candidate = remainder[:space_index] if space_index >= 0 else remainder
852
+ if login_candidate and login_candidate not in parsed_logins:
853
+ parsed_logins.append(login_candidate)
854
+ return parsed_logins
855
+
856
+
857
+ def fetch_gh_token_for_account(account_login: str) -> str:
858
+ """Return the cached gh token for a specific authenticated account.
859
+
860
+ Calls ``gh auth token --user <login>``. Does not mutate which account
861
+ is "active" in the gh CLI; only retrieves a stored token.
862
+
863
+ Args:
864
+ account_login: github.com login whose token should be returned.
865
+
866
+ Returns:
867
+ Cached gh token string, stripped of trailing whitespace.
868
+
869
+ Raises:
870
+ UserInputError: ``gh`` not on PATH, the call fails, or it returns
871
+ empty output.
872
+ """
873
+ try:
874
+ completion = subprocess.run(
875
+ list(ALL_GH_AUTH_TOKEN_COMMAND_PARTS) + [GH_AUTH_TOKEN_USER_FLAG, account_login],
876
+ capture_output=True,
877
+ text=True,
878
+ encoding="utf-8",
879
+ errors="replace",
880
+ check=False,
881
+ )
882
+ except FileNotFoundError as missing_gh_error:
883
+ raise UserInputError(
884
+ f"`gh` CLI not installed or not on PATH; cannot fetch token "
885
+ f"for account {account_login!r}"
886
+ ) from missing_gh_error
887
+ if completion.returncode != 0:
888
+ raise UserInputError(
889
+ f"`gh auth token --user {account_login}` failed (exit "
890
+ f"{completion.returncode}): {completion.stderr.strip()}"
891
+ )
892
+ token_text = completion.stdout.strip()
893
+ if not token_text:
894
+ raise UserInputError(
895
+ f"`gh auth token --user {account_login}` returned empty output"
896
+ )
897
+ return token_text
898
+
899
+
900
+ def resolve_reviewer_token(owner: str, repo: str, pr_number: int) -> str:
901
+ """Return the GitHub token to use for the reviews POST, auto-toggling on self-PR.
902
+
903
+ Precedence rules, evaluated in order:
904
+
905
+ - ``GH_TOKEN`` / ``GITHUB_TOKEN`` env var set → returned unchanged; no
906
+ toggle attempt.
907
+ - Active gh account differs ``vs.`` PR author → return the active
908
+ account's token via :func:`resolve_github_token` (no toggle).
909
+ - Active gh account matches PR author (self-PR) → if the env var
910
+ ``BUGTEAM_REVIEWER_ACCOUNT`` names an authenticated alternate, use
911
+ that account's token; else fall back to the first alternate
912
+ authenticated account ``gh auth status`` reports. Token is fetched
913
+ via :func:`fetch_gh_token_for_account`. The active account is not
914
+ mutated; only the token sent on the reviews request changes.
915
+
916
+ Args:
917
+ owner: Repository owner slug.
918
+ repo: Repository name slug.
919
+ pr_number: Pull request number whose author dictates whether a
920
+ toggle is needed.
921
+
922
+ Returns:
923
+ Bearer-token string suitable for the reviews POST.
924
+
925
+ Raises:
926
+ UserInputError: self-PR detected and no alternate gh account is
927
+ authenticated, or any underlying gh query fails.
928
+ """
929
+ for each_env_var_name in ALL_GH_TOKEN_ENV_VAR_NAMES:
930
+ env_token_value = os.environ.get(each_env_var_name, "").strip()
931
+ if env_token_value:
932
+ return env_token_value
933
+ active_account_login = query_active_gh_user_login()
934
+ pr_author_login = query_pull_request_author_login(owner, repo, pr_number)
935
+ if active_account_login.lower() != pr_author_login.lower():
936
+ return resolve_github_token()
937
+ all_authenticated_logins = list_authenticated_gh_account_logins()
938
+ all_alternate_logins = [
939
+ each_login for each_login in all_authenticated_logins
940
+ if each_login.lower() != pr_author_login.lower()
941
+ ]
942
+ if not all_alternate_logins:
943
+ raise UserInputError(
944
+ f"Self-PR detected: active gh account {active_account_login!r} "
945
+ f"matches PR author. GitHub rejects APPROVE / REQUEST_CHANGES on "
946
+ f"self-authored PRs with HTTP 422. No alternate authenticated gh "
947
+ f"account found — run `gh auth login` as a separate reviewer "
948
+ f"account before invoking the audit skill."
949
+ )
950
+ pinned_reviewer_account = os.environ.get(
951
+ BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME, ""
952
+ ).strip()
953
+ if pinned_reviewer_account:
954
+ matching_pinned_account = next(
955
+ (
956
+ each_login for each_login in all_alternate_logins
957
+ if each_login.lower() == pinned_reviewer_account.lower()
958
+ ),
959
+ None,
960
+ )
961
+ if matching_pinned_account is None:
962
+ raise UserInputError(
963
+ f"Self-PR detected and "
964
+ f"{BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME}="
965
+ f"{pinned_reviewer_account!r} is set, but that account is "
966
+ f"not in the alternate-reviewer set "
967
+ f"{all_alternate_logins!r} (PR author "
968
+ f"{pr_author_login!r} is excluded). Run `gh auth login` "
969
+ f"for {pinned_reviewer_account!r} or unset the env var to "
970
+ f"fall back to the first alternate account."
971
+ )
972
+ return fetch_gh_token_for_account(matching_pinned_account)
973
+ return fetch_gh_token_for_account(all_alternate_logins[0])
974
+
975
+
685
976
  def build_reviews_endpoint_url(owner: str, repo: str, pr_number: int) -> str:
686
977
  """Compose the full reviews-endpoint URL for a PR.
687
978
 
@@ -915,7 +1206,11 @@ def post_audit_review(parsed_arguments: argparse.Namespace) -> PostedReview:
915
1206
  repo=parsed_arguments.repo,
916
1207
  pr_number=parsed_arguments.pr_number,
917
1208
  )
918
- token_text = resolve_github_token()
1209
+ token_text = resolve_reviewer_token(
1210
+ owner=parsed_arguments.owner,
1211
+ repo=parsed_arguments.repo,
1212
+ pr_number=parsed_arguments.pr_number,
1213
+ )
919
1214
  return post_review_with_retries(endpoint_url, token_text, all_request_fields)
920
1215
 
921
1216
 
@@ -57,6 +57,12 @@ from config.preflight_constants import (
57
57
  PYTHON_FILE_SUFFIX,
58
58
  TESTS_DIRECTORY_NAME,
59
59
  )
60
+ from reviews_disabled import (
61
+ CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN,
62
+ CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME,
63
+ EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV,
64
+ is_bugteam_disabled_via_env,
65
+ )
60
66
 
61
67
 
62
68
  def verify_git_hooks_path(repository_root: Path | None = None) -> int:
@@ -67,8 +73,13 @@ def verify_git_hooks_path(repository_root: Path | None = None) -> int:
67
73
  overrides such as Husky or lefthook. Falls back to the current working
68
74
  directory's effective config when *repository_root* is None.
69
75
 
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.
76
+ Args:
77
+ repository_root: Optional repository root to check. When None, uses
78
+ the current working directory's effective config.
79
+
80
+ Returns:
81
+ Zero when the configured path ends with the expected hooks suffix.
82
+ Non-zero and prints a correction message when unset or pointing elsewhere.
72
83
  """
73
84
  expected_hooks_path_suffix = HOOKS_PATH_VERIFICATION_SUFFIX
74
85
  enforcement_absent_message = (
@@ -123,6 +134,18 @@ def verify_git_hooks_path(repository_root: Path | None = None) -> int:
123
134
 
124
135
 
125
136
  def find_repository_root(start: Path) -> Path:
137
+ """Find the repository root by walking up from the starting directory.
138
+
139
+ Searches for a ``.git`` directory or file in parent directories. Falls
140
+ back to the nearest ancestor containing ``pytest.ini`` when no git
141
+ repository is found.
142
+
143
+ Args:
144
+ start: The directory to start searching from.
145
+
146
+ Returns:
147
+ The repository root path, or *start* when no repository is found.
148
+ """
126
149
  resolved = start.resolve()
127
150
  all_candidates = [resolved, *resolved.parents]
128
151
  for each_candidate in all_candidates:
@@ -136,6 +159,17 @@ def find_repository_root(start: Path) -> Path:
136
159
 
137
160
 
138
161
  def has_pytest_configuration(root: Path) -> bool:
162
+ """Check whether a directory has pytest configuration available.
163
+
164
+ Checks for ``pytest.ini`` directly, then falls back to searching for
165
+ ``[tool.pytest]`` in ``pyproject.toml``.
166
+
167
+ Args:
168
+ root: The directory to check for pytest configuration.
169
+
170
+ Returns:
171
+ True when pytest configuration is found in either location.
172
+ """
139
173
  if (root / PYTEST_INI_FILENAME).is_file():
140
174
  return True
141
175
  pyproject = root / PYPROJECT_TOML_FILENAME
@@ -146,6 +180,20 @@ def has_pytest_configuration(root: Path) -> bool:
146
180
 
147
181
 
148
182
  def has_discoverable_tests(root: Path) -> bool | None:
183
+ """Check whether the repository contains discoverable test files via git ls-files.
184
+
185
+ When the root has no ``.git`` marker, returns True without invoking git.
186
+ Otherwise asks git for tracked plus untracked test files matching the
187
+ discovery patterns, respecting ``.gitignore``.
188
+
189
+ Args:
190
+ root: The directory tree root to search.
191
+
192
+ Returns:
193
+ True when at least one matching test file is found. False when git
194
+ succeeds and returns an empty list. None when git is unavailable or
195
+ the ls-files invocation fails.
196
+ """
149
197
  git_marker = root / GIT_DIRECTORY_NAME
150
198
  if not (git_marker.is_dir() or git_marker.is_file()):
151
199
  return True
@@ -192,6 +240,21 @@ def run_pytest(
192
240
  verbose: bool,
193
241
  all_test_paths: list[Path] | None = None,
194
242
  ) -> int:
243
+ """Run pytest in the repository root and return the exit code.
244
+
245
+ Passes ``--ff`` (failed-first) and ``-q`` unless *verbose* is True. When
246
+ *all_test_paths* is provided, restricts the run to those paths via the
247
+ ``--`` positional separator so pytest does not misinterpret leading
248
+ hyphens as options. Treats the "no tests collected" exit code as a pass.
249
+
250
+ Args:
251
+ repository_root: The repository root for running pytest.
252
+ verbose: When True, omit ``-q`` so individual test names show.
253
+ all_test_paths: Optional list of test paths to restrict the run.
254
+
255
+ Returns:
256
+ The pytest exit code, or 0 when no tests were collected.
257
+ """
195
258
  command = [sys.executable, "-m", "pytest", PYTEST_FAILED_FIRST_FLAG]
196
259
  if not verbose:
197
260
  command.append("-q")
@@ -209,6 +272,20 @@ def run_pytest(
209
272
 
210
273
 
211
274
  def get_changed_files(repository_root: Path, base_ref: str) -> list[Path] | None:
275
+ """Return the list of files changed between *base_ref* and HEAD.
276
+
277
+ Refuses base refs beginning with ``-`` to prevent option injection into
278
+ git diff. Logs a warning and returns None on every failure path so the
279
+ caller can fall back to running the full suite.
280
+
281
+ Args:
282
+ repository_root: The repository root for running git diff.
283
+ base_ref: The git base ref to diff against (e.g., ``origin/main``).
284
+
285
+ Returns:
286
+ A list of relative file paths changed vs *base_ref*. None when
287
+ *base_ref* is invalid or git diff fails.
288
+ """
212
289
  if base_ref.startswith("-"):
213
290
  print(
214
291
  f"bugteam_preflight: invalid base_ref '{base_ref}' starts "
@@ -295,6 +372,18 @@ def _find_related_test_files(changed_path: Path, repository_root: Path) -> list[
295
372
  def discover_related_tests(
296
373
  all_changed_files: list[Path], repository_root: Path
297
374
  ) -> list[Path]:
375
+ """Discover all test files related to the given changed files.
376
+
377
+ Walks every changed path through :func:`_find_related_test_files` and
378
+ returns the sorted, de-duplicated union.
379
+
380
+ Args:
381
+ all_changed_files: The list of changed source files to map to tests.
382
+ repository_root: The repository root for resolving relative paths.
383
+
384
+ Returns:
385
+ Sorted list of unique related test file paths.
386
+ """
298
387
  related: set[Path] = set()
299
388
  for each_file in all_changed_files:
300
389
  related.update(_find_related_test_files(each_file, repository_root))
@@ -302,6 +391,14 @@ def discover_related_tests(
302
391
 
303
392
 
304
393
  def run_pre_commit(repository_root: Path) -> int:
394
+ """Run pre-commit on all files and return its exit code.
395
+
396
+ Args:
397
+ repository_root: The repository root for running pre-commit.
398
+
399
+ Returns:
400
+ The pre-commit exit code (0 on success, non-zero on failure).
401
+ """
305
402
  completed = subprocess.run(
306
403
  list(ALL_PRE_COMMIT_RUN_ALL_FILES_COMMAND),
307
404
  cwd=str(repository_root),
@@ -311,6 +408,15 @@ def run_pre_commit(repository_root: Path) -> int:
311
408
 
312
409
 
313
410
  def parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
411
+ """Parse command-line arguments for the preflight script.
412
+
413
+ Args:
414
+ all_arguments: Command-line argument list.
415
+
416
+ Returns:
417
+ Parsed namespace with repo_root, no_pytest, pre_commit, verbose,
418
+ base_ref, and scope attributes.
419
+ """
314
420
  parser = argparse.ArgumentParser(
315
421
  description="Run local checks before /bugteam (pytest, optional pre-commit).",
316
422
  )
@@ -360,6 +466,16 @@ def parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
360
466
 
361
467
 
362
468
  def main(all_arguments: list[str]) -> int:
469
+ """Run the preflight checks (git-hooks path, pytest, optional pre-commit).
470
+
471
+ Args:
472
+ all_arguments: Command-line argument list to forward to argparse.
473
+
474
+ Returns:
475
+ Zero on success. Non-zero exit code on the first failing check.
476
+ Returns :data:`EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV` when
477
+ ``CLAUDE_REVIEWS_DISABLED`` lists the ``bugteam`` token.
478
+ """
363
479
  arguments = parse_arguments(all_arguments)
364
480
  skip_env_var_name = BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME
365
481
  skip_enabled_value = BUGTEAM_PREFLIGHT_SKIP_ENABLED_VALUE
@@ -369,6 +485,17 @@ def main(all_arguments: list[str]) -> int:
369
485
  file=sys.stderr,
370
486
  )
371
487
  return 0
488
+ reviews_disabled_env_var_name = CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME
489
+ reviews_disabled_bugteam_token = CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN
490
+ disabled_via_env_exit_code = EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV
491
+ if is_bugteam_disabled_via_env():
492
+ print(
493
+ f"bugteam_preflight: halted "
494
+ f"({reviews_disabled_env_var_name} contains "
495
+ f"'{reviews_disabled_bugteam_token}').",
496
+ file=sys.stderr,
497
+ )
498
+ return disabled_via_env_exit_code
372
499
  start = Path.cwd()
373
500
  repository_root = (
374
501
  arguments.repo_root.resolve()
@@ -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