161 lines
12 KiB
Python
161 lines
12 KiB
Python
"""The dojo's offline forge and the manager's hands. The World is test_time.py's fake Gitea (pulled out of that file, not copied);
|
|||
|
|
Dojo adds what a manager needs on top: authors by token, reviews, merge rules, variables, escalation counting."""
|
||
|
|
import ast, contextlib, datetime as dt, json, os, re
|
||
|
|
from pathlib import Path
|
||
|
|
from types import SimpleNamespace
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
LABELS = ["ready", "queued", "spec", "planned", "needs-human", "signal", "signup", "venture", "venture-batch", "error", "andon", "astra", "parked", "money"]
|
||
|
|
|
||
|
|
|
||
|
|
def make_lib(tm):
|
||
|
|
"""-> namespace L with World (Dojo), card, pr, beat, iso, NOW, js, ledger_files, tm."""
|
||
|
|
keep = [n for n in ast.parse((ROOT / ".github/time/test_time.py").read_text()).body
|
||
|
|
if (isinstance(n, ast.ClassDef) and n.name == "World") or getattr(getattr(n, "targets", [None])[0], "id", "") in ("NOW", "iso")]
|
||
|
|
ns = {"tm": tm, "json": json, "re": re, "dt": dt}
|
||
|
|
exec(compile(ast.Module(keep, []), "test_time.py", "exec"), ns)
|
||
|
|
iso, NOW = ns["iso"], ns["NOW"]
|
||
|
|
|
||
|
|
class Dojo(ns["World"]):
|
||
|
|
def __init__(s, cards, comments=None, prs=None, files=None, rule=0, seat="seat"):
|
||
|
|
super().__init__(cards, comments, prs, None, {p["number"]: {"pr": p, "reviews": [], "files": ["app.py"], "diff": ""} for p in prs or []})
|
||
|
|
s.label_ids = {n: i for i, n in enumerate(LABELS, 1)}
|
||
|
|
s.files.update(files or {})
|
||
|
|
s.rule, s.reviews, s.merges, s.vars, s.escalated, s.reviews_posted = {"required_approvals": rule}, [], [], [], [], []
|
||
|
|
s.logins = {"gh": "gitea-actions", "time-tok": tm.TIME_USER, "seat-tok": seat}
|
||
|
|
|
||
|
|
def vote(s, n, who, event="APPROVED"):
|
||
|
|
s.gh_pr[n]["reviews"].append({"user": {"login": who}, "state": event, "body": event, "submitted_at": iso()})
|
||
|
|
|
||
|
|
def http(s, method, url, token, body=None, timeout=0, headers=None):
|
||
|
|
path, who = url.replace(tm.GH, ""), s.logins.get(token, token)
|
||
|
|
if method == "POST" and path.endswith("/comments"):
|
||
|
|
n = int(path.split("/")[-2]); s.posted.append((n, body["body"]))
|
||
|
|
s.comments.setdefault(n, []).append({"created_at": iso(), "user": {"login": who}, "body": body["body"]})
|
||
|
|
if "@owner" in body["body"]: s.escalated.append(n)
|
||
|
|
return {}
|
||
|
|
if method == "POST" and (m := re.search(r"/pulls/(\d+)/reviews$", path)):
|
||
|
|
n = int(m[1])
|
||
|
|
if s.gh_pr[n]["pr"]["user"]["login"] == who: raise RuntimeError(f"POST {path} -> 422: approve your own pull is not allowed")
|
||
|
|
s.vote(n, who, body["event"]); s.gh_pr[n]["reviews"][-1]["body"] = body["body"]
|
||
|
|
s.reviews.append((n, who, body["event"], body["body"])); return {}
|
||
|
|
if method == "POST" and (m := re.search(r"/pulls/(\d+)/merge$", path)):
|
||
|
|
n = int(m[1]); d = s.gh_pr[n]
|
||
|
|
ok = sum(r["state"] == "APPROVED" and r["user"]["login"] != d["pr"]["user"]["login"] for r in d["reviews"])
|
||
|
|
if ok < s.rule["required_approvals"]: raise RuntimeError(f"POST {path} -> 405: Does not have enough approvals yet. {ok} of {s.rule['required_approvals']} approvals granted")
|
||
|
|
d["pr"].update(state="closed", merged_at=iso()); s.merges.append(n); return {}
|
||
|
|
if "/branch_protections/" in path:
|
||
|
|
if method != "GET": s.rule.update(body)
|
||
|
|
return s.rule
|
||
|
|
if "/actions/variables/" in path and method in ("PUT", "POST"):
|
||
|
|
s.vars.append((path.rsplit("/", 1)[1], body)); return {}
|
||
|
|
out = super().http(method, url, token, body, timeout)
|
||
|
|
if method == "POST" and "/issues/" in path and path.endswith("/labels") and "needs-human" in [s.lname(l) for l in body["labels"]]:
|
||
|
|
s.escalated.append(int(path.split("/")[5]))
|
||
|
|
if method == "POST" and path == "/repos/o/r/issues" and "needs-human" in s.issues[out["number"]]["labels"]:
|
||
|
|
s.escalated.append(out["number"])
|
||
|
|
return out
|
||
|
|
|
||
|
|
def card(n, title, done="", labels=("ready",)):
|
||
|
|
return {"number": n, "title": title, "body": f"{title}\n\nDONE: {done}" if done else title, "labels": list(labels)}
|
||
|
|
|
||
|
|
def pr(n, closes, user="time"):
|
||
|
|
return {"number": n, "title": f"PR {n}", "body": f"Closes #{closes}", "state": "open", "user": {"login": user},
|
||
|
|
"head": {"sha": f"sha{n:04d}"}, "html_url": f"https://forge.test/o/r/pulls/{n}", "merged_at": None}
|
||
|
|
|
||
|
|
def beat(state, h=0, **kw):
|
||
|
|
return {"created_at": iso(h), "user": {"login": tm.TIME_BOT}, "body": f"x\n\n{tm.MARK}{json.dumps({'beat': state, **kw})} -->"}
|
||
|
|
|
||
|
|
def ledger_files(rows): # rows: (hours ago, usd, pool) -> {path: jsonl}, one file per month
|
||
|
|
out = {}
|
||
|
|
for h, usd, pool in rows:
|
||
|
|
at = (NOW - dt.timedelta(hours=h)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
|
out[f"time/ledger/{at[:7]}.jsonl"] = out.get(f"time/ledger/{at[:7]}.jsonl", "") + json.dumps({"at": at, "model": "m", "pool": pool, "in": 1, "out": 1, "usd": usd}) + "\n"
|
||
|
|
return out
|
||
|
|
|
||
|
|
js = lambda text: json.loads(text[text.index("{"):text.rindex("}") + 1])
|
||
|
|
return SimpleNamespace(tm=tm, World=Dojo, card=card, pr=pr, beat=beat, iso=iso, NOW=NOW, js=js, ledger_files=ledger_files)
|
||
|
|
|
||
|
|
|
||
|
|
@contextlib.contextmanager
|
||
|
|
def offline(L, w, seat):
|
||
|
|
"""Bind the engine to world `w`: forge calls hit the fake, model calls still go out. The seat's token is its own user."""
|
||
|
|
tm, real, old = L.tm, L.tm.http, dict(os.environ)
|
||
|
|
keep = (tm.REPO, tm.budget_check)
|
||
|
|
os.environ.update(GITHUB_TOKEN="gh", TIME_TOKEN="time-tok", TIME_PACE="1", TIME_DAILY_USD="5", TIME_MONTHLY_USD="100", TIME_STUCK_HOURS="6",
|
||
|
|
**{f"TIME_SEAT_{seat.upper()}": "seat-tok"})
|
||
|
|
tm._label_ids = tm._pools = None; tm._trust.clear(); tm._ledger_files.clear()
|
||
|
|
tm.http = lambda m, u, t, b=None, timeout=0, headers=None: (real(m, u, t, b, timeout=timeout, **({"headers": headers} if headers else {}))
|
||
|
|
if u.startswith((tm.ANTHROPIC, tm.VENICE)) else w.http(m, u, t, b, timeout))
|
||
|
|
tm.REPO, tm.budget_check = "o/r", lambda *a, **k: None # the real budget is checked once, before the ladder, against the real repo
|
||
|
|
try:
|
||
|
|
yield
|
||
|
|
finally:
|
||
|
|
tm.http, (tm.REPO, tm.budget_check) = real, keep
|
||
|
|
os.environ.clear(); os.environ.update(old); tm._pools = None; tm._ledger_files.clear()
|
||
|
|
|
||
|
|
|
||
|
|
def _t(name, desc, props=None, req=()):
|
||
|
|
return {"type": "function", "function": {"name": name, "description": desc,
|
||
|
|
"parameters": {"type": "object", "properties": {k: {"type": v} for k, v in (props or {}).items()}, "required": list(req)}}}
|
||
|
|
|
||
|
|
|
||
|
|
TOOLS = [
|
||
|
|
_t("cards", "Every open card with its labels, Time's beats (state, hours ago), linked PRs, plus every PR with its reviews."),
|
||
|
|
_t("read", "One issue or PR: body, labels, comments, and for a PR its diff and reviews.", {"number": "integer"}, ["number"]),
|
||
|
|
_t("read_file", "A file in the repo (time/pools.json, time/ledger/YYYY-MM.jsonl, ...).", {"path": "string"}, ["path"]),
|
||
|
|
_t("comment", "Comment on an issue as Time's voice. `beat` is an optional object written as a Time beat, e.g. {\"beat\": \"handoff\", ...}.",
|
||
|
|
{"number": "integer", "text": "string", "beat": "object"}, ["number", "text"]),
|
||
|
|
_t("label", "Add and/or remove labels on an issue.", {"number": "integer", "add": "array", "remove": "array"}, ["number"]),
|
||
|
|
_t("new_issue", "File an issue (a card). Returns its number.", {"title": "string", "body": "string", "labels": "array"}, ["title", "body"]),
|
||
|
|
_t("close", "Close an issue.", {"number": "integer"}, ["number"]),
|
||
|
|
_t("review", "Post your review of a PR as yourself. Start the text with Approve or Object.", {"pr": "integer", "text": "string"}, ["pr", "text"]),
|
||
|
|
_t("merge", "Squash-merge a PR as yourself.", {"pr": "integer"}, ["pr"]),
|
||
|
|
_t("rule", "Read, or with required_approvals change, the branch protection rule of main.", {"required_approvals": "integer"}),
|
||
|
|
_t("open_pr", "Open a pull request.", {"title": "string", "body": "string", "head": "string"}, ["title", "body", "head"]),
|
||
|
|
_t("set_var", "Set an Actions variable (a cap, the mode).", {"name": "string", "value": "string"}, ["name", "value"]),
|
||
|
|
_t("budget", "Spend from the ledger: today, yesterday, month, and the caps."),
|
||
|
|
_t("finish", "End the kata with your answer (text; JSON when the task asks for it).", {"answer": "string"}, ["answer"]),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def act(L, w, seat, name, a):
|
||
|
|
"""Run one tool against world `w` (inside offline()). Returns the string the seat sees; errors are returned, not raised."""
|
||
|
|
tm, n = L.tm, a.get("number")
|
||
|
|
age = lambda at: round((L.NOW - dt.datetime.fromisoformat(at.replace("Z", "+00:00"))).total_seconds() / 3600, 1)
|
||
|
|
try:
|
||
|
|
if name == "cards":
|
||
|
|
return json.dumps({"cards": [{"number": i["number"], "title": i.get("title"), "labels": i["labels"], "has_done": bool(tm.done_of(i)),
|
||
|
|
"beats": [{"beat": b["beat"], "hours_ago": age(b["at"]), **({"pr": b["pr"]} if "pr" in b else {})} for b in tm.beats_of(w.comments.get(i["number"], []))],
|
||
|
|
"prs": [p["number"] for p in tm.linked_prs(w.prs, i["number"])]}
|
||
|
|
for i in sorted(w.issues.values(), key=lambda i: i["number"]) if i["state"] == "open"],
|
||
|
|
"prs": [{"number": p["number"], "body": p["body"], "state": p["state"], "author": p["user"]["login"],
|
||
|
|
"reviews": [(r["user"]["login"], r["state"]) for r in w.gh_pr[p["number"]]["reviews"]]} for p in w.prs]})
|
||
|
|
if name == "read":
|
||
|
|
d = w.gh_pr.get(n); i = d["pr"] if d else w.issues[n]
|
||
|
|
return json.dumps({"title": i.get("title"), "body": i.get("body"), "labels": i.get("labels"), "state": i.get("state"),
|
||
|
|
"comments": [{"by": c["user"]["login"], "body": c["body"]} for c in w.comments.get(n, [])],
|
||
|
|
**({"author": i["user"]["login"], "head_sha": i["head"]["sha"], "diff": d["diff"], "files": d["files"], "reviews": d["reviews"]} if d else {})})
|
||
|
|
if name == "read_file": return w.files.get(a["path"], "not found")
|
||
|
|
if name == "comment":
|
||
|
|
return tm.comment(n, a["text"], a["beat"]) if a.get("beat") else tm.gh("POST", f"/repos/o/r/issues/{n}/comments", {"body": a["text"]}) or "ok"
|
||
|
|
if name == "label":
|
||
|
|
[tm.label(n, l) for l in a.get("add") or []]; [tm.unlabel(n, l) for l in a.get("remove") or []]; return "ok"
|
||
|
|
if name == "new_issue": return json.dumps({"number": tm.new_issue(a["title"], a["body"], a.get("labels") or [])["number"]})
|
||
|
|
if name == "close": return tm.gh("PATCH", f"/repos/o/r/issues/{n}", {"state": "closed"}) or "ok"
|
||
|
|
if name == "review": return tm.post_review(a["pr"], seat, None, a["text"])
|
||
|
|
if name == "merge": return tm.http("POST", f"{tm.GH}/repos/o/r/pulls/{a['pr']}/merge", "seat-tok", {"Do": "squash"}) or "merged"
|
||
|
|
if name == "rule":
|
||
|
|
body = {"required_approvals": a["required_approvals"]} if "required_approvals" in a else None
|
||
|
|
return json.dumps(tm.http("PATCH" if body else "GET", f"{tm.GH}/repos/o/r/branch_protections/main", "seat-tok", body))
|
||
|
|
if name == "open_pr": return json.dumps(tm.http("POST", f"{tm.GH}/repos/o/r/pulls", "seat-tok", {"title": a["title"], "body": a["body"], "head": a["head"], "base": "main"}))
|
||
|
|
if name == "set_var": return tm.http("PUT", f"{tm.GH}/repos/o/r/actions/variables/{a['name']}", "seat-tok", {"value": a["value"]}) or "ok"
|
||
|
|
if name == "budget":
|
||
|
|
day = lambda h: (L.NOW - dt.timedelta(hours=h)).strftime("%Y-%m-%d")
|
||
|
|
rows = [json.loads(l) for f, t in w.files.items() if f.startswith("time/ledger/") for l in t.splitlines() if l.strip()]
|
||
|
|
spent = lambda p: round(sum(r["usd"] or 0 for r in rows if r["at"].startswith(p)), 4)
|
||
|
|
return json.dumps({"now": L.NOW.strftime("%Y-%m-%dT%H:%M:%SZ"), "today_usd": spent(day(0)), "yesterday_usd": spent(day(24)), "month_usd": spent(day(0)[:7]),
|
||
|
|
"daily_cap": float(os.environ["TIME_DAILY_USD"]), "monthly_cap": float(os.environ["TIME_MONTHLY_USD"])})
|
||
|
|
return f"unknown tool {name}"
|
||
|
|
except Exception as e: # noqa: BLE001 a refusal is information for the seat
|
||
|
|
return f"error: {e}"
|