236 lines
7.3 KiB
Python
236 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Review markdown documents before they enter the knowledge export workflow."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from .common import (
|
|
JSONMapping,
|
|
KnowledgeContractError,
|
|
control_files_changed,
|
|
load_review_entries,
|
|
load_source_rules,
|
|
match_globs,
|
|
parse_diff_entries,
|
|
require_non_empty_str,
|
|
resolve_document_review,
|
|
write_json,
|
|
)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
"""Parse CLI arguments."""
|
|
|
|
parser = argparse.ArgumentParser(description="Review knowledge markdown sources")
|
|
parser.add_argument(
|
|
"--repo-root", type=Path, required=True, help="Business repo root"
|
|
)
|
|
parser.add_argument(
|
|
"--deploy-root",
|
|
type=Path,
|
|
required=True,
|
|
help="ljwx-deploy root used for contracts",
|
|
)
|
|
parser.add_argument(
|
|
"--config",
|
|
type=Path,
|
|
default=Path("knowledge.sources.yaml"),
|
|
help="Path to knowledge.sources.yaml relative to repo root",
|
|
)
|
|
parser.add_argument(
|
|
"--review-ledger",
|
|
type=Path,
|
|
default=Path("knowledge.review.yaml"),
|
|
help="Path to knowledge.review.yaml relative to repo root",
|
|
)
|
|
parser.add_argument(
|
|
"--changed-files",
|
|
type=Path,
|
|
help="Path to git diff --name-status output. If omitted, review all matches.",
|
|
)
|
|
parser.add_argument(
|
|
"--summary-json",
|
|
type=Path,
|
|
required=True,
|
|
help="Where to write review summary JSON",
|
|
)
|
|
parser.add_argument(
|
|
"--report-markdown",
|
|
type=Path,
|
|
required=True,
|
|
help="Where to write a markdown review report",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def resolve_rule_for_path(path: str, rules: list[object]) -> object | None:
|
|
"""Find the single matching rule for a source path."""
|
|
|
|
matched: list[object] = []
|
|
for rule in rules:
|
|
if not hasattr(rule, "include") or not hasattr(rule, "exclude"):
|
|
continue
|
|
include = getattr(rule, "include")
|
|
exclude = getattr(rule, "exclude")
|
|
if not isinstance(include, tuple) or not isinstance(exclude, tuple):
|
|
continue
|
|
if match_globs(path, include, exclude):
|
|
matched.append(rule)
|
|
if len(matched) > 1:
|
|
raise KnowledgeContractError(f"路径命中了多个 source rule: {path}")
|
|
return matched[0] if matched else None
|
|
|
|
|
|
def format_issue_line(path: str, source_id: str, issues: tuple[str, ...]) -> str:
|
|
"""Render one markdown bullet for a document issue list."""
|
|
|
|
issue_text = "; ".join(issues)
|
|
return f"- `{path}` (`{source_id}`): {issue_text}"
|
|
|
|
|
|
def build_report(
|
|
*,
|
|
reviewed_documents: int,
|
|
blocking_lines: list[str],
|
|
warning_lines: list[str],
|
|
passed_lines: list[str],
|
|
) -> str:
|
|
"""Build a markdown report suitable for GitHub step summaries."""
|
|
|
|
lines = [
|
|
"## Knowledge Review",
|
|
f"- Reviewed documents: {reviewed_documents}",
|
|
f"- Blocking documents: {len(blocking_lines)}",
|
|
f"- Warning documents: {len(warning_lines)}",
|
|
]
|
|
if blocking_lines:
|
|
lines.append("")
|
|
lines.append("### Blocking")
|
|
lines.extend(blocking_lines)
|
|
if warning_lines:
|
|
lines.append("")
|
|
lines.append("### Warnings")
|
|
lines.extend(warning_lines)
|
|
if passed_lines:
|
|
lines.append("")
|
|
lines.append("### Passed")
|
|
lines.extend(passed_lines)
|
|
if reviewed_documents == 0:
|
|
lines.append("")
|
|
lines.append("- No matched markdown documents for this change set.")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main() -> int:
|
|
"""CLI entry point."""
|
|
|
|
args = parse_args()
|
|
repo_root = args.repo_root.resolve()
|
|
deploy_root = args.deploy_root.resolve()
|
|
config_path = (repo_root / args.config).resolve()
|
|
review_ledger_path = (repo_root / args.review_ledger).resolve()
|
|
summary_path = args.summary_json.resolve()
|
|
report_path = args.report_markdown.resolve()
|
|
|
|
_, rules = load_source_rules(config_path, deploy_root)
|
|
review_entries = load_review_entries(review_ledger_path, deploy_root)
|
|
diff_entries = (
|
|
parse_diff_entries(args.changed_files.resolve()) if args.changed_files else []
|
|
)
|
|
review_all = not diff_entries or control_files_changed(
|
|
diff_entries,
|
|
{args.config.as_posix(), args.review_ledger.as_posix()},
|
|
)
|
|
|
|
if review_all:
|
|
candidate_paths = sorted(
|
|
path.relative_to(repo_root).as_posix()
|
|
for path in repo_root.rglob("*.md")
|
|
if path.is_file()
|
|
)
|
|
else:
|
|
candidate_paths = sorted(
|
|
entry.path
|
|
for entry in diff_entries
|
|
if entry.change_type == "upsert" and entry.path.endswith(".md")
|
|
)
|
|
|
|
blocking_lines: list[str] = []
|
|
warning_lines: list[str] = []
|
|
passed_lines: list[str] = []
|
|
review_rows: list[JSONMapping] = []
|
|
|
|
for relative_path in candidate_paths:
|
|
rule = resolve_rule_for_path(relative_path, rules)
|
|
if rule is None:
|
|
continue
|
|
content = (repo_root / relative_path).read_text(encoding="utf-8")
|
|
resolved_review = resolve_document_review(
|
|
rule=rule,
|
|
relative_path=relative_path,
|
|
content=content,
|
|
review_entries=review_entries,
|
|
)
|
|
source_id = require_non_empty_str(getattr(rule, "source_id"), "rule.source_id")
|
|
review_mode = require_non_empty_str(
|
|
getattr(rule, "review_mode"), "rule.review_mode"
|
|
)
|
|
blocking_issues = (
|
|
resolved_review.standard_issues + resolved_review.review_issues
|
|
if review_mode == "required"
|
|
else ()
|
|
)
|
|
warning_issues = (
|
|
resolved_review.standard_issues if review_mode == "legacy" else ()
|
|
)
|
|
|
|
if blocking_issues:
|
|
blocking_lines.append(
|
|
format_issue_line(relative_path, source_id, blocking_issues)
|
|
)
|
|
elif warning_issues:
|
|
warning_lines.append(
|
|
format_issue_line(relative_path, source_id, warning_issues)
|
|
)
|
|
else:
|
|
passed_lines.append(f"- `{relative_path}` (`{source_id}`)")
|
|
|
|
review_rows.append(
|
|
{
|
|
"source_id": source_id,
|
|
"source_path": relative_path,
|
|
"review_mode": review_mode,
|
|
"review_status": resolved_review.review_status,
|
|
"blocking_issues": list(blocking_issues),
|
|
"warning_issues": list(warning_issues),
|
|
"reason": resolved_review.reason,
|
|
}
|
|
)
|
|
|
|
summary: JSONMapping = {
|
|
"reviewed_documents": len(review_rows),
|
|
"blocking_documents": len(blocking_lines),
|
|
"warning_documents": len(warning_lines),
|
|
"passed": len(blocking_lines) == 0,
|
|
"review_mode": "full" if review_all else "changed",
|
|
"documents": review_rows,
|
|
}
|
|
report = build_report(
|
|
reviewed_documents=len(review_rows),
|
|
blocking_lines=blocking_lines,
|
|
warning_lines=warning_lines,
|
|
passed_lines=passed_lines,
|
|
)
|
|
write_json(summary_path, summary)
|
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
report_path.write_text(report, encoding="utf-8")
|
|
print(json.dumps(summary, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|