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:
1
.github/workflows/i18n-validate.yml
vendored
1
.github/workflows/i18n-validate.yml
vendored
@@ -67,6 +67,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
uv run --quiet scripts/catalog/test_validate_catalog_metadata.py
|
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_collection_generator.py
|
||||||
|
uv run --quiet scripts/catalog/test_check_upstream_health.py
|
||||||
|
|
||||||
- name: Validate catalog metadata
|
- name: Validate catalog metadata
|
||||||
if: steps.changes.outputs.relevant == 'true'
|
if: steps.changes.outputs.relevant == 'true'
|
||||||
|
|||||||
87
.github/workflows/upstream-health.yml
vendored
Normal file
87
.github/workflows/upstream-health.yml
vendored
Normal file
@@ -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
|
||||||
@@ -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/i18n/test_validate_i18n.py
|
||||||
uv run scripts/catalog/test_validate_catalog_metadata.py
|
uv run scripts/catalog/test_validate_catalog_metadata.py
|
||||||
uv run scripts/catalog/test_collection_generator.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.
|
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.
|
# Optional source-URL availability checks in addition to normal validation.
|
||||||
uv run scripts/i18n/validate-i18n.py --online
|
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.
|
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.
|
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
|
## Security and trust
|
||||||
|
|||||||
@@ -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/i18n/test_validate_i18n.py
|
||||||
uv run scripts/catalog/test_validate_catalog_metadata.py
|
uv run scripts/catalog/test_validate_catalog_metadata.py
|
||||||
uv run scripts/catalog/test_collection_generator.py
|
uv run scripts/catalog/test_collection_generator.py
|
||||||
|
uv run scripts/catalog/test_check_upstream_health.py
|
||||||
```
|
```
|
||||||
|
|
||||||
传入技能路径只缩小技能正文的校验范围,市场级与 sidecar 检查仍会执行。应根据改动选择相关测试文件,而不是每次修改文档都运行全部测试。
|
传入技能路径只缩小技能正文的校验范围,市场级与 sidecar 检查仍会执行。应根据改动选择相关测试文件,而不是每次修改文档都运行全部测试。
|
||||||
@@ -221,10 +222,16 @@ uv run scripts/gen-collection-children.py --check
|
|||||||
|
|
||||||
# 可选:在常规校验之外检查来源 URL 是否可访问。
|
# 可选:在常规校验之外检查来源 URL 是否可访问。
|
||||||
uv run scripts/i18n/validate-i18n.py --online
|
uv run scripts/i18n/validate-i18n.py --online
|
||||||
|
|
||||||
|
# 检查所有外部条目:上游是否已删除、分支或固定提交是否失效、技能路径是否移动、
|
||||||
|
# 集合是否有未声明的子技能,以及是否有可同步的更新。
|
||||||
|
uv run scripts/catalog/check_upstream_health.py
|
||||||
```
|
```
|
||||||
|
|
||||||
集合检查需要 Git 和网络。可变来源集合会被报告并跳过,因为无法保证生成清单可复现。省略 `--check` 会进入写入模式;提交前应同时检查重新生成的子技能和对应 sidecar 事实。
|
集合检查需要 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 链接或中英文文档已被检查;这些内容仍需单独审查。
|
[校验工作流](.github/workflows/i18n-validate.yml)是 CI 行为的权威来源。它会检测相关路径,纯文档改动可能跳过目录校验。跳过后显示成功的任务,不代表 Markdown 链接或中英文文档已被检查;这些内容仍需单独审查。
|
||||||
|
|
||||||
## 安全与信任边界
|
## 安全与信任边界
|
||||||
|
|||||||
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