#!/usr/bin/env python3 """从 schema 生成一个项目的空白档案骨架,并自动预填可机器获取的字段。 用法 ---- python3 scripts/new_project.py --repo Devin-AXIS/iPolloWork python3 scripts/new_project.py --repo owner/name --id custom-id --status watch 生成后需要人工补完的内容(脚本不猜这些): identity / business / scenario / tech / risk / verdict 其中 **risk.claim_vs_reality 是必填**,它承载"宣称 vs 实测"的落差分析, 是整套档案里最不能空着的字段。 """ from __future__ import annotations import argparse import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from _common import ( # noqa: E402 DATA, PROJECTS, SCHEMA, classify_license, gh_get, load_yaml, today, ) LEAF_KEYS = {"type", "required", "desc", "enum", "auto", "note"} def render(node, indent=0, out=None, prefix="", idx=None): """把 schema 的 fields 树渲染成带注释的 YAML 文本。 同时返回 {字段路径: 行号} 索引。回填值必须走这个索引按**路径**定位, 不能靠硬编码缩进 —— schema 一旦加减层级,缩进常量就会悄悄错位, 而且像 `name` 这种同名不同层的字段(项目名 vs 许可证名)会互相覆盖。 """ out = out if out is not None else [] idx = idx if idx is not None else {} pad = " " * indent for key, spec in node.items(): if not isinstance(spec, dict): continue path = f"{prefix}{key}" if "type" in spec: # 叶子字段 default = { "str": '""', "int": "0", "bool": "false", "list": "[]", "dict": "{}", }.get(spec["type"], '""') comment = "" if spec.get("enum"): # fields 里的 enum 是 enums 段的键名引用(字符串),不是字面量数组; # 直接 join 会把 "vendor_type" 拆成 v|e|n|d|o|r|_|t|y|p|e e = spec["enum"] comment = f" # {'|'.join(e) if isinstance(e, list) else e}" elif spec.get("desc"): d = re.sub(r"\s+", " ", spec["desc"].replace("\n", " ")) comment = f" # {d[:60]}" idx[path] = len(out) out.append(f"{pad}{key}: {default}{comment}") elif spec and not (set(spec) & LEAF_KEYS): # 嵌套 section out.append(f"{pad}{key}:") render(spec, indent + 2, out, path + ".", idx) return out, idx def slug(repo: str) -> str: name = repo.rstrip("/").split("/")[-1] name = re.sub(r"\.git$", "", name) return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--repo", required=True, help="GitHub 仓库 owner/name") ap.add_argument("--id", help="档案 id,默认由仓库名生成") ap.add_argument("--status", default="active", choices=["active", "watch", "archived"]) args = ap.parse_args() pid = args.id or slug(args.repo) ypath = PROJECTS / f"{pid}.yaml" if ypath.exists(): print(f"档案已存在:{ypath}") return 1 print(f"==> 生成档案 {pid} <- {args.repo}") # ---- 抓可自动获取的部分 ---- info = gh_get(f"repos/{args.repo}") or {} lic = info.get("license") or {} spdx = lic.get("spdx_id") or "" if spdx == "NOASSERTION": spdx = "NOASSERTION" cls = classify_license(spdx, lic.get("name", "")) print(f" 许可证 spdx={spdx or '(无)'} → {cls['level']} / 可见性 {cls['visibility']}") schema = load_yaml(SCHEMA / "project.schema.yaml") lines, idx = render(schema.get("fields", {})) def setval(path: str, val) -> None: """按字段路径回填值,保留该行原有的缩进与键名。""" i = idx.get(path) if i is None: print(f" [warn] schema 中无字段 {path},跳过回填") return line = lines[i] indent = " " * (len(line) - len(line.lstrip())) key = path.split(".")[-1] lines[i] = f"{indent}{key}: {val}" setval("id", pid) setval("name", f'"{info.get("name", "")}"') setval("repo", f'"{args.repo}"') setval("homepage", f'"{info.get("homepage") or ""}"') setval("added_at", f'"{today()}"') setval("status", args.status) if info: setval("identity.tagline", f'"{info.get("description") or ""}"') setval("identity.first_seen", f'"{(info.get("created_at") or "")[:10]}"') setval("business.license.spdx", f'"{spdx or "NOASSERTION"}"') setval("business.license.name", f'"{lic.get("name") or "自定义许可"}"') setval("business.license.openness", cls["level"]) setval("business.license.redistributable", "true" if cls["redistributable"] else "false") setval("mirror.gitea_repo", f'"gitinbox/{info.get("name") or pid}"') setval("mirror.visibility", cls["visibility"]) setval("mirror.upstream", f'"https://github.com/{args.repo}"') text = "\n".join(lines) header = ( f"# {info.get('name') or pid} · 项目档案\n" f"# 生成于 {today()} · 由 scripts/new_project.py 从 schema 生成\n" "# 人工补完重点:business / scenario / tech / risk.claim_vs_reality / verdict\n" "# 字段规范见 schema/project.schema.yaml\n" "\n" ) PROJECTS.mkdir(parents=True, exist_ok=True) ypath.write_text(header + text + "\n", encoding="utf-8") print(f" 已写入 {ypath.relative_to(PROJECTS.parent.parent)}") # ---- 登记到 registry ---- reg_path = DATA / "registry.yaml" reg_text = reg_path.read_text(encoding="utf-8") if f"id: {pid}" not in reg_text: entry = ( f" - id: {pid}\n" f" repo: {args.repo}\n" f" status: {args.status}\n" f" added_at: {today()}\n" f" license_level: {cls['level']}" + (" # source-available,镜像私有\n" if cls["level"] == "L3" else "\n") + f" mirror_visibility: {cls['visibility']}\n" ) reg_text = reg_text.rstrip("\n") + "\n" + entry reg_path.write_text(reg_text, encoding="utf-8") print(f" 已登记到 data/registry.yaml") print(f"\n下一步:\n 1. 补完档案(重点 risk.claim_vs_reality)\n" f" 2. python3 scripts/fetch_stats.py --id {pid}\n" f" 3. bash scripts/mirror_sync.sh --id {pid}") return 0 if __name__ == "__main__": raise SystemExit(main())