claude-dev-env 1.25.1 → 1.26.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 +6 -0
- package/agents/clean-coder.md +1 -1
- package/docs/CODE_RULES.md +3 -1
- package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +54 -0
- package/hooks/blocking/{code-rules-enforcer.py → code_rules_enforcer.py} +150 -5
- package/hooks/blocking/{destructive-command-blocker.py → destructive_command_blocker.py} +12 -4
- package/hooks/blocking/{tdd-enforcer.py → tdd_enforcer.py} +12 -0
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +181 -0
- package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +4 -4
- package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_magic_string_masking.py +104 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +2 -2
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker.py +63 -4
- package/hooks/blocking/test_gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/test_pr_description_enforcer.py +8 -8
- package/hooks/blocking/test_tdd_enforcer.py +53 -1
- package/hooks/github-action/pre-push-review.yml +27 -0
- package/hooks/hooks.json +28 -28
- package/hooks/lifecycle/{config-change-guard.py → config_change_guard.py} +27 -12
- package/hooks/lifecycle/test_config_change_guard.py +3 -3
- package/hooks/notification/{attention-needed-notify.py → attention_needed_notify.py} +7 -0
- package/hooks/notification/{claude-notification-handler.py → claude_notification_handler.py} +8 -0
- package/hooks/notification/notification_utils.py +60 -2
- package/hooks/notification/subagent_complete_notify.py +381 -0
- package/hooks/notification/test_attention_needed_notify.py +47 -0
- package/hooks/notification/test_claude_notification_handler.py +54 -0
- package/hooks/notification/test_notification_utils.py +91 -0
- package/hooks/notification/test_subagent_complete_notify.py +72 -0
- package/hooks/validators/README.md +5 -1
- package/hooks/validators/abbreviation_checks.py +1 -1
- package/hooks/validators/code_quality_checks.py +1 -1
- package/hooks/validators/config.py +5 -0
- package/hooks/validators/conftest.py +10 -0
- package/hooks/validators/exempt_paths.py +1 -1
- package/hooks/validators/git_checks.py +80 -0
- package/hooks/validators/magic_value_checks.py +2 -2
- package/hooks/validators/pr_reference_checks.py +1 -1
- package/hooks/validators/python_antipattern_checks.py +1 -1
- package/hooks/validators/run_all_validators.py +53 -105
- package/hooks/validators/security_checks.py +1 -1
- package/hooks/validators/test_abbreviation_checks.py +2 -2
- package/hooks/validators/test_code_quality_checks.py +2 -2
- package/hooks/validators/test_file_structure_checks.py +1 -1
- package/hooks/validators/test_git_checks.py +79 -13
- package/hooks/validators/test_health_check.py +1 -1
- package/hooks/validators/test_magic_value_checks.py +2 -2
- package/hooks/validators/test_mypy_integration.py +1 -1
- package/hooks/validators/test_output_formatter.py +3 -1
- package/hooks/validators/test_pr_reference_checks.py +2 -2
- package/hooks/validators/test_python_antipattern_checks.py +2 -2
- package/hooks/validators/test_python_style_checks.py +2 -4
- package/hooks/validators/test_react_checks.py +1 -1
- package/hooks/validators/test_ruff_integration.py +1 -1
- package/hooks/validators/test_run_all_validators.py +75 -43
- package/hooks/validators/test_run_all_validators_integration.py +14 -37
- package/hooks/validators/test_security_checks.py +2 -2
- package/hooks/validators/test_test_safety_checks.py +1 -1
- package/hooks/validators/test_todo_checks.py +2 -2
- package/hooks/validators/test_type_safety_checks.py +2 -2
- package/hooks/validators/test_useless_test_checks.py +2 -2
- package/hooks/validators/test_validator_base.py +1 -1
- package/hooks/validators/test_verify_paths.py +2 -4
- package/hooks/validators/todo_checks.py +1 -1
- package/hooks/validators/type_safety_checks.py +1 -1
- package/hooks/validators/useless_test_checks.py +1 -1
- package/package.json +1 -1
- package/rules/file-global-constants.md +71 -0
- package/rules/gh-body-file.md +1 -1
- package/rules/prompt-workflow-context-controls.md +48 -0
- package/scripts/sync_to_cursor/rules.py +2 -2
- package/scripts/tests/test_sync_to_cursor.py +2 -2
- package/skills/bugteam/CONSTRAINTS.md +37 -0
- package/skills/bugteam/EXAMPLES.md +64 -0
- package/skills/bugteam/PROMPTS.md +175 -0
- package/skills/bugteam/SKILL.md +204 -295
- package/skills/bugteam/SKILL_EVALS.md +346 -0
- package/skills/bugteam/scripts/README.md +37 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +334 -0
- package/skills/bugteam/scripts/bugteam_preflight.py +135 -0
- package/skills/rule-audit/SKILL.md +4 -4
- /package/hooks/advisory/{migration-safety-advisor.py → migration_safety_advisor.py} +0 -0
- /package/hooks/advisory/{refactor-guard.py → refactor_guard.py} +0 -0
- /package/hooks/blocking/{block-main-commit.py → block_main_commit.py} +0 -0
- /package/hooks/blocking/{content-search-to-zoekt-redirector.py → content_search_to_zoekt_redirector.py} +0 -0
- /package/hooks/blocking/{gh-body-arg-blocker.py → gh_body_arg_blocker.py} +0 -0
- /package/hooks/blocking/{hedging-language-blocker.py → hedging_language_blocker.py} +0 -0
- /package/hooks/blocking/{pr-description-enforcer.py → pr_description_enforcer.py} +0 -0
- /package/hooks/blocking/{sensitive-file-protector.py → sensitive_file_protector.py} +0 -0
- /package/hooks/blocking/{test-preflight-check.py → test_preflight_check.py} +0 -0
- /package/hooks/blocking/{write-existing-file-blocker.py → write_existing_file_blocker.py} +0 -0
- /package/hooks/git-hooks/{post-commit.py → post_commit.py} +0 -0
- /package/hooks/lifecycle/{session-end-cleanup.py → session_end_cleanup.py} +0 -0
- /package/hooks/{rewrite-plugin-paths.py → rewrite_plugin_paths.py} +0 -0
- /package/hooks/session/{plugin-data-dir-cleanup.py → plugin_data_dir_cleanup.py} +0 -0
- /package/hooks/validation/{hook-format-validator.py → hook_format_validator.py} +0 -0
- /package/hooks/workflow/{auto-formatter.py → auto_formatter.py} +0 -0
- /package/hooks/workflow/{investigation-tracker-reset.py → investigation_tracker_reset.py} +0 -0
- /package/scripts/{sync-to-cursor.py → sync_to_cursor.py} +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Unit tests for claude-notification-handler Discord wiring."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import pathlib
|
|
5
|
+
import types
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
HOOK_DIRECTORY = pathlib.Path(__file__).parent
|
|
9
|
+
MODULE_PATH = HOOK_DIRECTORY / "claude_notification_handler.py"
|
|
10
|
+
|
|
11
|
+
FIXTURE_ATTENTION_SECRET_ID = "fixture-attention-id-0001"
|
|
12
|
+
FIXTURE_PROJECT_NAME = "fixture-project"
|
|
13
|
+
FIXTURE_MESSAGE = "attention required"
|
|
14
|
+
FIXTURE_PRIORITY = "default"
|
|
15
|
+
NON_WINDOWS_NON_WSL_PLATFORM = "Darwin"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_handler_with_environment(
|
|
19
|
+
environment_overrides: dict[str, str],
|
|
20
|
+
) -> types.ModuleType:
|
|
21
|
+
module_specification = importlib.util.spec_from_file_location(
|
|
22
|
+
"claude_notification_handler_under_test",
|
|
23
|
+
MODULE_PATH,
|
|
24
|
+
)
|
|
25
|
+
assert module_specification is not None
|
|
26
|
+
assert module_specification.loader is not None
|
|
27
|
+
module_under_test = importlib.util.module_from_spec(module_specification)
|
|
28
|
+
with patch.dict("os.environ", environment_overrides, clear=False):
|
|
29
|
+
module_specification.loader.exec_module(module_under_test)
|
|
30
|
+
return module_under_test
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_send_desktop_and_push_notification_forwards_attention_secret_id_to_notify_discord() -> (
|
|
34
|
+
None
|
|
35
|
+
):
|
|
36
|
+
module_under_test = load_handler_with_environment(
|
|
37
|
+
{"BWS_DISCORD_ATTENTION_SECRET_ID": FIXTURE_ATTENTION_SECRET_ID}
|
|
38
|
+
)
|
|
39
|
+
with (
|
|
40
|
+
patch.object(module_under_test, "notify_ntfy"),
|
|
41
|
+
patch.object(module_under_test, "notify_discord") as discord_spy,
|
|
42
|
+
patch.object(module_under_test, "platform") as platform_stub,
|
|
43
|
+
):
|
|
44
|
+
platform_stub.system.return_value = NON_WINDOWS_NON_WSL_PLATFORM
|
|
45
|
+
module_under_test.send_desktop_and_push_notification(
|
|
46
|
+
project_name=FIXTURE_PROJECT_NAME,
|
|
47
|
+
notification_message=FIXTURE_MESSAGE,
|
|
48
|
+
ntfy_priority=FIXTURE_PRIORITY,
|
|
49
|
+
)
|
|
50
|
+
assert discord_spy.call_count == 1
|
|
51
|
+
call_kwargs = discord_spy.call_args.kwargs
|
|
52
|
+
assert call_kwargs["webhook_secret_id"] == FIXTURE_ATTENTION_SECRET_ID
|
|
53
|
+
assert call_kwargs["title"] == FIXTURE_PROJECT_NAME
|
|
54
|
+
assert call_kwargs["message"] == FIXTURE_MESSAGE
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Unit tests for notification_utils ntfy guard behavior."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import pathlib
|
|
6
|
+
import subprocess
|
|
7
|
+
import types
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
HOOK_DIRECTORY = pathlib.Path(__file__).parent
|
|
11
|
+
MODULE_PATH = HOOK_DIRECTORY / "notification_utils.py"
|
|
12
|
+
|
|
13
|
+
FIXTURE_DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/111/aaa-fixture"
|
|
14
|
+
FIXTURE_SECRET_ID = "fixture-secret-id-0000"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_notification_utils_with_environment(
|
|
18
|
+
environment_overrides: dict[str, str],
|
|
19
|
+
) -> types.ModuleType:
|
|
20
|
+
module_specification = importlib.util.spec_from_file_location(
|
|
21
|
+
"notification_utils_under_test",
|
|
22
|
+
MODULE_PATH,
|
|
23
|
+
)
|
|
24
|
+
assert module_specification is not None
|
|
25
|
+
assert module_specification.loader is not None
|
|
26
|
+
module_under_test = importlib.util.module_from_spec(module_specification)
|
|
27
|
+
with patch.dict("os.environ", environment_overrides, clear=False):
|
|
28
|
+
module_specification.loader.exec_module(module_under_test)
|
|
29
|
+
return module_under_test
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_should_skip_curl_when_topic_environment_variable_is_unset() -> None:
|
|
33
|
+
environment_with_topic_removed = {"NTFY_TOPIC": ""}
|
|
34
|
+
module_under_test = load_notification_utils_with_environment(
|
|
35
|
+
environment_with_topic_removed
|
|
36
|
+
)
|
|
37
|
+
with patch("subprocess.Popen") as popen_spy:
|
|
38
|
+
module_under_test.notify_ntfy(title="Test", message="payload")
|
|
39
|
+
assert popen_spy.call_count == 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_should_invoke_curl_when_topic_environment_variable_is_set() -> None:
|
|
43
|
+
environment_with_topic_set = {"NTFY_TOPIC": "private-topic-for-test"}
|
|
44
|
+
module_under_test = load_notification_utils_with_environment(
|
|
45
|
+
environment_with_topic_set
|
|
46
|
+
)
|
|
47
|
+
with patch("subprocess.Popen") as popen_spy:
|
|
48
|
+
module_under_test.notify_ntfy(title="Test", message="payload")
|
|
49
|
+
assert popen_spy.call_count == 1
|
|
50
|
+
curl_arguments = popen_spy.call_args.args[0]
|
|
51
|
+
assert "https://ntfy.sh/private-topic-for-test" in curl_arguments
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_fetch_bws_secret_returns_none_when_secret_id_is_empty() -> None:
|
|
55
|
+
module_under_test = load_notification_utils_with_environment({})
|
|
56
|
+
with patch("subprocess.run") as run_spy:
|
|
57
|
+
fetched_value = module_under_test.fetch_bws_secret("")
|
|
58
|
+
assert fetched_value is None
|
|
59
|
+
assert run_spy.call_count == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_notify_discord_skips_curl_when_secret_id_is_empty() -> None:
|
|
63
|
+
module_under_test = load_notification_utils_with_environment({})
|
|
64
|
+
with patch("subprocess.Popen") as popen_spy:
|
|
65
|
+
module_under_test.notify_discord(
|
|
66
|
+
title="Test",
|
|
67
|
+
message="payload",
|
|
68
|
+
webhook_secret_id="",
|
|
69
|
+
)
|
|
70
|
+
assert popen_spy.call_count == 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_notify_discord_invokes_curl_when_bws_returns_url() -> None:
|
|
74
|
+
module_under_test = load_notification_utils_with_environment({})
|
|
75
|
+
bws_completed = subprocess.CompletedProcess(
|
|
76
|
+
args=[],
|
|
77
|
+
returncode=0,
|
|
78
|
+
stdout=json.dumps({"value": FIXTURE_DISCORD_WEBHOOK_URL}),
|
|
79
|
+
stderr="",
|
|
80
|
+
)
|
|
81
|
+
with patch("subprocess.run", return_value=bws_completed), patch(
|
|
82
|
+
"subprocess.Popen"
|
|
83
|
+
) as popen_spy:
|
|
84
|
+
module_under_test.notify_discord(
|
|
85
|
+
title="Test",
|
|
86
|
+
message="payload",
|
|
87
|
+
webhook_secret_id=FIXTURE_SECRET_ID,
|
|
88
|
+
)
|
|
89
|
+
assert popen_spy.call_count == 1
|
|
90
|
+
popen_arguments = popen_spy.call_args.args[0]
|
|
91
|
+
assert FIXTURE_DISCORD_WEBHOOK_URL in popen_arguments
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Unit tests for subagent-complete-notify Discord wiring."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import pathlib
|
|
5
|
+
import types
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
HOOK_DIRECTORY = pathlib.Path(__file__).parent
|
|
9
|
+
MODULE_PATH = HOOK_DIRECTORY / "subagent_complete_notify.py"
|
|
10
|
+
|
|
11
|
+
FIXTURE_ACTIVITY_SECRET_ID = "fixture-activity-id-0003"
|
|
12
|
+
FIXTURE_TASK_DESCRIPTION = "subagent finished research task"
|
|
13
|
+
FIXTURE_PROJECT_NAME = "fixture-project"
|
|
14
|
+
NON_WINDOWS_NON_WSL_PLATFORM = "Darwin"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_hook_with_environment(
|
|
18
|
+
environment_overrides: dict[str, str],
|
|
19
|
+
) -> types.ModuleType:
|
|
20
|
+
module_specification = importlib.util.spec_from_file_location(
|
|
21
|
+
"subagent_complete_notify_under_test",
|
|
22
|
+
MODULE_PATH,
|
|
23
|
+
)
|
|
24
|
+
assert module_specification is not None
|
|
25
|
+
assert module_specification.loader is not None
|
|
26
|
+
module_under_test = importlib.util.module_from_spec(module_specification)
|
|
27
|
+
with patch.dict("os.environ", environment_overrides, clear=False):
|
|
28
|
+
module_specification.loader.exec_module(module_under_test)
|
|
29
|
+
return module_under_test
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_main_forwards_activity_secret_id_to_notify_discord() -> None:
|
|
33
|
+
module_under_test = load_hook_with_environment(
|
|
34
|
+
{"BWS_DISCORD_ACTIVITY_SECRET_ID": FIXTURE_ACTIVITY_SECRET_ID}
|
|
35
|
+
)
|
|
36
|
+
with (
|
|
37
|
+
patch.object(
|
|
38
|
+
module_under_test,
|
|
39
|
+
"get_task_info_from_stdin",
|
|
40
|
+
return_value=FIXTURE_TASK_DESCRIPTION,
|
|
41
|
+
),
|
|
42
|
+
patch.object(
|
|
43
|
+
module_under_test, "get_project_name", return_value=FIXTURE_PROJECT_NAME
|
|
44
|
+
),
|
|
45
|
+
patch.object(module_under_test, "notify_ntfy"),
|
|
46
|
+
patch.object(module_under_test, "notify_discord") as discord_spy,
|
|
47
|
+
patch.object(module_under_test, "is_wsl", return_value=False),
|
|
48
|
+
patch.object(module_under_test, "platform") as platform_stub,
|
|
49
|
+
):
|
|
50
|
+
platform_stub.system.return_value = NON_WINDOWS_NON_WSL_PLATFORM
|
|
51
|
+
module_under_test.main()
|
|
52
|
+
assert discord_spy.call_count == 1
|
|
53
|
+
call_kwargs = discord_spy.call_args.kwargs
|
|
54
|
+
assert call_kwargs["webhook_secret_id"] == FIXTURE_ACTIVITY_SECRET_ID
|
|
55
|
+
assert call_kwargs["title"] == FIXTURE_PROJECT_NAME
|
|
56
|
+
assert call_kwargs["message"] == FIXTURE_TASK_DESCRIPTION
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_main_skips_notify_discord_when_task_description_is_empty() -> None:
|
|
60
|
+
module_under_test = load_hook_with_environment(
|
|
61
|
+
{"BWS_DISCORD_ACTIVITY_SECRET_ID": FIXTURE_ACTIVITY_SECRET_ID}
|
|
62
|
+
)
|
|
63
|
+
with (
|
|
64
|
+
patch.object(module_under_test, "get_task_info_from_stdin", return_value=""),
|
|
65
|
+
patch.object(
|
|
66
|
+
module_under_test, "get_project_name", return_value=FIXTURE_PROJECT_NAME
|
|
67
|
+
),
|
|
68
|
+
patch.object(module_under_test, "notify_ntfy"),
|
|
69
|
+
patch.object(module_under_test, "notify_discord") as discord_spy,
|
|
70
|
+
):
|
|
71
|
+
module_under_test.main()
|
|
72
|
+
assert discord_spy.call_count == 0
|
|
@@ -119,7 +119,11 @@ repos:
|
|
|
119
119
|
hooks:
|
|
120
120
|
- id: python-style-checks
|
|
121
121
|
name: Python Style Checks
|
|
122
|
-
entry: python hooks/validators/python_style_checks.py
|
|
122
|
+
entry: python packages/claude-dev-env/hooks/validators/python_style_checks.py
|
|
123
|
+
args: []
|
|
124
|
+
pass_filenames: true
|
|
125
|
+
# Invokes the script directly via its ``__main__`` block so the
|
|
126
|
+
# ``validators`` package qualifier does not need PYTHONPATH setup.
|
|
123
127
|
language: system
|
|
124
128
|
types: [python]
|
|
125
129
|
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Pytest fixture module ensuring validators directory is importable regardless of invocation cwd."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
VALIDATORS_DIRECTORY = Path(__file__).resolve().parent
|
|
8
|
+
|
|
9
|
+
if str(VALIDATORS_DIRECTORY) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(VALIDATORS_DIRECTORY))
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Single source of truth for CONFIG / TEST / HOOK-INFRASTRUCTURE /
|
|
4
4
|
WORKFLOW-REGISTRY / MIGRATION path pattern sets. Both Pre-Write
|
|
5
|
-
(``
|
|
5
|
+
(``code_rules_enforcer.py``) and pre-push (``magic_value_checks.py``)
|
|
6
6
|
scanners must short-circuit on the same file categories; drift between
|
|
7
7
|
the two produced the "inconsistent verdicts" bug this module prevents.
|
|
8
8
|
|
|
@@ -6,6 +6,8 @@ import sys
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from typing import List
|
|
8
8
|
|
|
9
|
+
from config import DEFAULT_BASE_BRANCH_WHEN_UNKNOWN
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
SUBPROCESS_TIMEOUT_SECONDS = 30
|
|
11
13
|
|
|
@@ -33,6 +35,83 @@ def get_current_branch() -> str:
|
|
|
33
35
|
return ""
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
def check_single_commit_when_pr_exists() -> List[Violation]:
|
|
39
|
+
"""
|
|
40
|
+
Check that a PR branch has exactly 1 commit ahead of its base.
|
|
41
|
+
|
|
42
|
+
Returns empty list if:
|
|
43
|
+
- No PR exists for current branch
|
|
44
|
+
- gh CLI or git is not available
|
|
45
|
+
- gh/git times out
|
|
46
|
+
- Branch is exactly 1 commit ahead of base
|
|
47
|
+
- git rev-list output is non-numeric
|
|
48
|
+
|
|
49
|
+
Returns violation if:
|
|
50
|
+
- Branch is 0 commits ahead of base
|
|
51
|
+
- Branch is more than 1 commit ahead of base
|
|
52
|
+
"""
|
|
53
|
+
branch = get_current_branch()
|
|
54
|
+
if not branch:
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
pr_info = subprocess.run(
|
|
59
|
+
["gh", "pr", "list", "--head", branch, "--json", "baseRefName,number"],
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
check=True,
|
|
63
|
+
timeout=SUBPROCESS_TIMEOUT_SECONDS,
|
|
64
|
+
)
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
return []
|
|
67
|
+
except subprocess.CalledProcessError:
|
|
68
|
+
return []
|
|
69
|
+
except subprocess.TimeoutExpired:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
pr_data = json.loads(pr_info.stdout)
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
if not pr_data:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
base_branch = pr_data[0].get("baseRefName", DEFAULT_BASE_BRANCH_WHEN_UNKNOWN)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
rev_list = subprocess.run(
|
|
84
|
+
["git", "rev-list", "--count", f"{base_branch}..HEAD"],
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
check=True,
|
|
88
|
+
timeout=SUBPROCESS_TIMEOUT_SECONDS,
|
|
89
|
+
)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
return []
|
|
92
|
+
except subprocess.CalledProcessError:
|
|
93
|
+
return []
|
|
94
|
+
except subprocess.TimeoutExpired:
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
commit_count_text = rev_list.stdout.strip()
|
|
98
|
+
try:
|
|
99
|
+
commit_count = int(commit_count_text)
|
|
100
|
+
except ValueError:
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
if commit_count == 1:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
return [
|
|
107
|
+
Violation(
|
|
108
|
+
file="",
|
|
109
|
+
line=0,
|
|
110
|
+
message=f"Branch must have exactly 1 commit ahead of {base_branch}, found {commit_count} commits",
|
|
111
|
+
)
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
36
115
|
def check_draft_pr_state() -> List[Violation]:
|
|
37
116
|
"""
|
|
38
117
|
Check that PR is in draft state.
|
|
@@ -93,6 +172,7 @@ def main() -> None:
|
|
|
93
172
|
"""Run all git checks and exit with appropriate code."""
|
|
94
173
|
violations: List[Violation] = []
|
|
95
174
|
|
|
175
|
+
violations.extend(check_single_commit_when_pr_exists())
|
|
96
176
|
violations.extend(check_draft_pr_state())
|
|
97
177
|
|
|
98
178
|
if violations:
|
|
@@ -12,11 +12,11 @@ import sys
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Dict, FrozenSet, List, Set, Tuple, Type
|
|
14
14
|
|
|
15
|
-
from exempt_paths import (
|
|
15
|
+
from .exempt_paths import (
|
|
16
16
|
is_config_file,
|
|
17
17
|
is_test_file,
|
|
18
18
|
)
|
|
19
|
-
from validator_base import Violation
|
|
19
|
+
from .validator_base import Violation
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
ALLOWED_NUMBERS: FrozenSet[int] = frozenset({-1, 0, 1})
|