Skip to content

Commit f671dcd

Browse files
authored
hud CLI + authed API shim (prototype) (#8156)
Prototype CLI over the HUD APIs for humans and agents, intended to replace ad-hoc HUD MCP calls. ## hud CLI (`tools/hud-cli/hud`) - `hud login` - reuses your `gh` login for HUD, and mints a Grafana token + runs `gcx login` for dashboards / raw ClickHouse. - `hud trunk` / `hud pr <n>` / `hud user <login>` / `hud query <name> -p k=v` (generic over `clickhouse_queries/`). - `--json` on any command for agents. ## Authed shim (`pages/api/authed/[...path].ts`) One catch-all that mirrors `/api/*` but validates a GitHub token (bogus -> 401), then forwards to the real endpoint with the internal bypass header. Avoids rewriting every endpoint and keeps public HUD public. ## Deploy prerequisite (two Vercel Firewall bypass rules) 1. Request Path starts with `/api/authed/` -> Bypass (shim validates the token). 2. Request Header `x-hud-internal-bot` Exists -> Bypass (server-only forward). Tested: `hud login` (mints token + configures gcx) and `hud user` (live GitHub) work. `trunk`/`pr`/`query` need the firewall rules above to test against prod. Roadmap to full MCP parity in the README (commits/job/log/similar).
1 parent 867a874 commit f671dcd

4 files changed

Lines changed: 314 additions & 0 deletions

File tree

.lintrunner.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ exclude_patterns = [
263263
'**/snapshots/**',
264264
# Putting this exclusion just to get the linter running.
265265
"tools/stronghold/bin/build-check-api-compatibility",
266+
"tools/hud-cli/hud",
266267
]
267268
command = [
268269
'python3',

tools/hud-cli/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# hud CLI
2+
3+
CLI over the PyTorch HUD APIs, for humans (tables) and agents (`--json`).
4+
5+
## Setup
6+
7+
```bash
8+
gh auth login
9+
ln -s "$(pwd)/hud" ~/.local/bin/hud
10+
hud login # also sets up gcx for dashboards (optional)
11+
```
12+
13+
Reuses your GitHub token (`gh auth token`) against the authed shim
14+
(`/api/authed/*`).
15+
16+
## Commands
17+
18+
```bash
19+
hud trunk --days 1
20+
hud pr 12345
21+
hud user wdvr
22+
hud query <name> -p key=value # any saved ClickHouse query
23+
```
24+
25+
Add `--json` to any command for agent output.
26+
27+
## Authed shim
28+
29+
`pages/api/authed/[...path].ts` mirrors `/api/*`: it validates the GitHub token
30+
(bad token -> 401), then forwards to the real endpoint. Requires one Vercel
31+
firewall bypass rule: Request Path starts with `/api/authed/`.

tools/hud-cli/hud

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env python3
2+
"""
3+
hud - a small CLI over the PyTorch HUD APIs (hud.pytorch.org).
4+
5+
Auth: reuses your GitHub login via `gh auth token`, sent as a Bearer header to
6+
the authed API shim (/api/authed/*), which validates it and forwards to the real
7+
HUD endpoint. `hud login` also sets up gcx for Grafana dashboards / raw
8+
ClickHouse access.
9+
10+
Serves humans (tables) and agents (`--json`). Replaces ad-hoc HUD MCP calls: the
11+
generic `hud query` runs any saved ClickHouse query, named commands wrap common
12+
ones.
13+
14+
Examples:
15+
hud login
16+
hud trunk --days 1
17+
hud pr 12345
18+
hud user wdvr
19+
hud query master_commit_red -p granularity=hour -p usePercentage=false --days 1
20+
"""
21+
import argparse
22+
import json
23+
import os
24+
import socket
25+
import subprocess
26+
import sys
27+
import urllib.error
28+
import urllib.parse
29+
import urllib.request
30+
from datetime import datetime, timedelta, timezone
31+
32+
HUD_URL = os.environ.get("HUD_URL", "https://hud.pytorch.org")
33+
API = "/api/authed" # authed shim: validates the GitHub token, forwards to /api/*
34+
GITHUB_API = "https://api.github.com"
35+
_TOKEN = None
36+
37+
38+
def gh_token():
39+
global _TOKEN
40+
if _TOKEN is None:
41+
try:
42+
_TOKEN = subprocess.check_output(
43+
["gh", "auth", "token"], text=True
44+
).strip()
45+
except (subprocess.CalledProcessError, FileNotFoundError):
46+
sys.exit("error: no GitHub token. Run `gh auth login`.")
47+
return _TOKEN
48+
49+
50+
def _get(url):
51+
req = urllib.request.Request(url)
52+
req.add_header("Authorization", f"Bearer {gh_token()}")
53+
req.add_header("Accept", "application/json")
54+
try:
55+
with urllib.request.urlopen(req, timeout=60) as resp:
56+
return json.load(resp)
57+
except urllib.error.HTTPError as e:
58+
body = e.read().decode("utf-8", "replace")[:300]
59+
if e.code == 429:
60+
sys.exit(
61+
"error: 429 (Vercel bot challenge). The /api/authed firewall "
62+
"bypass rule is missing. See README."
63+
)
64+
sys.exit(f"error: HTTP {e.code} from {url}\n{body}")
65+
66+
67+
def hud_query(name, params):
68+
qs = urllib.parse.urlencode({"parameters": json.dumps(params)})
69+
return _get(f"{HUD_URL}{API}/clickhouse/{name}?{qs}")
70+
71+
72+
def hud_get(path):
73+
return _get(f"{HUD_URL}{API}{path}")
74+
75+
76+
def github_get(path, params=None):
77+
url = f"{GITHUB_API}{path}"
78+
if params:
79+
url += "?" + urllib.parse.urlencode(params)
80+
return _get(url)
81+
82+
83+
def ch_time(dt):
84+
return dt.strftime("%Y-%m-%d %H:%M:%S.000")
85+
86+
87+
def now_utc():
88+
return datetime.now(timezone.utc)
89+
90+
91+
def emit(data, as_json, rows=None, headers=None):
92+
if as_json or rows is None:
93+
print(json.dumps(data, indent=2, default=str))
94+
else:
95+
print_table(rows, headers)
96+
97+
98+
def print_table(rows, headers):
99+
if not rows:
100+
print("(no rows)")
101+
return
102+
cols = headers or list(rows[0].keys())
103+
body = [[str(r.get(c, "")) for c in cols] for r in rows]
104+
w = [max(len(cols[i]), *(len(r[i]) for r in body)) for i in range(len(cols))]
105+
print(" ".join(h.ljust(w[i]) for i, h in enumerate(cols)))
106+
print(" ".join("-" * w[i] for i in range(len(cols))))
107+
for r in body:
108+
print(" ".join(r[i].ljust(w[i]) for i in range(len(cols))))
109+
110+
111+
# ----- commands -----
112+
113+
114+
def cmd_login(args):
115+
gh_token() # verifies gh is logged in
116+
label = socket.gethostname().split(".")[0]
117+
info = _get(f"{HUD_URL}/api/gcx-token?token_name={label}&format=json")
118+
grafana_token = info["token"]
119+
server = info.get("grafanaServer", "https://pytorchci.grafana.net")
120+
print(f"HUD: ready (uses your GitHub login as {label}).", flush=True)
121+
if subprocess.run(["which", "gcx"], capture_output=True).returncode == 0:
122+
subprocess.run(
123+
["gcx", "login", "pytorchci", "--server", server, "--yes",
124+
"--token", grafana_token],
125+
check=True,
126+
)
127+
print("gcx: configured. Try `gcx dashboards list`.")
128+
else:
129+
print("gcx not installed (optional, for dashboards/raw ClickHouse):")
130+
print(" curl -fsSL https://raw.githubusercontent.com/grafana/gcx/main/scripts/install.sh | sh")
131+
print(f" gcx login pytorchci --server {server} --yes --token {grafana_token}")
132+
133+
134+
def cmd_query(args):
135+
params = {}
136+
for kv in args.param or []:
137+
if "=" not in kv:
138+
sys.exit(f"bad -p '{kv}', expected key=value")
139+
k, v = kv.split("=", 1)
140+
try:
141+
params[k] = json.loads(v)
142+
except json.JSONDecodeError:
143+
params[k] = v
144+
if args.days is not None:
145+
params.setdefault("stopTime", ch_time(now_utc()))
146+
params.setdefault("startTime", ch_time(now_utc() - timedelta(days=args.days)))
147+
params.setdefault("timezone", "UTC")
148+
rows = hud_query(args.name, params)
149+
emit(rows, args.json, rows=rows if isinstance(rows, list) else None)
150+
151+
152+
def cmd_trunk(args):
153+
rows = hud_query("master_commit_red", {
154+
"startTime": ch_time(now_utc() - timedelta(days=args.days)),
155+
"stopTime": ch_time(now_utc()),
156+
"timezone": "UTC",
157+
"granularity": args.granularity,
158+
"usePercentage": False,
159+
})
160+
emit(rows, args.json, rows=rows if isinstance(rows, list) else None)
161+
162+
163+
def cmd_pr(args):
164+
owner, repo = args.repo.split("/", 1)
165+
data = hud_get(f"/{owner}/{repo}/pull/{args.pr}")
166+
if args.json:
167+
emit(data, True)
168+
return
169+
jobs = data.get("jobs", []) if isinstance(data, dict) else []
170+
failing = [j for j in jobs if (j.get("conclusion") or "") == "failure"]
171+
print(f"PR {owner}/{repo}#{args.pr}: {data.get('title', '')}")
172+
print(f" jobs: {len(jobs)} failing: {len(failing)}")
173+
if failing:
174+
print_table([{"job": j.get("name", ""), "conclusion": j.get("conclusion", "")}
175+
for j in failing], ["job", "conclusion"])
176+
177+
178+
def cmd_user(args):
179+
q = f"repo:{args.repo} is:pr is:open author:{args.user}"
180+
items = github_get("/search/issues", {"q": q, "per_page": args.limit}).get("items", [])
181+
if args.json:
182+
emit(items, True)
183+
return
184+
rows = [{"pr": f"#{it['number']}", "created": it["created_at"][:10],
185+
"draft": "draft" if it.get("draft") else "", "title": it["title"][:70]}
186+
for it in items]
187+
print(f"open PRs by {args.user} in {args.repo}: {len(items)}")
188+
print_table(rows, ["pr", "created", "draft", "title"])
189+
190+
191+
def main():
192+
p = argparse.ArgumentParser(prog="hud", description="PyTorch HUD CLI")
193+
p.add_argument("--json", action="store_true", help="JSON output (for agents)")
194+
sub = p.add_subparsers(dest="cmd", required=True)
195+
196+
sub.add_parser("login", help="set up gh + gcx").set_defaults(func=cmd_login)
197+
198+
q = sub.add_parser("query", help="run any saved ClickHouse query")
199+
q.add_argument("name")
200+
q.add_argument("-p", "--param", action="append", help="key=value (repeatable)")
201+
q.add_argument("--days", type=int, help="set startTime/stopTime to last N days")
202+
q.set_defaults(func=cmd_query)
203+
204+
t = sub.add_parser("trunk", help="trunk red/green over time")
205+
t.add_argument("--days", type=int, default=1)
206+
t.add_argument("--granularity", default="hour", choices=["hour", "day"])
207+
t.set_defaults(func=cmd_trunk)
208+
209+
pr = sub.add_parser("pr", help="CI status for a PR")
210+
pr.add_argument("pr")
211+
pr.add_argument("--repo", default="pytorch/pytorch")
212+
pr.set_defaults(func=cmd_pr)
213+
214+
u = sub.add_parser("user", help="open PRs for a user")
215+
u.add_argument("user")
216+
u.add_argument("--repo", default="pytorch/pytorch")
217+
u.add_argument("--limit", type=int, default=30)
218+
u.set_defaults(func=cmd_user)
219+
220+
args = p.parse_args()
221+
args.func(args)
222+
223+
224+
if __name__ == "__main__":
225+
main()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Authed API shim: /api/authed/<path> mirrors /api/<path> but requires the same
3+
* GitHub gate as flambeau / gcx-token (write access to pytorch/pytorch, or the
4+
* allow list), then forwards to the real endpoint. Lets the `hud` CLI / agents
5+
* reach HUD APIs without the browser bot challenge.
6+
*
7+
* One Vercel firewall bypass rule is needed: Request Path starts with
8+
* /api/authed/ (safe: the shim validates the token, bad token -> 401). The
9+
* forward below is a function calling its own deployment, which Vercel does not
10+
* put through the bot challenge. If that ever changes (forward starts 429ing),
11+
* re-add an `x-hud-internal-bot` header here plus a firewall bypass for it.
12+
*/
13+
import { authorizeGithubToken, bearerToken } from "lib/auth/githubAuth";
14+
import type { NextApiRequest, NextApiResponse } from "next";
15+
16+
const SELF_URL = process.env.HUD_SELF_URL || "https://hud.pytorch.org";
17+
18+
export default async function handler(
19+
req: NextApiRequest,
20+
res: NextApiResponse
21+
) {
22+
const token = bearerToken(req);
23+
if (!token) {
24+
return res.status(401).json({
25+
error: "Authentication required: Authorization: Bearer <token>",
26+
});
27+
}
28+
const auth = await authorizeGithubToken(token);
29+
if (!auth.ok) {
30+
return res.status(auth.status).json({ error: auth.error });
31+
}
32+
33+
const segments = Array.isArray(req.query.path)
34+
? req.query.path
35+
: [req.query.path];
36+
const subpath = segments.join("/");
37+
const qIndex = (req.url || "").indexOf("?");
38+
const qs = qIndex >= 0 ? (req.url as string).slice(qIndex) : "";
39+
const target = `${SELF_URL}/api/${subpath}${qs}`;
40+
41+
const init: RequestInit = { method: req.method, headers: {} };
42+
if (req.method !== "GET" && req.method !== "HEAD" && req.body) {
43+
(init.headers as Record<string, string>)["content-type"] =
44+
"application/json";
45+
init.body =
46+
typeof req.body === "string" ? req.body : JSON.stringify(req.body);
47+
}
48+
49+
const upstream = await fetch(target, init);
50+
const text = await upstream.text();
51+
res.status(upstream.status);
52+
const ct = upstream.headers.get("content-type");
53+
if (ct) {
54+
res.setHeader("content-type", ct);
55+
}
56+
return res.send(text);
57+
}

0 commit comments

Comments
 (0)