discover_bumps.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. #!/usr/bin/env python3
  2. """Discover plugins in marketplace.json whose upstream repo has moved past
  3. their pinned SHA, update the file in place, and emit a summary.
  4. Adapted from claude-plugins-community-internal's discover_bumps.py for the
  5. single-file marketplace.json format used by claude-plugins-official.
  6. Usage: discover_bumps.py [--plugin NAME] [--max N] [--dry-run]
  7. """
  8. import argparse
  9. import json
  10. import os
  11. import re
  12. import subprocess
  13. import sys
  14. from datetime import datetime, timezone
  15. from typing import Any
  16. MARKETPLACE_PATH = ".claude-plugin/marketplace.json"
  17. def gh_api(path: str) -> Any:
  18. """GET from the GitHub API. None on not-found; raises on other errors.
  19. "Not found" covers both 404 (resource gone) and 422 "No commit found
  20. for SHA" (force-pushed away). Both mean the thing we asked for isn't
  21. there — treating them the same lets callers handle dead refs uniformly.
  22. """
  23. r = subprocess.run(
  24. ["gh", "api", path], capture_output=True, text=True
  25. )
  26. if r.returncode != 0:
  27. combined = r.stdout + r.stderr
  28. if any(s in combined for s in ("404", "Not Found", "No commit found")):
  29. return None
  30. raise RuntimeError(f"gh api {path}: {r.stderr.strip() or r.stdout.strip()}")
  31. return json.loads(r.stdout)
  32. def parse_github_repo(url: str) -> tuple[str, str] | None:
  33. """Extract (owner, repo) from a URL or owner/repo shorthand."""
  34. # Full URL: https://github.com/owner/repo(.git)(/...)
  35. m = re.match(r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/|$)", url)
  36. if m:
  37. return m.group(1), m.group(2)
  38. # Shorthand: owner/repo
  39. m = re.match(r"^([\w.-]+)/([\w.-]+)$", url)
  40. if m:
  41. return m.group(1), m.group(2)
  42. return None
  43. def latest_sha(owner: str, repo: str, *, ref: str | None, path: str | None) -> str | None:
  44. """Latest commit SHA for the repo, optionally scoped to a ref and/or path."""
  45. if path:
  46. # Scoped to a subdirectory — use the commits list endpoint with path filter.
  47. q = f"repos/{owner}/{repo}/commits?per_page=1&path={path}"
  48. if ref:
  49. q += f"&sha={ref}"
  50. commits = gh_api(q)
  51. if not commits:
  52. return None
  53. return commits[0]["sha"]
  54. # Whole repo — the single-ref endpoint is cheaper.
  55. if not ref:
  56. meta = gh_api(f"repos/{owner}/{repo}")
  57. if not meta:
  58. return None
  59. ref = meta["default_branch"]
  60. c = gh_api(f"repos/{owner}/{repo}/commits/{ref}")
  61. return c["sha"] if c else None
  62. def pinned_age_days(owner: str, repo: str, sha: str) -> int | None:
  63. """Days since the pinned commit was authored. Used for oldest-first rotation."""
  64. c = gh_api(f"repos/{owner}/{repo}/commits/{sha}")
  65. if not c:
  66. return None
  67. dt = datetime.fromisoformat(
  68. c["commit"]["committer"]["date"].replace("Z", "+00:00")
  69. )
  70. return (datetime.now(timezone.utc) - dt).days
  71. def main() -> int:
  72. ap = argparse.ArgumentParser()
  73. ap.add_argument("--plugin", help="only check this plugin")
  74. ap.add_argument("--max", type=int, default=20, help="cap bumps emitted")
  75. ap.add_argument("--dry-run", action="store_true", help="don't write marketplace.json")
  76. args = ap.parse_args()
  77. with open(MARKETPLACE_PATH) as f:
  78. marketplace = json.load(f)
  79. plugins = marketplace.get("plugins", [])
  80. bumps: list[dict] = []
  81. dead: list[str] = []
  82. skipped_non_github = 0
  83. checked = 0
  84. for plugin in plugins:
  85. name = plugin.get("name", "?")
  86. src = plugin.get("source")
  87. # Only process object sources with a sha field
  88. if not isinstance(src, dict) or "sha" not in src:
  89. continue
  90. # Filter to specific plugin if requested
  91. if args.plugin and name != args.plugin:
  92. continue
  93. checked += 1
  94. kind = src.get("source")
  95. url = src.get("url", "")
  96. path = src.get("path")
  97. ref = src.get("ref")
  98. pinned = src.get("sha")
  99. slug = parse_github_repo(url)
  100. if not slug:
  101. skipped_non_github += 1
  102. continue
  103. owner, repo = slug
  104. try:
  105. latest = latest_sha(owner, repo, ref=ref, path=path)
  106. except RuntimeError as e:
  107. print(f"::warning::{name}: {e}", file=sys.stderr)
  108. continue
  109. if latest is None:
  110. dead.append(f"{name} ({owner}/{repo})")
  111. continue
  112. if latest == pinned:
  113. continue # up to date
  114. # Age lookup for rotation — oldest-pinned first prevents starvation.
  115. try:
  116. age = pinned_age_days(owner, repo, pinned) if pinned else None
  117. except RuntimeError as e:
  118. print(f"::warning::{name}: age lookup failed: {e}", file=sys.stderr)
  119. age = None
  120. bumps.append({
  121. "name": name,
  122. "kind": kind,
  123. "url": url,
  124. "path": path or "",
  125. "ref": ref or "",
  126. "old_sha": pinned or "",
  127. "new_sha": latest,
  128. "age_days": age if age is not None else 10**6,
  129. })
  130. # Oldest-pinned first so nothing starves under the cap.
  131. bumps.sort(key=lambda b: -b["age_days"])
  132. emitted = bumps[: args.max]
  133. # Apply bumps to marketplace data
  134. if emitted and not args.dry_run:
  135. bump_map = {b["name"]: b["new_sha"] for b in emitted}
  136. for plugin in plugins:
  137. name = plugin.get("name")
  138. src = plugin.get("source")
  139. if isinstance(src, dict) and name in bump_map:
  140. src["sha"] = bump_map[name]
  141. with open(MARKETPLACE_PATH, "w") as f:
  142. json.dump(marketplace, f, indent=2, ensure_ascii=False)
  143. f.write("\n")
  144. # Write GitHub outputs
  145. out = os.environ.get("GITHUB_OUTPUT")
  146. if out:
  147. bumped_names = ",".join(b["name"] for b in emitted)
  148. with open(out, "a") as fh:
  149. fh.write(f"count={len(emitted)}\n")
  150. fh.write(f"bumped_names={bumped_names}\n")
  151. # Write GitHub step summary
  152. summary = os.environ.get("GITHUB_STEP_SUMMARY")
  153. if summary:
  154. with open(summary, "a") as fh:
  155. fh.write("## SHA Bump Discovery\n\n")
  156. fh.write(f"- Checked: {checked} SHA-pinned entries\n")
  157. fh.write(f"- Stale: {len(bumps)} (applying {len(emitted)}, cap {args.max})\n")
  158. if skipped_non_github:
  159. fh.write(f"- Skipped non-GitHub: {skipped_non_github}\n")
  160. if dead:
  161. fh.write(f"- **Dead upstream** ({len(dead)}): {', '.join(dead)}\n")
  162. if emitted:
  163. fh.write("\n| Plugin | Old | New | Age |\n|---|---|---|---|\n")
  164. for b in emitted:
  165. old = b["old_sha"][:8] if b["old_sha"] else "(unpinned)"
  166. fh.write(f"| {b['name']} | `{old}` | `{b['new_sha'][:8]}` | {b['age_days']}d |\n")
  167. # Write PR body for the workflow to use
  168. pr_body_path = os.environ.get("PR_BODY_PATH", "/tmp/bump-pr-body.md")
  169. if emitted:
  170. with open(pr_body_path, "w") as fh:
  171. fh.write("Upstream repos moved. Bumping pinned SHAs so plugins track latest.\n\n")
  172. fh.write("| Plugin | Old | New | Upstream |\n")
  173. fh.write("|--------|-----|-----|----------|\n")
  174. for b in emitted:
  175. old = b["old_sha"][:8] if b["old_sha"] else "(unpinned)"
  176. slug_str = re.sub(r"https?://github\.com/", "", b["url"])
  177. slug_str = re.sub(r"\.git$", "", slug_str)
  178. compare = f"https://github.com/{slug_str}/compare/{b['old_sha'][:12]}...{b['new_sha'][:12]}"
  179. fh.write(f"| `{b['name']}` | `{old}` | `{b['new_sha'][:8]}` | [diff]({compare}) |\n")
  180. fh.write(f"\n---\n_Auto-generated by `bump-plugin-shas.yml` on {datetime.now(timezone.utc).strftime('%Y-%m-%d')}_\n")
  181. # Console summary
  182. print(f"Checked {checked} SHA-pinned plugins", file=sys.stderr)
  183. print(f"Stale: {len(bumps)}, applying: {len(emitted)}", file=sys.stderr)
  184. if dead:
  185. print(f"Dead upstream: {', '.join(dead)}", file=sys.stderr)
  186. for b in emitted:
  187. old = b["old_sha"][:8] if b["old_sha"] else "unpinned"
  188. print(f" {b['name']}: {old} -> {b['new_sha'][:8]} ({b['age_days']}d)", file=sys.stderr)
  189. return 0
  190. if __name__ == "__main__":
  191. sys.exit(main())