Jaarvis / core /github.py
abc1181's picture
Real accounts, native globe skin, and a way to notice things
6fa11a3 verified
Raw History Blame Contribute Delete
11 kB
"""GitHub: what the user's code is doing right now.
Read paths are free. Write paths — comment, open an issue, push, merge — are
registered in `core/policy.py` so the approval gate asks first, and this module
does not attempt to be clever about that. It has no opinion on whether you
approved; it only refuses to invent a token.
The interesting one is `failing_ci`, because that is what a background watch
calls: pull the recent workflow runs, and say which ones broke and on what.
"""
from __future__ import annotations
from typing import Any, Callable, Optional
from core import svc
API = "https://api.github.com"
def _token() -> str:
return svc.get_token("github_token", "github_pat", "gh_token")
def configured() -> bool:
return bool(_token())
def _h() -> dict:
return {"Authorization": f"Bearer {_token()}",
"X-GitHub-Api-Version": "2022-11-28"}
def _call(method: str, path: str, *, params: Optional[dict] = None,
body: Any = None, fetcher: Optional[Callable] = None) -> Any:
url = API + path + svc.qs(params or {})
if fetcher:
return fetcher(method, url, body)
return svc.request(method, url, headers=_h(), body=body)
# ── read ─────────────────────────────────────────────────────────────────────
def whoami(fetcher: Optional[Callable] = None) -> dict:
u = _call("GET", "/user", fetcher=fetcher) or {}
return {"login": u.get("login", ""), "name": u.get("name", ""),
"public_repos": u.get("public_repos", 0),
"html_url": u.get("html_url", "")}
def repos(limit: int = 20, fetcher: Optional[Callable] = None) -> list[dict]:
rows = _call("GET", "/user/repos",
params={"sort": "pushed", "per_page": limit},
fetcher=fetcher) or []
out = []
for r in rows if isinstance(rows, list) else []:
out.append({
"full_name": r.get("full_name", ""),
"private": bool(r.get("private")),
"language": r.get("language") or "",
"stars": r.get("stargazers_count", 0),
"open_issues": r.get("open_issues_count", 0),
"default_branch": r.get("default_branch", "main"),
"pushed_at": r.get("pushed_at", ""),
"url": r.get("html_url", ""),
})
return out
def _repo_path(repo: str, rest: str = "") -> str:
r = str(repo or "").strip().strip("/")
if "/" not in r:
raise svc.ServiceError(f"'{r}' is not owner/repo — I need the full name, "
f"like facebook/react.")
return f"/repos/{r}{rest}"
def prs(repo: str, *, state: str = "open", limit: int = 15,
fetcher: Optional[Callable] = None) -> list[dict]:
rows = _call("GET", _repo_path(repo, "/pulls"),
params={"state": state, "per_page": limit},
fetcher=fetcher) or []
out = []
for p in rows if isinstance(rows, list) else []:
out.append({
"number": p.get("number"), "title": p.get("title", ""),
"user": (p.get("user") or {}).get("login", ""),
"draft": bool(p.get("draft")),
"created_at": p.get("created_at", ""), "updated_at": p.get("updated_at", ""),
"url": p.get("html_url", ""),
"head": ((p.get("head") or {}).get("ref") or ""),
"base": ((p.get("base") or {}).get("ref") or ""),
})
return out
def issues(repo: str, *, state: str = "open", limit: int = 15,
fetcher: Optional[Callable] = None) -> list[dict]:
rows = _call("GET", _repo_path(repo, "/issues"),
params={"state": state, "per_page": limit},
fetcher=fetcher) or []
# The issues endpoint returns pull requests too; a PR in an issue list is
# noise the assistant will otherwise narrate as a bug report.
return [{"number": i.get("number"), "title": i.get("title", ""),
"labels": [l.get("name") for l in (i.get("labels") or [])],
"user": (i.get("user") or {}).get("login", ""),
"created_at": i.get("created_at", ""), "url": i.get("html_url", "")}
for i in rows if isinstance(rows, list) and "pull_request" not in i]
def runs(repo: str, *, limit: int = 15, branch: str = "",
fetcher: Optional[Callable] = None) -> list[dict]:
params = {"per_page": limit}
if branch:
params["branch"] = branch
data = _call("GET", _repo_path(repo, "/actions/runs"), params=params,
fetcher=fetcher) or {}
out = []
for r in ((data.get("workflow_runs") or []) if isinstance(data, dict) else []):
out.append({
"id": r.get("id"), "name": r.get("name", ""),
"status": r.get("status", ""), "conclusion": r.get("conclusion") or "",
"branch": (r.get("head_branch") or ""),
"commit": ((r.get("head_commit") or {}) or {}).get("message", "")[:120],
"created_at": r.get("created_at", ""), "url": r.get("html_url", ""),
})
return out
def failing_ci(repo: str, *, limit: int = 20,
fetcher: Optional[Callable] = None) -> list[dict]:
"""Runs that actually broke. A watch calls this; a human rarely will."""
return [r for r in runs(repo, limit=limit, fetcher=fetcher)
if r.get("conclusion") in ("failure", "timed_out", "cancelled")
or (r.get("status") == "completed"
and r.get("conclusion") not in ("success", "skipped", None, ""))]
def run_log(repo: str, run_id: Any, fetcher: Optional[Callable] = None) -> str:
"""The tail of a failing job's log — where the actual error is."""
if fetcher:
return str(fetcher("GET", f"{API}{_repo_path(repo, f'/actions/runs/{run_id}/logs')}",
None) or "")
import urllib.request
req = urllib.request.Request(
f"{API}{_repo_path(repo, f'/actions/runs/{run_id}/logs')}",
headers=dict(_h(), Accept="application/vnd.github+json"))
try:
with urllib.request.urlopen(req, timeout=20) as r:
return r.read().decode("utf-8", "replace")[-4000:]
except Exception as e:
return f"(log unavailable: {type(e).__name__})"
# ── write (approval-gated upstream) ──────────────────────────────────────────
def comment(repo: str, number: Any, text: str,
fetcher: Optional[Callable] = None) -> dict:
r = _call("POST", _repo_path(repo, f"/issues/{number}/comments"),
body={"body": str(text)}, fetcher=fetcher) or {}
return {"ok": True, "url": r.get("html_url", "")}
def open_issue(repo: str, title: str, text: str = "",
labels: str = "", fetcher: Optional[Callable] = None) -> dict:
body: dict = {"title": str(title)}
if text:
body["body"] = str(text)
lab = [l.strip() for l in str(labels or "").split(",") if l.strip()]
if lab:
body["labels"] = lab
r = _call("POST", _repo_path(repo, "/issues"), body=body, fetcher=fetcher) or {}
return {"ok": True, "number": r.get("number"), "url": r.get("html_url", "")}
# ── the model-facing surface ─────────────────────────────────────────────────
def tool(action: str = "", *, repo: str = "", number: Any = "", text: str = "",
title: str = "", limit: int = 15, branch: str = "",
fetcher: Optional[Callable] = None) -> str:
a = str(action or "").strip().lower()
if not configured() and fetcher is None:
return ("GitHub is not connected — add a token in Settings (scope: repo "
"for private repositories, public_repo if only public).")
try:
if a in ("", "repos", "list"):
rows = repos(limit=limit, fetcher=fetcher)
if not rows:
return "No repositories found."
return "\n".join(
f"- {r['full_name']} · {r['language'] or '—'} · "
f"{r['open_issues']} open · pushed {r['pushed_at'][:10]}"
for r in rows)
if a in ("me", "whoami", "who"):
u = whoami(fetcher=fetcher)
return f"Signed in as {u['login']} ({u['name'] or 'no name'})."
if a in ("prs", "pr", "pulls", "reviews"):
rows = prs(repo, limit=limit, fetcher=fetcher)
if not rows:
return f"No open pull requests on {repo or 'that repo'}."
return "\n".join(f"- #{r['number']} {r['title']} "
f"({r['user']}{', draft' if r['draft'] else ''})"
for r in rows)
if a in ("issues", "issue"):
rows = issues(repo, limit=limit, fetcher=fetcher)
if not rows:
return f"No open issues on {repo or 'that repo'}."
return "\n".join(f"- #{r['number']} {r['title']}"
+ (f" [{', '.join(r['labels'])}]" if r["labels"] else "")
for r in rows)
if a in ("ci", "runs", "workflows", "actions"):
rows = runs(repo, limit=limit, branch=branch, fetcher=fetcher)
if not rows:
return f"No workflow runs recorded for {repo or 'that repo'}."
return "\n".join(f"- {r['name']}: {r['status']}"
+ (f" · {r['conclusion']}" if r["conclusion"] else "")
+ f" · {r['commit'][:60]}" for r in rows)
if a in ("failing", "broken", "red"):
rows = failing_ci(repo, fetcher=fetcher)
if not rows:
return f"CI is green on {repo or 'the repo'}. Nothing failing."
return ("CI is red on " + repo + ":\n" + "\n".join(
f"- {r['name']} {r['conclusion']} · {r['branch']} · "
f"{r['commit'][:60]}" for r in rows[:10]))
if a == "log":
if not repo or not number:
return "Give me the repo and the run id."
return run_log(repo, number, fetcher=fetcher)
if a in ("comment", "say"):
if not repo or not number:
return "Give me the repo and the issue/PR number to comment on."
r = comment(repo, number, text, fetcher=fetcher)
return f"Commented on #{number} in {repo}. {r['url']}"
if a in ("open", "create", "new"):
if not repo:
return "Give me the repo."
r = open_issue(repo, title or text, text, fetcher=fetcher)
return f"Opened #{r['number']} in {repo}. {r['url']}"
return "Unknown action. Use repos / prs / issues / ci / failing / log / comment / open."
except svc.ServiceError as e:
return f"GitHub: {e.message}"
except Exception as e:
return f"{type(e).__name__}: {e}"[:180]