scripts/watch_github.py
scripts/watch_github.pyBrowse 5 files
1,580 tokens
6,203 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""Watch GitHub activity — issues, pulls, releases, or commits — with dedup.3 4Usage (via cron with --no-agent):5 6 hermes cron create hermes-issues \\7 --schedule "*/5 * * * *" --no-agent \\8 --script "$HERMES_HOME/skills/devops/watchers/scripts/watch_github.py" \\9 --script-args "--name hermes-issues --repo NousResearch/hermes-agent --scope issues"10 11Set GITHUB_TOKEN (or GH_TOKEN) in the Hermes .env file12(``${HERMES_HOME:-~/.hermes}/.env``) to avoid the 60 req/hr13anonymous rate limit.14 15Scopes: issues | pulls | releases | commits. Or pass --search QUERY to16use the /search/issues endpoint instead of /repos/:owner/:repo/:scope.17"""18 19from __future__ import annotations20 21import argparse22import json23import os24import re25import sys26import urllib.error27import urllib.parse28import urllib.request29from pathlib import Path30 31sys.path.insert(0, str(Path(__file__).parent))32from _watermark import Watermark, format_items_as_markdown # type: ignore33 34 35VALID_SCOPES = ("issues", "pulls", "releases", "commits")36 37 38def _flatten_commit(item):39 """Commit objects nest title/author/date under 'commit' — flatten for rendering."""40 commit = item.get("commit") or {}41 msg = (commit.get("message") or "").strip().splitlines()42 title = msg[0] if msg else ""43 body = "\n".join(msg[1:]).strip() if len(msg) > 1 else ""44 author = (item.get("author") or {}).get("login") or (commit.get("author") or {}).get("name", "")45 date = (commit.get("author") or {}).get("date", "")46 return {47 "id": item.get("sha", ""),48 "title": f"{title} ({author})" if author else title,49 "url": item.get("html_url"),50 "body": body,51 "created_at": date,52 }53 54 55def _flatten_issue_or_release(item):56 return {57 "id": str(item.get("id", "")),58 "title": item.get("title") or item.get("name") or "",59 "url": item.get("html_url") or item.get("url"),60 "body": (item.get("body") or "").strip(),61 "state": item.get("state"),62 "author": (item.get("user") or {}).get("login")63 or (item.get("author") or {}).get("login"),64 "created_at": item.get("created_at"),65 }66 67 68def main() -> int:69 p = argparse.ArgumentParser(description="Watch GitHub issues / pulls / releases / commits.")70 p.add_argument("--name", required=True, help="Watcher name (used for state file)")71 p.add_argument("--repo", default="",72 help="owner/name of the repo (one of --repo or --search is required)")73 p.add_argument("--scope", default="issues", choices=VALID_SCOPES,74 help="What to poll (default: issues)")75 p.add_argument("--search", default="",76 help="GitHub issues search query (alternative to --repo/--scope)")77 p.add_argument("--per-page", type=int, default=30,78 help="Results per page (default: 30, max: 100)")79 p.add_argument("--max", type=int, default=20,80 help="Max new items to emit per tick (default: 20)")81 p.add_argument("--with-body", action="store_true",82 help="Include issue/commit body as a snippet under each item")83 p.add_argument("--timeout", type=float, default=30.0,84 help="HTTP timeout in seconds (default: 30)")85 args = p.parse_args()86 87 if not args.repo and not args.search:88 print("watch_github: one of --repo or --search is required", file=sys.stderr)89 return 290 if args.repo and not re.fullmatch(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+", args.repo):91 print(f"watch_github: --repo must be owner/name (got {args.repo!r})", file=sys.stderr)92 return 293 94 # URL + flattening strategy.95 if args.search:96 url = (97 "https://api.github.com/search/issues"98 f"?q={urllib.parse.quote(args.search)}&per_page={args.per_page}"99 )100 flatten = _flatten_issue_or_release101 items_path = "items"102 elif args.scope == "commits":103 url = f"https://api.github.com/repos/{args.repo}/commits?per_page={args.per_page}"104 flatten = _flatten_commit105 items_path = ""106 else:107 url = (108 f"https://api.github.com/repos/{args.repo}/{args.scope}"109 f"?per_page={args.per_page}&state=all"110 )111 flatten = _flatten_issue_or_release112 items_path = ""113 114 headers = {115 "Accept": "application/vnd.github+json",116 "User-Agent": "Hermes-Watcher/1.0",117 }118 token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")119 if token:120 headers["Authorization"] = f"Bearer {token}"121 122 req = urllib.request.Request(url)123 for k, v in headers.items():124 req.add_header(k, v)125 126 try:127 with urllib.request.urlopen(req, timeout=args.timeout) as resp:128 raw = resp.read()129 except urllib.error.HTTPError as e:130 print(f"watch_github: HTTP {e.code} from {url}", file=sys.stderr)131 return 2132 except (urllib.error.URLError, TimeoutError, OSError) as e:133 print(f"watch_github: network error: {e}", file=sys.stderr)134 return 2135 136 try:137 data = json.loads(raw.decode("utf-8"))138 except (UnicodeDecodeError, json.JSONDecodeError) as e:139 print(f"watch_github: response is not valid JSON: {e}", file=sys.stderr)140 return 2141 142 # Drill into items_path if needed (search endpoint returns {"items":[...]}).143 if items_path:144 data = data.get(items_path) if isinstance(data, dict) else None145 if not isinstance(data, list):146 print(f"watch_github: expected a list of items; got {type(data).__name__}",147 file=sys.stderr)148 return 2149 150 items = [flatten(i) for i in data if isinstance(i, dict)]151 # Drop any items that flattened without an ID (defensive).152 items = [i for i in items if i.get("id")]153 154 wm = Watermark.load(args.name)155 new_items = wm.filter_new(items, id_key="id")156 wm.save()157 158 if args.max > 0:159 new_items = new_items[: args.max]160 161 body_key = "body" if args.with_body else None162 output = format_items_as_markdown(new_items, body_key=body_key)163 if output:164 sys.stdout.write(output)165 return 0166 167 168if __name__ == "__main__":169 sys.exit(main())170