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
package/CLAUDE.md
CHANGED
|
@@ -5,7 +5,7 @@ The user delegates execution to you and expects zero manual steps unless strictl
|
|
|
5
5
|
## Code Rules
|
|
6
6
|
@~/.claude/docs/CODE_RULES.md
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
ALWAYS call the AskUserQuestion tool if you have a question for the user. Provide content-appropriate default options, with a flag for the recommended one.
|
|
9
9
|
|
|
10
10
|
## GOTCHAS
|
|
11
11
|
When making code changes, make sure you are working in the proper worktree path for the task at hand.
|
|
@@ -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
|
|
@@ -9,9 +9,22 @@ the changes applied. No-op when the entries already exist.
|
|
|
9
9
|
import sys
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
sys.path.
|
|
12
|
+
parent_directory = str(Path(__file__).absolute().parent)
|
|
13
|
+
try:
|
|
14
|
+
sys.path.remove(parent_directory)
|
|
15
|
+
except ValueError:
|
|
16
|
+
pass
|
|
17
|
+
if parent_directory not in sys.path:
|
|
18
|
+
sys.path.insert(0, parent_directory)
|
|
19
|
+
|
|
20
|
+
for each_cached_module_name in [
|
|
21
|
+
each_module_key
|
|
22
|
+
for each_module_key in list(sys.modules)
|
|
23
|
+
if each_module_key == "config"
|
|
24
|
+
or each_module_key.startswith("config.")
|
|
25
|
+
or each_module_key == "_claude_permissions_common"
|
|
26
|
+
]:
|
|
27
|
+
sys.modules.pop(each_cached_module_name, None)
|
|
15
28
|
|
|
16
29
|
from _claude_permissions_common import ( # noqa: E402
|
|
17
30
|
append_if_missing,
|
|
@@ -41,6 +54,15 @@ from config.claude_settings_keys_constants import ( # noqa: E402
|
|
|
41
54
|
def add_rules_to_allow_list(
|
|
42
55
|
all_settings: dict[str, object], all_rules_to_add: list[str]
|
|
43
56
|
) -> int:
|
|
57
|
+
"""Add permission rules to the settings allow list.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
all_settings: The parsed settings dictionary.
|
|
61
|
+
all_rules_to_add: Permission rule strings to append.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Number of rules actually added (new entries).
|
|
65
|
+
"""
|
|
44
66
|
permissions_section = ensure_dict_section(
|
|
45
67
|
all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
|
|
46
68
|
)
|
|
@@ -57,6 +79,15 @@ def add_rules_to_allow_list(
|
|
|
57
79
|
def add_directory_to_additional_directories(
|
|
58
80
|
all_settings: dict[str, object], directory_path: str
|
|
59
81
|
) -> int:
|
|
82
|
+
"""Add a project path to the additionalDirectories allow list.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
all_settings: The parsed settings dictionary.
|
|
86
|
+
directory_path: The project directory path to add.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
1 when the entry was added, 0 when it already existed.
|
|
90
|
+
"""
|
|
60
91
|
permissions_section = ensure_dict_section(
|
|
61
92
|
all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
|
|
62
93
|
)
|
|
@@ -71,6 +102,15 @@ def add_directory_to_additional_directories(
|
|
|
71
102
|
def add_auto_mode_environment_entry(
|
|
72
103
|
all_settings: dict[str, object], entry_text: str
|
|
73
104
|
) -> int:
|
|
105
|
+
"""Add an auto-mode environment entry for the project.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
all_settings: The parsed settings dictionary.
|
|
109
|
+
entry_text: The environment entry text to add.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
1 when the entry was added, 0 when it already existed.
|
|
113
|
+
"""
|
|
74
114
|
auto_mode_section = ensure_dict_section(
|
|
75
115
|
all_settings, CLAUDE_SETTINGS_AUTO_MODE_KEY
|
|
76
116
|
)
|
|
@@ -83,6 +123,16 @@ def add_auto_mode_environment_entry(
|
|
|
83
123
|
|
|
84
124
|
|
|
85
125
|
def grant_permissions_for_current_directory() -> None:
|
|
126
|
+
"""Grant Edit/Write/Read permissions for the current project directory.
|
|
127
|
+
|
|
128
|
+
Reads the current project path, constructs permission rules from config
|
|
129
|
+
constants, and writes them to ~/.claude/settings.json atomically.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
SystemExit: When the current directory is not a valid project root.
|
|
133
|
+
ValueError: Propagated from get_current_project_path() when the path
|
|
134
|
+
contains glob metacharacters.
|
|
135
|
+
"""
|
|
86
136
|
claude_user_settings_path: Path = get_claude_user_settings_path()
|
|
87
137
|
project_root_path = Path.cwd()
|
|
88
138
|
if not is_valid_project_root(project_root_path):
|
|
@@ -33,10 +33,13 @@ from pathlib import Path
|
|
|
33
33
|
from typing import NoReturn
|
|
34
34
|
|
|
35
35
|
sys.modules.pop("config", None)
|
|
36
|
-
if str(Path(__file__).
|
|
37
|
-
sys.path.insert(0, str(Path(__file__).
|
|
36
|
+
if str(Path(__file__).absolute().parent) not in sys.path:
|
|
37
|
+
sys.path.insert(0, str(Path(__file__).absolute().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 =
|
|
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
|
-
|
|
71
|
-
|
|
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()
|