from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
try:
import yaml
except ImportError: sys.exit("error: PyYAML is required; install it with `pip install pyyaml`")
TONE_INSTRUCTIONS_MAX = 250 PATH_INSTRUCTIONS_MAX = 20000 LABELING_INSTRUCTIONS_MAX = 3000
BROKEN_IDENTIFIER = re.compile(r"`[^`]*\w(?:_|::) \w[^`]*`")
def repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=True,
)
return Path(result.stdout.strip())
def tracked_files(root: Path) -> list[str]:
result = subprocess.run(
["git", "ls-files"], cwd=root, capture_output=True, text=True, check=True
)
return [line for line in result.stdout.splitlines() if line]
def expand_braces(pattern: str) -> list[str]:
match = re.search(r"\{([^{}]*)\}", pattern)
if not match:
return [pattern]
expanded = []
for alternative in match.group(1).split(","):
substituted = pattern[: match.start()] + alternative + pattern[match.end() :]
expanded.extend(expand_braces(substituted))
return expanded
def glob_to_regex(pattern: str) -> re.Pattern[str]:
out = ["^"]
index = 0
while index < len(pattern):
char = pattern[index]
if pattern.startswith("**/", index):
out.append("(?:[^/]+/)*")
index += 3
elif pattern.startswith("**", index):
out.append(".*")
index += 2
elif char == "*":
out.append("[^/]*")
index += 1
elif char == "?":
out.append("[^/]")
index += 1
else:
out.append(re.escape(char))
index += 1
out.append("$")
return re.compile("".join(out))
def matches(pattern: str, paths: list[str]) -> list[str]:
regexes = [glob_to_regex(alternative) for alternative in expand_braces(pattern)]
return [path for path in paths if any(regex.match(path) for regex in regexes)]
def check_length(label: str, text: str, cap: int, errors: list[str]) -> None:
if len(text) > cap:
errors.append(
f"{label} is {len(text)} characters, over the {cap}-character cap. "
f"Exceeding it invalidates the entire file, not just this field."
)
def main() -> int:
root = repo_root()
config_path = root / ".coderabbit.yaml"
if not config_path.is_file():
print(f"error: {config_path} not found", file=sys.stderr)
return 1
try:
config = yaml.safe_load(config_path.read_text())
except yaml.YAMLError as exc:
print(f"error: .coderabbit.yaml is not valid YAML: {exc}", file=sys.stderr)
return 1
errors: list[str] = []
reviews = config.get("reviews", {})
check_length(
"tone_instructions", config.get("tone_instructions", ""), TONE_INSTRUCTIONS_MAX, errors
)
paths = tracked_files(root)
path_instructions = reviews.get("path_instructions", [])
for entry in path_instructions:
glob = entry["path"]
check_length(
f"path_instructions[{glob}]",
entry["instructions"],
PATH_INSTRUCTIONS_MAX,
errors,
)
if not matches(glob, paths):
errors.append(
f"path_instructions glob {glob!r} matches no tracked file. "
f"A stale glob silently stops aiming the reviewer at anything; "
f"update it, or drop it until the code it names exists."
)
for entry in reviews.get("labeling_instructions", []):
check_length(
f"labeling_instructions[{entry['label']}]",
entry["instructions"],
LABELING_INSTRUCTIONS_MAX,
errors,
)
for entry in path_instructions:
for broken in BROKEN_IDENTIFIER.findall(entry["instructions"]):
errors.append(
f"path_instructions[{entry['path']}] contains {broken!r}, an identifier "
f"split across lines by YAML folding. Rewrap so it stays on one line."
)
if errors:
print("`.coderabbit.yaml` validation failed:\n", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
print(
"\nCodeRabbit reports none of these on the PR -- it discards the file "
"and uses org defaults instead.",
file=sys.stderr,
)
return 1
print(
f"check-coderabbit-config: OK "
f"({len(path_instructions)} path instructions, all globs match tracked files; "
f"tone_instructions {len(config.get('tone_instructions', ''))}/{TONE_INSTRUCTIONS_MAX})"
)
return 0
if __name__ == "__main__":
sys.exit(main())