631 lines
20 KiB
Python
631 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Deterministic runner for the AO weekly release notes skill.
|
|
|
|
Queries the GitHub API (via the `gh` CLI) for releases, merged PRs, commits,
|
|
contributors, and star counts in a 7-day window, then renders a markdown
|
|
post in the house style. See SKILL.md for the output contract.
|
|
|
|
This script never fabricates data. Every value in the output traces back to
|
|
a `gh` or `git` command in this file. If a data source is unavailable, the
|
|
corresponding field is rendered as `(unavailable)` or the run exits with a
|
|
non-zero code — see the error handling table in SKILL.md.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
DEFAULT_REPO = "ComposioHQ/agent-orchestrator"
|
|
DEFAULT_WINDOW_DAYS = 7
|
|
MAX_BODY_CHARS = 2000
|
|
MIN_HIGHLIGHTS = 8
|
|
MAX_HIGHLIGHTS = 14
|
|
|
|
EXIT_OK = 0
|
|
EXIT_INPUT_ERROR = 1
|
|
EXIT_GH_ERROR = 2
|
|
EXIT_NO_ACTIVITY = 3
|
|
|
|
|
|
@dataclass
|
|
class PullRequest:
|
|
number: int
|
|
title: str
|
|
author: str
|
|
merged_at: str
|
|
labels: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def theme(self) -> str:
|
|
title = self.title.lower()
|
|
if title.startswith("feat"):
|
|
return "feature"
|
|
if title.startswith("fix"):
|
|
return "fix"
|
|
if title.startswith("refactor") or title.startswith("perf"):
|
|
return "refactor"
|
|
if title.startswith("docs"):
|
|
return "docs"
|
|
if title.startswith("test"):
|
|
return "test"
|
|
if title.startswith("chore") or title.startswith("ci"):
|
|
return "chore"
|
|
return "other"
|
|
|
|
@property
|
|
def clean_title(self) -> str:
|
|
# Strip the conventional-commit prefix for the bullet text.
|
|
# e.g. "feat(cli): add --json flag" -> "add --json flag"
|
|
title = self.title.strip()
|
|
for prefix in ("feat", "fix", "refactor", "perf", "docs", "test", "chore", "ci", "style"):
|
|
if title.lower().startswith(prefix):
|
|
rest = title[len(prefix):]
|
|
if rest.startswith("(") and ")" in rest:
|
|
rest = rest[rest.index(")") + 1:]
|
|
if rest.startswith(":"):
|
|
rest = rest[1:]
|
|
title = rest.strip() or title
|
|
break
|
|
# Strip any trailing inline PR refs like (#1060) to avoid doubling
|
|
title = re.sub(r"\s*\(#\d+\)\s*$", "", title)
|
|
return title
|
|
|
|
|
|
@dataclass
|
|
class Snapshot:
|
|
repo: str
|
|
since: datetime
|
|
until: datetime
|
|
mode: str
|
|
latest_release_tag: str | None
|
|
latest_release_name: str | None
|
|
latest_release_url: str | None
|
|
merged_prs: list[PullRequest]
|
|
commit_count: int
|
|
contributors: list[str]
|
|
stars_now: int | None
|
|
stars_delta: int | None
|
|
npm_version: str | None = None
|
|
since_sha: str | None = None
|
|
until_sha: str | None = None
|
|
|
|
@property
|
|
def window_label(self) -> str:
|
|
return f"{self.since.strftime('%Y-%m-%d')}..{self.until.strftime('%Y-%m-%d')}"
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(msg, file=sys.stderr)
|
|
|
|
|
|
def require_gh() -> None:
|
|
if shutil.which("gh") is None:
|
|
log("error: `gh` CLI not found on PATH. Install from https://cli.github.com/")
|
|
sys.exit(EXIT_GH_ERROR)
|
|
try:
|
|
subprocess.run(
|
|
["gh", "auth", "status"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
except subprocess.CalledProcessError as err:
|
|
log("error: `gh` is not authenticated. Run `gh auth login`.")
|
|
log(err.stderr.strip())
|
|
sys.exit(EXIT_GH_ERROR)
|
|
|
|
|
|
def run_gh(args: list[str], *, retries: int = 1) -> str:
|
|
"""Run a gh command, retrying once on transient failure."""
|
|
attempt = 0
|
|
while True:
|
|
try:
|
|
result = subprocess.run(
|
|
["gh", *args],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return result.stdout
|
|
except subprocess.CalledProcessError as err:
|
|
stderr = err.stderr or ""
|
|
if attempt < retries and ("rate limit" in stderr.lower() or "timeout" in stderr.lower()):
|
|
log(f"gh transient failure ({stderr.strip()}); retrying in 30s")
|
|
time.sleep(30)
|
|
attempt += 1
|
|
continue
|
|
log(f"error: gh {' '.join(args)} failed: {stderr.strip()}")
|
|
sys.exit(EXIT_GH_ERROR)
|
|
|
|
|
|
def fetch_latest_release(repo: str) -> tuple[str | None, str | None, str | None]:
|
|
out = run_gh([
|
|
"api",
|
|
f"repos/{repo}/releases/latest",
|
|
"-q",
|
|
"{tag_name, name, html_url}",
|
|
])
|
|
try:
|
|
data = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
return None, None, None
|
|
return data.get("tag_name"), data.get("name"), data.get("html_url")
|
|
|
|
|
|
def fetch_merged_prs(repo: str, since: datetime, until: datetime) -> list[PullRequest]:
|
|
query = (
|
|
f"repo:{repo} is:pr is:merged "
|
|
f"merged:{since.strftime('%Y-%m-%d')}..{until.strftime('%Y-%m-%d')}"
|
|
)
|
|
out = run_gh([
|
|
"api",
|
|
"-X",
|
|
"GET",
|
|
"search/issues",
|
|
"-f",
|
|
f"q={query}",
|
|
"-f",
|
|
"per_page=100",
|
|
])
|
|
try:
|
|
data = json.loads(out)
|
|
except json.JSONDecodeError as err:
|
|
log(f"error: could not parse PR search response: {err}")
|
|
sys.exit(EXIT_GH_ERROR)
|
|
|
|
prs: list[PullRequest] = []
|
|
for item in data.get("items", []):
|
|
try:
|
|
prs.append(
|
|
PullRequest(
|
|
number=item["number"],
|
|
title=item["title"],
|
|
author=(item.get("user") or {}).get("login", "unknown"),
|
|
merged_at=item.get("closed_at", ""),
|
|
labels=[lbl["name"] for lbl in item.get("labels", [])],
|
|
)
|
|
)
|
|
except (KeyError, TypeError) as err:
|
|
log(f"warning: skipping malformed PR entry: {err}")
|
|
continue
|
|
prs.sort(key=lambda p: p.merged_at)
|
|
return prs
|
|
|
|
|
|
def fetch_commit_count(repo: str, since: datetime, until: datetime) -> int:
|
|
"""Prefer local `git log` if available — the checkout is the source of truth."""
|
|
if shutil.which("git") is not None:
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"git",
|
|
"log",
|
|
f"--since={since.isoformat()}",
|
|
f"--until={until.isoformat()}",
|
|
"--pretty=oneline",
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
lines = [ln for ln in result.stdout.splitlines() if ln.strip()]
|
|
if lines:
|
|
return len(lines)
|
|
except subprocess.CalledProcessError:
|
|
pass
|
|
|
|
out = run_gh([
|
|
"api",
|
|
"-X",
|
|
"GET",
|
|
f"repos/{repo}/commits",
|
|
"-f",
|
|
f"since={since.isoformat()}",
|
|
"-f",
|
|
f"until={until.isoformat()}",
|
|
"-f",
|
|
"per_page=100",
|
|
])
|
|
try:
|
|
data = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
return 0
|
|
return len(data) if isinstance(data, list) else 0
|
|
|
|
|
|
def fetch_stars(repo: str) -> int | None:
|
|
out = run_gh(["api", f"repos/{repo}", "-q", ".stargazers_count"])
|
|
try:
|
|
return int(out.strip())
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
def fetch_npm_version(package: str = "@aoagents/ao") -> str | None:
|
|
"""Fetch the latest published version from the npm registry."""
|
|
try:
|
|
result = subprocess.run(
|
|
["npm", "view", package, "version"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
version = result.stdout.strip()
|
|
return version if version else None
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
return None
|
|
|
|
|
|
def resolve_commit_sha(repo: str, date: datetime) -> str | None:
|
|
"""Resolve a date to a commit SHA, preferring local git, falling back to the API."""
|
|
iso = date.isoformat()
|
|
if shutil.which("git") is not None:
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "rev-list", "-1", f"--before={iso}", "main"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
sha = result.stdout.strip()
|
|
if sha:
|
|
return sha[:12]
|
|
except subprocess.CalledProcessError:
|
|
pass
|
|
try:
|
|
out = run_gh([
|
|
"api",
|
|
"-X",
|
|
"GET",
|
|
f"repos/{repo}/commits",
|
|
"-f",
|
|
f"until={iso}",
|
|
"-f",
|
|
"per_page=1",
|
|
"-q",
|
|
".[0].sha",
|
|
])
|
|
sha = out.strip()
|
|
return sha[:12] if sha else None
|
|
except SystemExit:
|
|
return None
|
|
|
|
|
|
def compute_contributors(prs: list[PullRequest]) -> list[str]:
|
|
seen: dict[str, None] = {}
|
|
for pr in prs:
|
|
if pr.author and pr.author not in seen:
|
|
seen[pr.author] = None
|
|
return list(seen.keys())
|
|
|
|
|
|
def stars_delta_estimate(repo: str, current: int | None) -> int | None:
|
|
"""Read last week's star count from a local cache file, if present."""
|
|
if current is None:
|
|
return None
|
|
cache_dir = os.environ.get("AO_RELEASE_NOTES_CACHE") or os.path.expanduser(
|
|
"~/.cache/ao-weekly-release"
|
|
)
|
|
cache_path = os.path.join(cache_dir, f"{repo.replace('/', '__')}.json")
|
|
previous: int | None = None
|
|
try:
|
|
with open(cache_path, "r", encoding="utf-8") as f:
|
|
previous = json.load(f).get("stars")
|
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
previous = None
|
|
|
|
try:
|
|
os.makedirs(cache_dir, exist_ok=True)
|
|
with open(cache_path, "w", encoding="utf-8") as f:
|
|
json.dump({"stars": current, "recorded_at": datetime.now(timezone.utc).isoformat()}, f)
|
|
except OSError as err:
|
|
log(f"warning: could not update stars cache: {err}")
|
|
|
|
if previous is None:
|
|
return None
|
|
return current - previous
|
|
|
|
|
|
def gather(repo: str, since: datetime, until: datetime, mode: str) -> Snapshot:
|
|
tag, name, url = fetch_latest_release(repo)
|
|
prs = fetch_merged_prs(repo, since, until)
|
|
commits = fetch_commit_count(repo, since, until)
|
|
contributors = compute_contributors(prs)
|
|
stars = fetch_stars(repo)
|
|
delta = stars_delta_estimate(repo, stars)
|
|
npm_version = fetch_npm_version()
|
|
since_sha = resolve_commit_sha(repo, since)
|
|
until_sha = resolve_commit_sha(repo, until)
|
|
return Snapshot(
|
|
repo=repo,
|
|
since=since,
|
|
until=until,
|
|
mode=mode,
|
|
latest_release_tag=tag,
|
|
latest_release_name=name,
|
|
latest_release_url=url,
|
|
merged_prs=prs,
|
|
commit_count=commits,
|
|
contributors=contributors,
|
|
stars_now=stars,
|
|
stars_delta=delta,
|
|
npm_version=npm_version,
|
|
since_sha=since_sha,
|
|
until_sha=until_sha,
|
|
)
|
|
|
|
|
|
def format_theme_order(prs: list[PullRequest]) -> list[PullRequest]:
|
|
order = {"feature": 0, "fix": 1, "refactor": 2, "docs": 3, "test": 4, "chore": 5, "other": 6}
|
|
return sorted(prs, key=lambda p: (order.get(p.theme, 99), p.number))
|
|
|
|
|
|
_LEADING_VERB_RE = re.compile(
|
|
r"^(add|fix|refactor|update|remove|change|implement|improve|enable|disable|"
|
|
r"document|test|clean|drop|bump|migrate|introduce|support|handle|replace|"
|
|
r"rename|move|extract|merge|revert|skip|allow|prevent|ensure)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def render_highlights(prs: list[PullRequest]) -> tuple[list[str], int]:
|
|
ordered = format_theme_order(prs)
|
|
bullets: list[str] = []
|
|
for pr in ordered[:MAX_HIGHLIGHTS]:
|
|
theme_verb = {
|
|
"feature": "Added",
|
|
"fix": "Fixed",
|
|
"refactor": "Refactored",
|
|
"docs": "Documented",
|
|
"test": "Tested",
|
|
"chore": "Updated",
|
|
"other": "Changed",
|
|
}[pr.theme]
|
|
body = pr.clean_title
|
|
# If the cleaned title already starts with a verb, capitalize it
|
|
# and use it directly — avoids "Added add …" stutter.
|
|
if _LEADING_VERB_RE.match(body):
|
|
body = body[0].upper() + body[1:]
|
|
else:
|
|
if body and body[0].isupper():
|
|
body = body[0].lower() + body[1:]
|
|
body = f"{theme_verb} {body}"
|
|
bullets.append(f"- {body} (#{pr.number})")
|
|
overflow = max(0, len(ordered) - MAX_HIGHLIGHTS)
|
|
return bullets, overflow
|
|
|
|
|
|
def render_markdown(snap: Snapshot) -> str:
|
|
if not snap.merged_prs:
|
|
log("no merged PRs in window")
|
|
sys.exit(EXIT_NO_ACTIVITY)
|
|
|
|
week_label = snap.since.strftime("%b %d, %Y")
|
|
title = f"# Agent Orchestrator — Week of {week_label}"
|
|
|
|
positioning = build_positioning_line(snap)
|
|
|
|
highlights, overflow = render_highlights(snap.merged_prs)
|
|
if len(snap.merged_prs) < MIN_HIGHLIGHTS:
|
|
highlights.append(
|
|
f"- Quiet week: {len(snap.merged_prs)} merged PR(s); every change is listed above."
|
|
)
|
|
# Track total omitted PRs across both the initial cap and any later truncation.
|
|
# The overflow sentinel line is rendered once at the end by truncate_if_needed.
|
|
total_omitted = overflow
|
|
|
|
stars_line = (
|
|
f"- Stars: {snap.stars_now}"
|
|
+ (f" (+{snap.stars_delta})" if snap.stars_delta and snap.stars_delta > 0 else "")
|
|
if snap.stars_now is not None
|
|
else "- Stars: (unavailable)"
|
|
)
|
|
|
|
by_numbers = [
|
|
"## By the Numbers",
|
|
f"- Commits: {snap.commit_count}",
|
|
f"- Merged PRs: {len(snap.merged_prs)}",
|
|
f"- Contributors: {len(snap.contributors)}",
|
|
stars_line,
|
|
]
|
|
|
|
version = snap.npm_version or "latest"
|
|
install_block = [
|
|
"## Install",
|
|
"```bash",
|
|
"npm install -g @aoagents/ao",
|
|
f"# or pin to the current release: npm install -g @aoagents/ao@{version}",
|
|
"```",
|
|
]
|
|
|
|
release_url = snap.latest_release_url or f"https://github.com/{snap.repo}/releases"
|
|
if snap.since_sha and snap.until_sha:
|
|
changelog_url = f"https://github.com/{snap.repo}/compare/{snap.since_sha}...{snap.until_sha}"
|
|
else:
|
|
changelog_url = f"https://github.com/{snap.repo}/commits/main"
|
|
links = [
|
|
"## Links",
|
|
f"- Release: {release_url}",
|
|
f"- Full changelog: {changelog_url}",
|
|
f"- Repository: https://github.com/{snap.repo}",
|
|
"- Discord: https://discord.gg/agent-orchestrator",
|
|
]
|
|
|
|
release_version = version if version != "latest" else "X.Y.Z"
|
|
release_checklist = [
|
|
"## Release commands",
|
|
"```bash",
|
|
"git fetch origin && git checkout main && git reset --hard origin/main",
|
|
"pnpm install --frozen-lockfile",
|
|
"pnpm changeset version",
|
|
"git add . && git commit -m \"chore: version packages\"",
|
|
"pnpm -r build",
|
|
"pnpm -r publish --access public --no-git-checks",
|
|
f"gh release create v{release_version} --generate-notes",
|
|
"git push origin main --follow-tags",
|
|
"```",
|
|
]
|
|
|
|
operator_checklist = [
|
|
"## Operator checklist",
|
|
"- [ ] Changelog reviewed for accuracy and tone",
|
|
"- [ ] PR titles cleaned up where necessary",
|
|
"- [ ] Screenshots or GIFs attached for any UI-visible changes",
|
|
"- [ ] Discord announcement drafted in #releases",
|
|
"- [ ] Tweet / social post drafted",
|
|
"- [ ] Docs site updated if any public API changed",
|
|
"- [ ] Breaking changes (if any) called out at the top of the post",
|
|
]
|
|
|
|
footer = (
|
|
f"_Generated {datetime.now(timezone.utc).isoformat(timespec='seconds')}"
|
|
f" • mode: {snap.mode}"
|
|
f" • window: {snap.window_label}_"
|
|
)
|
|
|
|
# Append the overflow line now (before truncation adjusts it).
|
|
if total_omitted:
|
|
highlights.append(f"- … and {total_omitted} more — see the full changelog.")
|
|
|
|
parts: list[str] = [
|
|
title,
|
|
"",
|
|
positioning,
|
|
"",
|
|
"## Highlights",
|
|
*highlights,
|
|
"",
|
|
*by_numbers,
|
|
"",
|
|
*install_block,
|
|
"",
|
|
*links,
|
|
"",
|
|
*release_checklist,
|
|
"",
|
|
*operator_checklist,
|
|
"",
|
|
footer,
|
|
"",
|
|
]
|
|
|
|
body = "\n".join(parts)
|
|
return truncate_if_needed(body, total_omitted)
|
|
|
|
|
|
def build_positioning_line(snap: Snapshot) -> str:
|
|
pr_count = len(snap.merged_prs)
|
|
contrib_count = len(snap.contributors)
|
|
return (
|
|
f"This week the team merged {pr_count} PRs from {contrib_count} "
|
|
f"contributor{'s' if contrib_count != 1 else ''}, continuing steady iteration "
|
|
f"on the orchestrator core, plugins, and dashboard."
|
|
)
|
|
|
|
|
|
def truncate_if_needed(body: str, already_omitted: int) -> str:
|
|
plain = "\n".join(line for line in body.splitlines() if not line.startswith("```"))
|
|
if len(plain) <= MAX_BODY_CHARS:
|
|
return body
|
|
# Trim highlights from the tail until the body fits.
|
|
lines = body.splitlines()
|
|
try:
|
|
start = lines.index("## Highlights") + 1
|
|
end = next(i for i in range(start, len(lines)) if lines[i].startswith("## "))
|
|
except (ValueError, StopIteration):
|
|
return body
|
|
|
|
kept = lines[start:end]
|
|
# Remove any existing overflow sentinel so we can re-render it with the correct total.
|
|
if kept and kept[-1].startswith("- … and "):
|
|
kept.pop()
|
|
dropped = 0
|
|
while kept and len(
|
|
"\n".join(ln for ln in (lines[:start] + kept + lines[end:]) if not ln.startswith("```"))
|
|
) > MAX_BODY_CHARS:
|
|
kept.pop()
|
|
dropped += 1
|
|
|
|
total = already_omitted + dropped
|
|
if total:
|
|
kept.append(f"- … and {total} more — see the full changelog.")
|
|
|
|
return "\n".join(lines[:start] + kept + lines[end:])
|
|
|
|
|
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Generate AO weekly release notes")
|
|
parser.add_argument(
|
|
"--mode",
|
|
choices=("scheduled", "on-demand"),
|
|
default="scheduled",
|
|
help="How this run was triggered. Recorded in the footer.",
|
|
)
|
|
parser.add_argument(
|
|
"--since",
|
|
help="ISO date (YYYY-MM-DD) for the start of the window. Defaults to 7 days ago.",
|
|
)
|
|
parser.add_argument(
|
|
"--until",
|
|
help="ISO date (YYYY-MM-DD) for the end of the window. Defaults to today.",
|
|
)
|
|
parser.add_argument("--repo", default=DEFAULT_REPO, help="owner/name of the target repo")
|
|
parser.add_argument("--output", help="Write markdown to this file instead of stdout")
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def resolve_window(args: argparse.Namespace) -> tuple[datetime, datetime]:
|
|
now = datetime.now(timezone.utc)
|
|
try:
|
|
until = (
|
|
datetime.fromisoformat(args.until).replace(tzinfo=timezone.utc)
|
|
if args.until
|
|
else now
|
|
)
|
|
since = (
|
|
datetime.fromisoformat(args.since).replace(tzinfo=timezone.utc)
|
|
if args.since
|
|
else until - timedelta(days=DEFAULT_WINDOW_DAYS)
|
|
)
|
|
except ValueError as err:
|
|
log(f"error: invalid date: {err}")
|
|
sys.exit(EXIT_INPUT_ERROR)
|
|
|
|
if since >= until:
|
|
log("error: --since must be before --until")
|
|
sys.exit(EXIT_INPUT_ERROR)
|
|
return since, until
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = parse_args(argv if argv is not None else sys.argv[1:])
|
|
require_gh()
|
|
since, until = resolve_window(args)
|
|
snap = gather(args.repo, since, until, args.mode)
|
|
markdown = render_markdown(snap)
|
|
|
|
if args.output:
|
|
try:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(markdown)
|
|
except OSError as err:
|
|
log(f"error: could not write output file: {err}")
|
|
return EXIT_INPUT_ERROR
|
|
else:
|
|
sys.stdout.write(markdown)
|
|
|
|
return EXIT_OK
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|