Files
deer-flow/scripts/review_changed_public_skills.py
Daoyuan LiandGitHub 656f6b364c fix(skills): recognize fully deleted public skill packages in review CI (#4169)
select_skill_packages() resolved every changed non-SKILL.md path to its
owning package via an unconditional depth-3 fallback, then queued that
path for review. When a PR deletes an entire public skill package (not
just SKILL.md, but its scripts/assets/etc. too), the fallback still
returned the package directory even though it no longer exists on disk
post-deletion. The review CLI then reported a false
structure.missing-skill-md blocker for a path that isn't there,
failing CI on a routine, correct package removal.

Skip a resolved package only when every changed file under it was a
deletion and the package directory itself is gone from disk - i.e. the
whole package was intentionally removed. A package left in a
broken/partial state (e.g. SKILL.md deleted while sibling files
remain) still resolves to an existing directory, so it is unaffected
and continues to be queued and flagged.
2026-07-14 23:08:33 +08:00

302 lines
10 KiB
Python

#!/usr/bin/env python3
"""Run deterministic skill review for changed public skills."""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
REPO_ROOT = Path(__file__).resolve().parents[1]
HARNESS_PATH = REPO_ROOT / "backend" / "packages" / "harness"
if HARNESS_PATH.is_dir():
sys.path.insert(0, str(HARNESS_PATH))
PUBLIC_SKILL_PACKAGE_PATHSPEC = ":(glob)skills/public/**"
EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
@dataclass(frozen=True)
class ChangedPath:
status: str
path: PurePosixPath
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
repo_root = args.repo_root.resolve()
diff_args = build_diff_args(args)
print(f"[skill-review] Repository: {repo_root}")
print(f"[skill-review] Diff: git diff {' '.join(diff_args)}")
result = subprocess.run(
["git", "diff", *diff_args, "--", PUBLIC_SKILL_PACKAGE_PATHSPEC],
cwd=repo_root,
capture_output=True,
check=False,
)
if result.returncode != 0:
fallback_args = build_force_push_fallback_diff_args(args)
if fallback_args is None:
sys.stderr.write("[skill-review] Failed to collect changed public skill files.\n")
sys.stderr.write(result.stderr.decode("utf-8", errors="replace"))
return result.returncode
sys.stderr.write("[skill-review] Primary push diff failed; falling back to empty-tree comparison.\n")
sys.stderr.write(result.stderr.decode("utf-8", errors="replace"))
diff_args = fallback_args
print(f"[skill-review] Fallback diff: git diff {' '.join(diff_args)}")
result = subprocess.run(
["git", "diff", *diff_args, "--", PUBLIC_SKILL_PACKAGE_PATHSPEC],
cwd=repo_root,
capture_output=True,
check=False,
)
if result.returncode != 0:
sys.stderr.write("[skill-review] Failed to collect changed public skill files.\n")
sys.stderr.write(result.stderr.decode("utf-8", errors="replace"))
return result.returncode
changes = parse_name_status(result.stdout)
packages = select_skill_packages(changes, repo_root)
if not packages:
print("[skill-review] No changed public skill package files; skipping review.")
return 0
print(f"[skill-review] Reviewing {len(packages)} changed public skill package(s).")
failed = False
for package in packages:
if run_review(package, repo_root, args.python) != 0:
failed = True
if failed:
print("[skill-review] One or more skill reviews failed.")
return 1
print("[skill-review] All changed public skill packages passed review.")
return 0
def parse_args(argv: Sequence[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=("Review public skill packages whose SKILL.md changed in a PR or push diff."))
parser.add_argument(
"--base-ref",
"--base_ref",
dest="base_ref",
help="Base ref/SHA for PR-style base...head comparison.",
)
parser.add_argument(
"--head-ref",
"--head_ref",
dest="head_ref",
help="Head ref/SHA for PR-style base...head comparison.",
)
parser.add_argument("--before", help="Before SHA for push-style before/after comparison.")
parser.add_argument("--after", help="After SHA for push-style before/after comparison.")
parser.add_argument(
"--repo-root",
type=Path,
default=Path(__file__).resolve().parents[1],
help="Repository root. Defaults to this script's parent repository.",
)
parser.add_argument(
"--python",
default=sys.executable,
help="Python executable used to invoke python -m deerflow.skills.review.cli.",
)
args = parser.parse_args(argv)
has_pr_args = bool(args.base_ref or args.head_ref)
has_push_args = bool(args.before or args.after)
if has_pr_args == has_push_args:
parser.error("pass either --base-ref/--head-ref or --before/--after, but not both")
if has_pr_args and not (args.base_ref and args.head_ref):
parser.error("--base-ref and --head-ref must be provided together")
if has_push_args and not (args.before and args.after):
parser.error("--before and --after must be provided together")
return args
def build_diff_args(args: argparse.Namespace) -> list[str]:
if args.base_ref and args.head_ref:
return ["--name-status", "-z", f"{args.base_ref}...{args.head_ref}"]
before = str(args.before)
after = str(args.after)
if is_zero_sha(before):
return ["--name-status", "-z", EMPTY_TREE_SHA, after]
return ["--name-status", "-z", before, after]
def build_force_push_fallback_diff_args(args: argparse.Namespace) -> list[str] | None:
if not args.before or not args.after or is_zero_sha(str(args.before)):
return None
return ["--name-status", "-z", EMPTY_TREE_SHA, str(args.after)]
def parse_name_status(output: bytes) -> list[ChangedPath]:
parts = [part for part in output.split(b"\0") if part]
changes: list[ChangedPath] = []
index = 0
while index < len(parts):
status = parts[index].decode("utf-8", errors="surrogateescape")
index += 1
if not status:
continue
path_index = index + 1 if status[0] in {"C", "R"} else index
if path_index >= len(parts):
raise ValueError(f"Malformed git diff --name-status output near {status!r}")
path = parts[path_index].decode("utf-8", errors="surrogateescape")
changes.append(ChangedPath(status=status, path=PurePosixPath(path)))
index = path_index + 1
return changes
def select_skill_packages(changes: Sequence[ChangedPath], repo_root: Path) -> list[Path]:
package_statuses: dict[PurePosixPath, list[str]] = {}
resolutions: list[tuple[ChangedPath, PurePosixPath]] = []
for change in changes:
if not is_public_skill_package_path(change.path):
continue
if change.status.startswith("D") and is_public_skill_md(change.path):
print(f"[skill-review] Skipping deleted SKILL.md: {change.path}")
continue
package_rel = find_public_skill_package(change.path, repo_root)
if package_rel is None:
print(f"[skill-review] Skipping path outside public skill package: {change.path}")
continue
package_statuses.setdefault(package_rel, []).append(change.status)
resolutions.append((change, package_rel))
packages: list[Path] = []
seen: set[PurePosixPath] = set()
for _, package_rel in resolutions:
if package_rel in seen:
print(f"[skill-review] Already queued package: {package_rel}")
continue
seen.add(package_rel)
if is_fully_removed_package(package_rel, package_statuses[package_rel], repo_root):
print(f"[skill-review] Skipping fully removed package: {package_rel}")
continue
packages.append(repo_root / package_rel)
print(f"[skill-review] Queued package: {package_rel}")
return packages
def is_fully_removed_package(package_rel: PurePosixPath, statuses: Sequence[str], repo_root: Path) -> bool:
"""Whether every changed file that resolved to ``package_rel`` was a deletion and the
package directory itself no longer exists on disk.
This identifies a whole public skill package being intentionally deleted (all of its
files removed, not just SKILL.md), as distinct from a package left in a broken/partial
state (e.g. SKILL.md deleted while other package files remain on disk) — the latter
must still be reviewed and flagged.
"""
if not all(status.startswith("D") for status in statuses):
return False
return not (repo_root / package_rel).is_dir()
def is_public_skill_md(path: PurePosixPath) -> bool:
parts = path.parts
return len(parts) >= 4 and parts[0] == "skills" and parts[1] == "public" and parts[-1] == "SKILL.md" and not _is_eval_fixture_skill_md(path)
def is_public_skill_package_path(path: PurePosixPath) -> bool:
parts = path.parts
return len(parts) >= 3 and parts[0] == "skills" and parts[1] == "public"
def find_public_skill_package(path: PurePosixPath, repo_root: Path) -> PurePosixPath | None:
if not is_public_skill_package_path(path):
return None
current = path.parent if path.name else path
while len(current.parts) >= 3:
skill_md_rel = current / "SKILL.md"
if not _is_eval_fixture_skill_md(skill_md_rel) and (repo_root / skill_md_rel).is_file():
return current
if len(current.parts) == 3:
return current
current = current.parent
return None
def _is_eval_fixture_skill_md(path: PurePosixPath) -> bool:
from deerflow.skills.package_paths import is_eval_fixture_skill_md
return is_eval_fixture_skill_md(path)
def run_review(package: Path, repo_root: Path, python_executable: str) -> int:
package_rel = package.relative_to(repo_root).as_posix()
command = [
python_executable,
"-m",
"deerflow.skills.review.cli",
package_rel,
"--format",
"text",
"--fail-on",
"error",
"--fail-on-incomplete",
]
log_command = [
"python",
"-m",
"deerflow.skills.review.cli",
package_rel,
"--format",
"text",
"--fail-on",
"error",
"--fail-on-incomplete",
]
print(f"[skill-review] Reviewing package: {package_rel}")
print(f"[skill-review] $ {' '.join(log_command)}")
result = subprocess.run(
command,
cwd=repo_root,
env=review_env(repo_root),
check=False,
)
if result.returncode == 0:
print(f"[skill-review] Passed: {package_rel}")
else:
print(f"[skill-review] Failed: {package_rel} (exit {result.returncode})")
return result.returncode
def review_env(repo_root: Path) -> dict[str, str]:
env = os.environ.copy()
harness_path = repo_root / "backend" / "packages" / "harness"
existing_pythonpath = env.get("PYTHONPATH")
env["PYTHONPATH"] = str(harness_path) if not existing_pythonpath else f"{harness_path}{os.pathsep}{existing_pythonpath}"
return env
def is_zero_sha(value: str) -> bool:
return len(value) in {40, 64} and set(value) == {"0"}
if __name__ == "__main__":
sys.exit(main())