mirror of
https://git.openapi.site/https://github.com/desirecore/market.git
synced 2026-10-21 04:04:25 +08:00
feat: 新增外部条目上游健康检查与每周巡检工作流 (#188)
## 背景 外部条目只存上游指针,上游仓库被删除、分支被重写或技能目录移动时,客户端安装会静默失败,目前没有任何机制能发现(例如 `dingtalk-cli` 的上游已 404,见修复 PR)。 ## 变更 - **`scripts/catalog/check_upstream_health.py`**:只读,逐个检查 `skills/`、`agents/`、`teams/` 下的 `entry.json`: - 失效(退出码 1):`gone` 上游不存在 · `bad-branch` 分支不存在 · `bad-ref` 固定提交无法获取 · `bad-path` `source.path`、根 `SKILL.md` 或 children 路径在分支最新提交下缺失 - 提示:`unreachable` 网络错误(不等于失效)· `new-skill` 集合有未声明子技能(发现规则直接复用 `gen-collection-children.py` 的 `discover_children`,两者结论一致)· `outdated` 固定 ref 落后于分支 · `unpinned` 未锁定 ref - 只稀疏检出 `SKILL.md`,39 个条目本地全量运行约 12 秒 - **`scripts/catalog/test_check_upstream_health.py`**:9 个基于本地 Git 仓库的单测,已加入 `i18n-validate.yml`。 - **`.github/workflows/upstream-health.yml`**:每周一 01:23 UTC 运行(可手动触发),仅在 `desirecore/market` 运行: - 报告写入 Job Summary,并维护唯一一个带 `upstream-health` 标签的 Issue:有待处理项时创建或更新,全部正常时评论并关闭 - 有失效条目时任务标红 - 权限仅 `contents: read` + `issues: write`,**不改写任何目录文件**;修复与 `source.ref` 更新仍通过评审 PR(与固定 ref 的审查语义一致) - README 中英文补充用法。 ## 当前主干的检查结果 - 失效 1:`dingtalk-cli`(已有修复 PR) - 未声明子技能 3:`khazix-skills`、`marketingskills`、`mattpocock-skills` - 可更新 21 个,未锁定 1 个 - `amap-jsapi-skill`、`luckin-my-coffee` 在本地因代理显示 `unreachable`,需要 CI 环境确认 ## 验证 ``` uv run scripts/catalog/test_check_upstream_health.py # Ran 9 tests OK uv run scripts/catalog/check_upstream_health.py # 如上 uv run scripts/i18n/validate-i18n.py # 0 error(s) ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0191D9g1FhRQzEGTefUn6mTb Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Yige <a@wyr.me>
This commit is contained in:
266
scripts/catalog/check_upstream_health.py
Normal file
266
scripts/catalog/check_upstream_health.py
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pyyaml>=6.0"]
|
||||
# ///
|
||||
"""Report the upstream health of every external pointer in the catalog.
|
||||
|
||||
External entries (`skills/*/entry.json`, `agents/*/entry.json`,
|
||||
`teams/*/entry.json`) keep their content upstream, so a deleted repository, a
|
||||
rewritten branch or a moved skill directory silently breaks installation. This
|
||||
script looks at each upstream and reports, without rewriting anything:
|
||||
|
||||
gone upstream repository or URL no longer exists (deleted, private, 404/410)
|
||||
bad-branch source.repoBranch no longer exists upstream
|
||||
bad-ref pinned source.ref can no longer be fetched (history rewritten)
|
||||
bad-path source.path, the root SKILL.md, or a declared child path is missing at
|
||||
branch head
|
||||
unreachable network error other than "not found"; treated as unknown, not broken
|
||||
new-skill branch head has collection children that `children` does not declare
|
||||
(same discovery rules as scripts/gen-collection-children.py)
|
||||
outdated pinned source.ref is behind branch head; an update is available
|
||||
unpinned entry follows a moving branch without source.ref
|
||||
ok nothing to report
|
||||
|
||||
Path checks run against branch head: they answer "will this pointer still work
|
||||
after the next ref bump", while `bad-ref` answers "does the pinned ref still
|
||||
resolve". Only skills get path checks; agents and teams are AgentFS repositories.
|
||||
|
||||
Usage:
|
||||
uv run scripts/catalog/check_upstream_health.py # text table
|
||||
uv run scripts/catalog/check_upstream_health.py --format markdown
|
||||
uv run scripts/catalog/check_upstream_health.py --format json
|
||||
uv run scripts/catalog/check_upstream_health.py dingtalk-cli humanlayer-skills
|
||||
|
||||
Exit code: 1 when any entry is gone / bad-branch / bad-ref / bad-path, else 0.
|
||||
Needs Git and network access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
KINDS = ("skills", "agents", "teams")
|
||||
|
||||
BROKEN = ("gone", "bad-branch", "bad-ref", "bad-path")
|
||||
ORDER = (*BROKEN, "unreachable", "new-skill", "outdated", "unpinned", "ok")
|
||||
|
||||
# Git asks for credentials when a public repository disappears; never prompt.
|
||||
GIT_ENV = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"}
|
||||
NOT_FOUND = re.compile(
|
||||
r"repository not found|authentication failed|could not read username|"
|
||||
r"not found|does not exist|does not appear to be a git repository",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _load_generator():
|
||||
"""Reuse the collection generator's discovery so both tools agree on children."""
|
||||
path = REPO_ROOT / "scripts" / "gen-collection-children.py"
|
||||
spec = importlib.util.spec_from_file_location("market_collection_generator", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
GENERATOR = _load_generator()
|
||||
|
||||
|
||||
def git(*args: str, cwd: Path | None = None, timeout: int = 180) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True,
|
||||
env=GIT_ENV, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(args, 124, "", "timed out")
|
||||
|
||||
|
||||
def last_line(text: str) -> str:
|
||||
lines = [line for line in text.strip().splitlines() if line.strip()]
|
||||
return lines[-1][:200] if lines else "no output"
|
||||
|
||||
|
||||
def check_git(entry: dict, kind: str) -> list[tuple[str, str]]:
|
||||
source = entry["source"]
|
||||
url = source["repoUrl"]
|
||||
branch = source.get("repoBranch")
|
||||
ref = source.get("ref")
|
||||
issues: list[tuple[str, str]] = []
|
||||
|
||||
query = ["--heads", url, branch] if branch else [url, "HEAD"]
|
||||
remote = git("ls-remote", *query, timeout=90)
|
||||
if remote.returncode != 0:
|
||||
detail = last_line(remote.stderr)
|
||||
return [("gone" if NOT_FOUND.search(remote.stderr) else "unreachable", detail)]
|
||||
heads = [line.split()[0] for line in remote.stdout.splitlines() if line.strip()]
|
||||
if branch and not heads:
|
||||
return [("bad-branch", f"branch '{branch}' not found upstream")]
|
||||
if not heads:
|
||||
return [("unreachable", "remote advertised no HEAD")]
|
||||
head = heads[0]
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=f"health-{entry['id']}-") as tmp:
|
||||
repo = Path(tmp) / "repo"
|
||||
clone = ["clone", "--quiet", "--depth", "1", "--filter=blob:none", "--no-checkout"]
|
||||
if branch:
|
||||
clone += ["--single-branch", "-b", branch]
|
||||
cloned = git(*clone, url, str(repo), timeout=300)
|
||||
if cloned.returncode != 0:
|
||||
return [("unreachable", f"clone failed: {last_line(cloned.stderr)}")]
|
||||
|
||||
if ref and ref != head:
|
||||
fetched = git("fetch", "--quiet", "--depth", "1", "origin", ref, cwd=repo, timeout=180)
|
||||
if fetched.returncode != 0:
|
||||
issues.append(("bad-ref", f"pinned {ref[:12]} is no longer fetchable"))
|
||||
|
||||
if kind == "skills":
|
||||
issues += check_skill_paths(entry, repo)
|
||||
|
||||
if ref and ref != head:
|
||||
issues.append(("outdated", f"{ref[:7]} -> {head[:7]}"))
|
||||
if not ref:
|
||||
issues.append(("unpinned", f"follows '{branch or 'HEAD'}' at {head[:7]}"))
|
||||
return issues
|
||||
|
||||
|
||||
def check_skill_paths(entry: dict, repo: Path) -> list[tuple[str, str]]:
|
||||
# Materialise only SKILL.md files; the generator reads their frontmatter.
|
||||
git("sparse-checkout", "set", "--no-cone", "SKILL.md", cwd=repo)
|
||||
checkout = git("checkout", "--quiet", "HEAD", cwd=repo, timeout=300)
|
||||
if checkout.returncode != 0:
|
||||
return [("unreachable", f"checkout failed: {last_line(checkout.stderr)}")]
|
||||
|
||||
skill_dirs = {p.parent.relative_to(repo).as_posix() for p in repo.rglob("SKILL.md")
|
||||
if ".git" not in p.parts}
|
||||
skill_dirs = {"" if d == "." else d for d in skill_dirs}
|
||||
issues: list[tuple[str, str]] = []
|
||||
children = entry.get("children") or []
|
||||
|
||||
if children:
|
||||
declared = {c["path"].strip("/") for c in children}
|
||||
for child in children:
|
||||
if child["path"].strip("/") not in skill_dirs:
|
||||
issues.append(("bad-path", f"child '{child['id']}' -> '{child['path']}' has no SKILL.md"))
|
||||
discovered = [c for c in GENERATOR.discover_children(repo) if c["path"] not in declared]
|
||||
declared_ids = {c["id"] for c in children}
|
||||
extra = [c["path"] for c in discovered if c["id"] not in declared_ids]
|
||||
if extra:
|
||||
shown = ", ".join(extra[:8]) + (f" (+{len(extra) - 8} more)" if len(extra) > 8 else "")
|
||||
issues.append(("new-skill", shown))
|
||||
else:
|
||||
path = (entry["source"].get("path") or "").strip("/")
|
||||
if path not in skill_dirs:
|
||||
where = f"source.path '{path}'" if path else "repository root"
|
||||
issues.append(("bad-path", f"{where} has no SKILL.md"))
|
||||
return issues
|
||||
|
||||
|
||||
def check_http(entry: dict) -> list[tuple[str, str]]:
|
||||
url = entry["source"].get("url") or entry["source"].get("repoUrl")
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "desirecore-market-health", "Range": "bytes=0-0"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30):
|
||||
return []
|
||||
except urllib.error.HTTPError as err:
|
||||
if err.code in (404, 410):
|
||||
return [("gone", f"HTTP {err.code}")]
|
||||
return [("unreachable", f"HTTP {err.code}")]
|
||||
except Exception as err: # noqa: BLE001 - network errors are reported, not raised
|
||||
return [("unreachable", str(err)[:200])]
|
||||
|
||||
|
||||
def check_entry(path: Path) -> dict:
|
||||
entry = json.loads(path.read_text(encoding="utf-8"))
|
||||
kind = path.parent.parent.name
|
||||
source = entry.get("source", {})
|
||||
issues = check_git(entry, kind) if source.get("kind") == "git" else check_http(entry)
|
||||
return {
|
||||
"id": entry["id"],
|
||||
"kind": kind.rstrip("s"),
|
||||
"url": source.get("repoUrl") or source.get("url"),
|
||||
"issues": [{"status": s, "detail": d} for s, d in (issues or [("ok", "")])],
|
||||
}
|
||||
|
||||
|
||||
def worst(result: dict) -> int:
|
||||
return min(ORDER.index(i["status"]) for i in result["issues"])
|
||||
|
||||
|
||||
def render_text(results: list[dict]) -> str:
|
||||
rows = []
|
||||
for r in sorted(results, key=lambda r: (worst(r), r["id"])):
|
||||
for issue in sorted(r["issues"], key=lambda i: ORDER.index(i["status"])):
|
||||
rows.append(f"{issue['status']:<11} {r['kind']:<6} {r['id']:<32} {issue['detail']}")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def render_markdown(results: list[dict]) -> str:
|
||||
counts = {s: 0 for s in ORDER}
|
||||
for r in results:
|
||||
counts[ORDER[worst(r)]] += 1
|
||||
summary = " · ".join(f"{s}: {n}" for s, n in counts.items() if n)
|
||||
out = [f"Checked {len(results)} external entries. {summary}", ""]
|
||||
sections = [
|
||||
("Broken — installation fails", BROKEN),
|
||||
("Unreachable — network error, status unknown", ("unreachable",)),
|
||||
("Undeclared upstream skills", ("new-skill",)),
|
||||
("Updates available", ("outdated",)),
|
||||
("Unpinned", ("unpinned",)),
|
||||
]
|
||||
for title, statuses in sections:
|
||||
rows = [(r, i) for r in results for i in r["issues"] if i["status"] in statuses]
|
||||
if not rows:
|
||||
continue
|
||||
out += [f"### {title} ({len(rows)})", "", "| Status | Entry | Detail |", "|---|---|---|"]
|
||||
for r, i in sorted(rows, key=lambda x: (ORDER.index(x[1]["status"]), x[0]["id"])):
|
||||
detail = i["detail"].replace("|", "\\|")
|
||||
out.append(f"| `{i['status']}` | [{r['kind']}/{r['id']}]({r['url']}) | {detail} |")
|
||||
out.append("")
|
||||
ok = sorted(r["id"] for r in results if worst(r) == ORDER.index("ok"))
|
||||
if ok:
|
||||
out += [f"<details><summary>OK ({len(ok)})</summary>", "", ", ".join(f"`{i}`" for i in ok), "", "</details>"]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("ids", nargs="*", help="entry IDs to check (default: every external entry)")
|
||||
parser.add_argument("--format", choices=("text", "markdown", "json"), default="text")
|
||||
parser.add_argument("--jobs", type=int, default=8, help="parallel upstream checks")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
paths = [p for kind in KINDS for p in sorted((REPO_ROOT / kind).glob("*/entry.json"))]
|
||||
if args.ids:
|
||||
paths = [p for p in paths if p.parent.name in set(args.ids)]
|
||||
missing = set(args.ids) - {p.parent.name for p in paths}
|
||||
if missing:
|
||||
print(f"unknown entry id(s): {', '.join(sorted(missing))}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
|
||||
results = list(pool.map(check_entry, paths))
|
||||
|
||||
if args.format == "json":
|
||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
elif args.format == "markdown":
|
||||
print(render_markdown(results))
|
||||
else:
|
||||
print(render_text(results))
|
||||
return 1 if any(i["status"] in BROKEN for r in results for i in r["issues"]) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
119
scripts/catalog/test_check_upstream_health.py
Normal file
119
scripts/catalog/test_check_upstream_health.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pyyaml>=6.0"]
|
||||
# ///
|
||||
"""Unit tests for the upstream health check, using local Git repositories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CHECKER_PATH = Path(__file__).with_name("check_upstream_health.py")
|
||||
SPEC = importlib.util.spec_from_file_location("market_upstream_health", CHECKER_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
CHECKER = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = CHECKER
|
||||
SPEC.loader.exec_module(CHECKER)
|
||||
|
||||
|
||||
def sh(*args: str, cwd: Path) -> str:
|
||||
return subprocess.run(args, cwd=cwd, check=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
class UpstreamHealthTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name)
|
||||
self.upstream = self.root / "upstream"
|
||||
self.upstream.mkdir()
|
||||
sh("git", "init", "--quiet", "-b", "main", cwd=self.upstream)
|
||||
for key, value in (("user.name", "t"), ("user.email", "t@example.com"),
|
||||
("uploadpack.allowFilter", "true"),
|
||||
("uploadpack.allowAnySHA1InWant", "true")):
|
||||
sh("git", "config", key, value, cwd=self.upstream)
|
||||
self.first = self.commit({"skills/alpha/SKILL.md": "---\nname: alpha\ndescription: A.\n---\n"})
|
||||
self.url = self.upstream.as_uri()
|
||||
self.catalog = self.root / "catalog"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tmp.cleanup()
|
||||
|
||||
def commit(self, files: dict[str, str]) -> str:
|
||||
for rel, text in files.items():
|
||||
path = self.upstream / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
sh("git", "add", "-A", cwd=self.upstream)
|
||||
sh("git", "commit", "--quiet", "-m", "c", cwd=self.upstream)
|
||||
return sh("git", "rev-parse", "HEAD", cwd=self.upstream)
|
||||
|
||||
def entry(self, **source) -> Path:
|
||||
base = {"kind": "git", "repoUrl": self.url, "repoBranch": "main"}
|
||||
data = {"id": "demo", "source": {**base, **source}}
|
||||
children = data["source"].pop("children", None)
|
||||
if children is not None:
|
||||
data["children"] = children
|
||||
path = self.catalog / "skills" / "demo" / "entry.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def statuses(self, path: Path) -> dict[str, str]:
|
||||
return {i["status"]: i["detail"] for i in CHECKER.check_entry(path)["issues"]}
|
||||
|
||||
def test_pinned_head_with_valid_path_is_ok(self) -> None:
|
||||
result = self.statuses(self.entry(ref=self.first, path="skills/alpha"))
|
||||
self.assertEqual(result, {"ok": ""})
|
||||
|
||||
def test_missing_repository_is_gone(self) -> None:
|
||||
result = self.statuses(self.entry(repoUrl=(self.root / "missing").as_uri()))
|
||||
self.assertIn("gone", result)
|
||||
|
||||
def test_missing_branch_is_bad_branch(self) -> None:
|
||||
result = self.statuses(self.entry(repoBranch="release", ref=self.first))
|
||||
self.assertIn("bad-branch", result)
|
||||
|
||||
def test_moved_skill_is_bad_path(self) -> None:
|
||||
result = self.statuses(self.entry(ref=self.first, path="alpha"))
|
||||
self.assertIn("bad-path", result)
|
||||
|
||||
def test_newer_head_is_outdated(self) -> None:
|
||||
self.commit({"README.md": "x"})
|
||||
result = self.statuses(self.entry(ref=self.first, path="skills/alpha"))
|
||||
self.assertIn("outdated", result)
|
||||
self.assertNotIn("bad-ref", result)
|
||||
|
||||
def test_unfetchable_ref_is_bad_ref(self) -> None:
|
||||
result = self.statuses(self.entry(ref="0" * 40, path="skills/alpha"))
|
||||
self.assertIn("bad-ref", result)
|
||||
|
||||
def test_unpinned_entry_is_reported(self) -> None:
|
||||
result = self.statuses(self.entry(path="skills/alpha"))
|
||||
self.assertIn("unpinned", result)
|
||||
|
||||
def test_collection_reports_missing_and_new_children(self) -> None:
|
||||
head = self.commit({"skills/beta/SKILL.md": "---\nname: beta\ndescription: B.\n---\n"})
|
||||
children = [{"id": "alpha", "path": "skills/alpha"}, {"id": "gamma", "path": "skills/gamma"}]
|
||||
result = self.statuses(self.entry(ref=head, children=children))
|
||||
self.assertIn("gamma", result["bad-path"])
|
||||
self.assertEqual(result["new-skill"], "skills/beta")
|
||||
|
||||
def test_markdown_groups_broken_entries_first(self) -> None:
|
||||
results = [
|
||||
{"id": "a", "kind": "skill", "url": "u", "issues": [{"status": "outdated", "detail": "x"}]},
|
||||
{"id": "b", "kind": "skill", "url": "u", "issues": [{"status": "gone", "detail": "y"}]},
|
||||
]
|
||||
text = CHECKER.render_markdown(results)
|
||||
self.assertLess(text.index("Broken"), text.index("Updates available"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user