archive_reviews.py

scripts/archive_reviews.py(141 行)

archive_reviews.py
#!/usr/bin/env python3
"""archive_reviews.py — reviews/ の旧版レビューMDを reviews/archive/ へ掃き出す

方針:
  「reviews/ 直下には(レビュアー×記事)の最新版だけを置く。旧版は削除せず
   reviews/archive/ へ移す(ループ履歴・差分レビューの参照元として残す)」

対象ファイル名(4形式とも解釈する):
  ⓪ {prefix}_{slug}_YYYYMMDD-HHMMSS.md … 標準(タイムスタンプ式)
  ① {prefix}_{slug}_vN.md              … 版番号式(N=1,2,3…)
  ② {prefix}_{slug}_vYYYYMMDD.md       … 日付版(末尾 a-z 付きも可)
  ③ {prefix}_{slug}.md                 … 版数なし
  prefix = config の gate.tier1 / gate.tier2 のレビュアー名+confidentiality
           (config が無ければ legal / consistency / seo / cro / panel / confidentiality)
  ※ ⓪のバッチ形 {prefix}_batch-{テーマ}_TS.md は slug='batch-{テーマ}' として同テーマ内で世代管理される
  ※ legal_audit_*(精密監査・日付名)と review_summary / review_log 等は対象外(触らない)

「最新」の判定: 同一(prefix, slug)グループ内で、ファイル名のタイムスタンプ
  (⓪形式のみ・YYYYMMDD-HHMMSS)があればそれ、無ければ**ファイル更新時刻(mtime)を
  同書式に変換した値**で比較して最新の1本(vN と v日付は番号では比較不能なため)。

使い方:
  python -X utf8 scripts/archive_reviews.py           # dry-run(移動予定を表示するだけ)
  python -X utf8 scripts/archive_reviews.py --apply   # 実際に移動する
  python -X utf8 scripts/archive_reviews.py --root <プロジェクトルート> --config config.yaml
"""
import sys
import io
import re
import shutil
import argparse
from datetime import datetime
from pathlib import Path

sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")

DEFAULT_PREFIXES = ["legal", "consistency", "seo", "cro", "panel", "confidentiality"]
TS_RE = re.compile(r"_(\d{8}-\d{6})\.md$")


def load_config(path: Path) -> dict:
    """config.yaml を読む。無い・PyYAML 無し・壊れている場合は {} を返し既定値で動く"""
    if not path.is_file():
        return {}
    try:
        import yaml
    except ImportError:
        print("⚠️ PyYAML が無いため config を読めません(既定値で動きます): pip install pyyaml", file=sys.stderr)
        return {}
    try:
        data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
    except Exception as e:
        print(f"⚠️ config を解釈できません(既定値で動きます): {e}", file=sys.stderr)
        return {}
    return data if isinstance(data, dict) else {}


def prefixes_from(cfg: dict):
    """config のレビュアー名からファイル名プレフィックス一覧を作る(既定値も常に含める)"""
    gate = cfg.get("gate") if isinstance(cfg.get("gate"), dict) else {}
    names = list(DEFAULT_PREFIXES)
    for key in ("tier1", "tier2"):
        for n in gate.get(key) or []:
            n = str(n).strip()
            if re.fullmatch(r"[A-Za-z0-9-]+", n) and n not in names:
                names.append(n)
    return names


def name_re(prefixes):
    alt = "|".join(re.escape(p) for p in sorted(prefixes, key=len, reverse=True))
    return re.compile(rf"^({alt})_(.+?)(?:_v(\d+[a-z]?|\d{{8}}[a-z]?)|_(\d{{8}}-\d{{6}}))?\.md$")


def version_key(f: Path) -> str:
    """世代比較キー: ファイル名TS優先・無ければ mtime を同書式へ変換"""
    m = TS_RE.search(f.name)
    if m:
        return m.group(1)
    return datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y%m%d-%H%M%S")


def collect(reviews_dir: Path, pattern):
    """(prefix, slug) → [Path, ...] にグループ化。対象外ファイルは無視"""
    groups = {}
    skipped = []
    for f in sorted(reviews_dir.glob("*.md")):
        if f.name.startswith("legal_audit_"):
            skipped.append(f.name)
            continue
        m = pattern.match(f.name)
        if not m:
            skipped.append(f.name)
            continue
        groups.setdefault((m.group(1), m.group(2)), []).append(f)
    return groups, skipped


def main():
    ap = argparse.ArgumentParser(description="reviews/ の旧版レビューMDを reviews/archive/ へ移す(削除はしない)")
    ap.add_argument("--apply", action="store_true", help="実際に移動する(省略時は dry-run)")
    ap.add_argument("--root", default="", help="プロジェクトルート(省略時はこのスクリプトの1つ上のフォルダ)")
    ap.add_argument("--config", default="", help="config.yaml のパス(省略時は <root>/config.yaml)")
    args = ap.parse_args()

    root = Path(args.root).resolve() if args.root else Path(__file__).resolve().parent.parent
    cfg = load_config(Path(args.config) if args.config else root / "config.yaml")
    paths = cfg.get("paths") if isinstance(cfg.get("paths"), dict) else {}
    reviews_dir = root / str(paths.get("reviews") or "reviews")
    if not reviews_dir.is_dir():
        print(f"レビューフォルダがありません → 何もしません: {reviews_dir}")
        return
    archive_dir = reviews_dir / "archive"
    groups, skipped = collect(reviews_dir, name_re(prefixes_from(cfg)))

    moved = 0
    kept = 0
    for (prefix, slug), files in sorted(groups.items()):
        files.sort(key=version_key)  # 末尾が最新(ファイル名TS優先・mtimeフォールバック)
        latest = files[-1]
        kept += 1
        for old in files[:-1]:
            dest = archive_dir / old.name
            if dest.exists():
                print(f"  ⚠️ 移動先に同名あり・スキップ: {old.name}")
                continue
            if args.apply:
                archive_dir.mkdir(exist_ok=True)
                shutil.move(str(old), str(dest))
            moved += 1
            if not args.apply:
                print(f"  [dry] {old.name}  →  archive/  (残す最新: {latest.name})")

    mode = "適用" if args.apply else "dry-run"
    print(f"{mode}: 移動 {moved}本 / 最新として残置 {kept}本 / 対象外 {len(skipped)}本"
          + ("" if args.apply else "(dry-run=まだ何も動かしていない。実行は --apply)"))
    print("※旧版は削除しない(archive/ に温存)。最新版の鮮度照合・差分レビューには影響しない")


if __name__ == "__main__":
    main()

MIT ライセンスで配布しています。