From 6cd60a71411d3b07da68862e181a36dcbf443a54 Mon Sep 17 00:00:00 2001 From: mashagua <993498039@qq.com> Date: Sat, 26 Sep 2026 16:13:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E4=B8=8A=E6=B8=B8=E5=81=A5=E5=BA=B7=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E4=B8=8E=E6=AF=8F=E5=91=A8=E5=B7=A1=E6=A3=80=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=20(#188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 外部条目只存上游指针,上游仓库被删除、分支被重写或技能目录移动时,客户端安装会静默失败,目前没有任何机制能发现(例如 `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 Co-authored-by: Yige --- .github/workflows/i18n-validate.yml | 1 + .github/workflows/upstream-health.yml | 87 ++++++ README.md | 7 + README.zh-CN.md | 7 + scripts/catalog/check_upstream_health.py | 266 ++++++++++++++++++ scripts/catalog/test_check_upstream_health.py | 119 ++++++++ 6 files changed, 487 insertions(+) create mode 100644 .github/workflows/upstream-health.yml create mode 100644 scripts/catalog/check_upstream_health.py create mode 100644 scripts/catalog/test_check_upstream_health.py diff --git a/.github/workflows/i18n-validate.yml b/.github/workflows/i18n-validate.yml index 66c6290..5f1ba0f 100644 --- a/.github/workflows/i18n-validate.yml +++ b/.github/workflows/i18n-validate.yml @@ -67,6 +67,7 @@ jobs: run: | uv run --quiet scripts/catalog/test_validate_catalog_metadata.py uv run --quiet scripts/catalog/test_collection_generator.py + uv run --quiet scripts/catalog/test_check_upstream_health.py - name: Validate catalog metadata if: steps.changes.outputs.relevant == 'true' diff --git a/.github/workflows/upstream-health.yml b/.github/workflows/upstream-health.yml new file mode 100644 index 0000000..3533c04 --- /dev/null +++ b/.github/workflows/upstream-health.yml @@ -0,0 +1,87 @@ +name: Upstream Health + +# Weekly check that every external pointer (skills/agents/teams entry.json) +# still resolves upstream. Results go to one tracking issue labelled +# `upstream-health`, which is updated in place and closed once nothing is +# actionable. Fixing an entry stays a normal reviewed PR; this workflow never +# rewrites catalog files. + +on: + schedule: + - cron: "23 1 * * 1" # Mondays 01:23 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: upstream-health + cancel-in-progress: false + +jobs: + check: + if: github.repository == 'desirecore/market' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Check upstreams + id: check + run: | + set +e + uv run --quiet scripts/catalog/check_upstream_health.py --format markdown > report.md + code=$? + set -e + if [ "$code" -gt 1 ]; then + echo "health check crashed with exit code $code" >&2 + exit "$code" + fi + echo "broken=$([ "$code" = 1 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + if grep -qE '^### (Broken|Undeclared upstream skills|Updates available) ' report.md; then + echo "actionable=true" >> "$GITHUB_OUTPUT" + else + echo "actionable=false" >> "$GITHUB_OUTPUT" + fi + cat report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Update tracking issue + env: + GH_TOKEN: ${{ github.token }} + ACTIONABLE: ${{ steps.check.outputs.actionable }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + label=upstream-health + gh label create "$label" --color D93F0B \ + --description "Upstream availability of external catalog entries" 2>/dev/null || true + + { + echo "_Updated by [Upstream Health]($RUN_URL) on $(date -u +%Y-%m-%d)._" + echo + echo "Fix entries through a normal PR: repoint or remove \`gone\` entries, correct \`bad-*\` paths, and review upstream changes before bumping \`source.ref\` for \`outdated\` entries (rerun \`scripts/gen-collection-children.py\` for collections). \`unreachable\` means a network error, not a confirmed failure." + echo + cat report.md + } > body.md + + issue=$(gh issue list --label "$label" --state open --limit 1 --json number --jq '.[0].number // empty') + if [ "$ACTIONABLE" = "true" ]; then + if [ -n "$issue" ]; then + gh issue edit "$issue" --body-file body.md + else + gh issue create --title "Upstream health report" --label "$label" --body-file body.md + fi + elif [ -n "$issue" ]; then + gh issue comment "$issue" --body "All external entries resolve upstream and are current as of $RUN_URL. Closing." + gh issue close "$issue" + fi + + - name: Fail when an entry is broken + if: steps.check.outputs.broken == 'true' + run: | + echo "::error::At least one external entry no longer resolves upstream; see the upstream-health issue." + exit 1 diff --git a/README.md b/README.md index 1f0d283..f1087bf 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ uv run scripts/i18n/translate.py --check skills/web-access uv run scripts/i18n/test_validate_i18n.py uv run scripts/catalog/test_validate_catalog_metadata.py uv run scripts/catalog/test_collection_generator.py +uv run scripts/catalog/test_check_upstream_health.py ``` Passing a skill path narrows skill-body validation, but market-level and sidecar checks still run. Choose the relevant test file for your change rather than treating every documentation edit as a reason to run all test suites. @@ -221,10 +222,16 @@ uv run scripts/gen-collection-children.py --check # Optional source-URL availability checks in addition to normal validation. uv run scripts/i18n/validate-i18n.py --online + +# Report deleted upstreams, missing branches or pinned refs, moved skill paths, +# undeclared collection children, and available updates for every external entry. +uv run scripts/catalog/check_upstream_health.py ``` Collection checking requires Git and network access. Mutable collections are reported and skipped because their generated inventory is not reproducible. Omitting `--check` runs the generator in write mode; review both the regenerated children and the corresponding sidecar facts before committing. +The upstream health check is read-only and exits non-zero when an entry is `gone` or has a `bad-branch`, `bad-ref`, or `bad-path`. `unreachable` is a network error, not a confirmed failure. The [upstream health workflow](.github/workflows/upstream-health.yml) runs it weekly and keeps one `upstream-health` issue current; fixes and `source.ref` bumps still go through reviewed PRs. + The [validation workflow](.github/workflows/i18n-validate.yml) is the source of truth for CI. It detects relevant paths and can skip catalog validation for documentation-only changes. A green skipped job is not evidence that Markdown links or bilingual documentation were checked; review those separately. ## Security and trust diff --git a/README.zh-CN.md b/README.zh-CN.md index d4d57b3..643b399 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -209,6 +209,7 @@ uv run scripts/i18n/translate.py --check skills/web-access uv run scripts/i18n/test_validate_i18n.py uv run scripts/catalog/test_validate_catalog_metadata.py uv run scripts/catalog/test_collection_generator.py +uv run scripts/catalog/test_check_upstream_health.py ``` 传入技能路径只缩小技能正文的校验范围,市场级与 sidecar 检查仍会执行。应根据改动选择相关测试文件,而不是每次修改文档都运行全部测试。 @@ -221,10 +222,16 @@ uv run scripts/gen-collection-children.py --check # 可选:在常规校验之外检查来源 URL 是否可访问。 uv run scripts/i18n/validate-i18n.py --online + +# 检查所有外部条目:上游是否已删除、分支或固定提交是否失效、技能路径是否移动、 +# 集合是否有未声明的子技能,以及是否有可同步的更新。 +uv run scripts/catalog/check_upstream_health.py ``` 集合检查需要 Git 和网络。可变来源集合会被报告并跳过,因为无法保证生成清单可复现。省略 `--check` 会进入写入模式;提交前应同时检查重新生成的子技能和对应 sidecar 事实。 +上游健康检查只读,不改写条目;出现 `gone`、`bad-branch`、`bad-ref` 或 `bad-path` 时以非零状态退出。`unreachable` 表示网络错误,不等于确认失效。[上游健康工作流](.github/workflows/upstream-health.yml)每周运行一次,并维护唯一一个 `upstream-health` Issue;修复条目和更新 `source.ref` 仍需通过经过评审的 PR。 + [校验工作流](.github/workflows/i18n-validate.yml)是 CI 行为的权威来源。它会检测相关路径,纯文档改动可能跳过目录校验。跳过后显示成功的任务,不代表 Markdown 链接或中英文文档已被检查;这些内容仍需单独审查。 ## 安全与信任边界 diff --git a/scripts/catalog/check_upstream_health.py b/scripts/catalog/check_upstream_health.py new file mode 100644 index 0000000..ef5186a --- /dev/null +++ b/scripts/catalog/check_upstream_health.py @@ -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"
OK ({len(ok)})", "", ", ".join(f"`{i}`" for i in ok), "", "
"] + 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()) diff --git a/scripts/catalog/test_check_upstream_health.py b/scripts/catalog/test_check_upstream_health.py new file mode 100644 index 0000000..af7bf69 --- /dev/null +++ b/scripts/catalog/test_check_upstream_health.py @@ -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()