175 lines
6.5 KiB
Python
Executable File
175 lines
6.5 KiB
Python
Executable File
#!/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())
|