mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-13 20:08:05 +08:00
146 lines
6.2 KiB
Python
146 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Print local guidance and suggested checks selected by changed paths."""
|
|
import argparse
|
|
import fnmatch
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import subprocess
|
|
import sys
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
CONFIG = ".github/review-context.json"
|
|
|
|
|
|
def git(root, *args):
|
|
result = subprocess.run(["git", "-C", str(root), *args], capture_output=True)
|
|
if result.returncode:
|
|
raise ValueError(result.stderr.decode(errors="replace").strip() or "Git command failed")
|
|
return result.stdout
|
|
|
|
|
|
def local_path(value):
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError("Expected a nonempty repository-relative path")
|
|
value = value.replace("\\", "/")
|
|
path = PurePosixPath(value)
|
|
if path.is_absolute() or ".." in path.parts or ":" in value:
|
|
raise ValueError(f"Not a repository-relative path: {value}")
|
|
return path.as_posix()
|
|
|
|
|
|
def string_list(value, label):
|
|
if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
|
|
raise ValueError(f"{label} must be a list of nonempty strings")
|
|
return value
|
|
|
|
|
|
def load_rules(root):
|
|
data = json.loads((root / CONFIG).read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict) or data.get("version") != 1 or not isinstance(data.get("rules"), list):
|
|
raise ValueError(f"{CONFIG}: expected version 1 and a rules list")
|
|
seen = set()
|
|
for rule in data["rules"]:
|
|
if not isinstance(rule, dict) or set(rule) - {"id", "any", "all", "docs", "skills", "checks"}:
|
|
raise ValueError(f"{CONFIG}: invalid rule fields")
|
|
name = rule.get("id")
|
|
if not isinstance(name, str) or not name.strip() or name in seen:
|
|
raise ValueError(f"{CONFIG}: rule IDs must be nonempty and unique")
|
|
seen.add(name)
|
|
for key in ("any", "all", "docs", "skills"):
|
|
for value in string_list(rule.get(key, []), f"{name}.{key}"):
|
|
local_path(value)
|
|
if not rule.get("any") and not rule.get("all"):
|
|
raise ValueError(f"{name}: at least one any/all glob is required")
|
|
checks = rule.get("checks", [])
|
|
if not isinstance(checks, list):
|
|
raise ValueError(f"{name}.checks must be a list")
|
|
for check in checks:
|
|
if not isinstance(check, dict) or set(check) != {"command", "targets"}:
|
|
raise ValueError(f"{name}: each check needs command and targets")
|
|
if not isinstance(check["command"], str) or not check["command"].strip():
|
|
raise ValueError(f"{name}: empty check command")
|
|
if not string_list(check["targets"], f"{name}.targets"):
|
|
raise ValueError(f"{name}: check targets cannot be empty")
|
|
for target in check["targets"]:
|
|
local_path(target)
|
|
return data["rules"]
|
|
|
|
|
|
def diff_paths(data):
|
|
"""Git -z emits separate status/path tokens; renames include both paths."""
|
|
tokens = data.split(b"\0")
|
|
paths = set()
|
|
index = 0
|
|
while index < len(tokens) and tokens[index]:
|
|
status = tokens[index]
|
|
count = 2 if status[:1] in (b"R", b"C") else 1
|
|
index += 1
|
|
if index + count > len(tokens) or any(not value for value in tokens[index:index + count]):
|
|
raise ValueError("Incomplete Git name-status output")
|
|
paths.update(os.fsdecode(value) for value in tokens[index:index + count])
|
|
index += count
|
|
return paths
|
|
|
|
|
|
def changed_paths(root, base=None):
|
|
paths = set()
|
|
if base is not None:
|
|
# Resolve an argument as a revision, never as a Git option or shell text.
|
|
commit = git(root, "rev-parse", "--verify", "--end-of-options", base + "^{commit}").decode().strip()
|
|
ancestor = git(root, "merge-base", commit, "HEAD").decode().strip()
|
|
paths.update(diff_paths(git(root, "diff", "--name-status", "-z", "--find-renames", ancestor, "HEAD", "--")))
|
|
for args in (("--cached",), ()):
|
|
paths.update(diff_paths(git(root, "diff", "--name-status", "-z", "--find-renames", *args, "--")))
|
|
paths.update(os.fsdecode(value) for value in git(root, "ls-files", "--others", "--exclude-standard", "-z").split(b"\0") if value)
|
|
return sorted(paths)
|
|
|
|
|
|
def matching_rules(rules, paths):
|
|
def hit(pattern):
|
|
return any(fnmatch.fnmatchcase(path, pattern) for path in paths)
|
|
|
|
return [rule for rule in rules
|
|
if (not rule.get("any") or any(hit(pattern) for pattern in rule["any"]))
|
|
and all(hit(pattern) for pattern in rule.get("all", []))]
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--root", type=Path, default=ROOT)
|
|
source = parser.add_mutually_exclusive_group()
|
|
source.add_argument("--files", nargs="+", help="Explicit repository-relative paths, including deleted paths")
|
|
source.add_argument("--base", help="Compare merge-base(REF, HEAD)..HEAD and include local changes")
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
rules = load_rules(args.root)
|
|
paths = sorted({local_path(path) for path in args.files}) if args.files else changed_paths(args.root, args.base)
|
|
if not paths:
|
|
print("No changed files; no additional guidance selected.")
|
|
return 0
|
|
print("Changed paths:")
|
|
for path in paths:
|
|
print(" " + path)
|
|
selected = matching_rules(rules, paths)
|
|
if not selected:
|
|
print("No matching rules; start with AGENTS.md.")
|
|
return 0
|
|
print("\nMatching rules: " + ", ".join(rule["id"] for rule in selected))
|
|
for label, key in (("Read local guidance", "docs"), ("Load skills when needed", "skills")):
|
|
values = dict.fromkeys(value for rule in selected for value in rule.get(key, []))
|
|
if values:
|
|
print("\n" + label + ":")
|
|
for value in values:
|
|
print(" " + value)
|
|
print("\nSuggested checks (printed only; not executed):")
|
|
for command in dict.fromkeys(check["command"] for rule in selected for check in rule.get("checks", [])):
|
|
print(" " + command)
|
|
return 0
|
|
except (OSError, ValueError) as error:
|
|
print(f"doc-context: {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|