diff --git a/.github/time/time.py b/.github/time/time.py index 33c292b..6ae6910 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -151,42 +151,235 @@ def cmd_mention() -> int: return 0 + + +# ---------- worker: Cursor cloud agents ---------- +# Docs: Cloud Agents API (POST /v1/agents, GET /v1/models). The agent opens a PR that says "Closes #N"; +# Time finds that PR on GitHub, so tracking never depends on fields of Cursor's responses. + +CURSOR = os.environ.get("CURSOR_API_BASE", "https://api.cursor.com") + + +def cursor(method: str, path: str, body=None): + key = os.environ.get("CURSOR_API_KEY") + if not key: + raise LookupError("no CURSOR_API_KEY secret on this repo yet") + return http(method, f"{CURSOR}{path}", key, body, timeout=60) + + +def repo_url() -> str: + return f"https://github.com/{REPO}" + + +def default_branch() -> str: + return gh("GET", f"/repos/{REPO}").get("default_branch") or "main" + + +BUILD_PROMPT = """You are the worker for card #{n} in {repo}: "{title}". + +{body} + +How to finish: +- Make the change described above, small and focused. Follow AGENTS.md and any repo skills if present. +- Run the project's tests or checks if they exist. +- Open one pull request into `{base}`. Its description MUST contain the line `Closes #{n}` and a short summary + of what changed and how you checked it. +- The card's finish line is: DONE: {done} +""" + +REVIEW_PROMPT = """Review pull request #{pr} in {repo} for card #{n} ("{title}"). Do NOT push commits. +Check the diff against the card and its finish line (DONE: {done}): correctness, tests, scope, anything risky. +Leave one PR comment that starts with **Approve** or **Object**, then your reasons as a short list with file:line.""" + + +def cursor_launch(prompt: str, *, model: str | None, pr_url: str | None = None, create_pr: bool = True) -> dict: + r = {"url": repo_url()} + if pr_url: + r["prUrl"] = pr_url + else: + r["startingRef"] = default_branch() + body = {"prompt": {"text": prompt}, "repos": [r], "autoCreatePR": create_pr} + if model: + body["model"] = {"id": model} + out = cursor("POST", "/v1/agents", body) + agent = out.get("agent") or out + return {"agent_id": agent.get("id"), "agent_url": agent.get("url") or agent.get("webUrl"), + "status": (out.get("run") or {}).get("status") or agent.get("status")} + + # ---------- heartbeat ---------- +def beats_of(comments: list[dict]) -> list[dict]: + """Time's own state comments on a card, oldest first, as {meta..., at}.""" + out = [] + for c in comments: + b = c.get("body") or "" + if MARK in b and '"beat":' in b: + try: + meta = json.loads(b.split(MARK, 1)[1].rsplit("-->", 1)[0]) + except ValueError: + continue + out.append({**meta, "at": c["created_at"]}) + return out + + +def retry_requested(comments: list[dict], since: str) -> bool: + return any(c["created_at"] > since and c["user"].get("type") != "Bot" and "/time retry" in (c.get("body") or "") + for c in comments) + + +def linked_prs(prs: list[dict], n: int) -> list[dict]: + pat = re.compile(rf"\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#{n}\b", re.I) + return [p for p in prs if pat.search(p.get("body") or "")] + + +def pr_diff(number: int, limit: int = 100_000) -> str: + req = urllib.request.Request(f"{GH}/repos/{REPO}/pulls/{number}", + headers={"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", + "Accept": "application/vnd.github.diff"}) + with urllib.request.urlopen(req, timeout=60) as r: + return r.read().decode(errors="replace")[:limit] + + +def review(card: dict, pr: dict, done: str) -> dict: + """TIME_REVIEW: off | venice: [model] [effort] | cursor[:]""" + spec = (os.environ.get("TIME_REVIEW") or "off").strip() + if spec in ("", "off"): + return {"review": "off"} + kind, _, rest = spec.partition(":") + if kind == "cursor": + r = cursor_launch(REVIEW_PROMPT.format(pr=pr["number"], repo=REPO, n=card["number"], title=card["title"], done=done), + model=rest.strip() or None, pr_url=pr["html_url"], create_pr=False) + return {"review": "cursor", **r} + if kind == "venice": + words = rest.split() + seat = words[0] if words else "kimi" + model, effort = mentions(f"@{rest}").get(seat, (None, None)) if seat in SEATS else (None, None) + diff = pr_diff(pr["number"]) + user = (f"# Card #{card['number']}: {card['title']}\n{(card.get('body') or '')[:4000]}\n\n" + f"# PR #{pr['number']}: {pr['title']}\n{(pr.get('body') or '')[:3000]}\n\n# Diff\n{diff}") + system = (f"You are `{seat}`, reviewing a pull request made by another agent. " + + REVIEW_PROMPT.format(pr=pr["number"], repo=REPO, n=card["number"], title=card["title"], done=done) + .replace("Leave one PR comment that", "Your reply is posted as the PR comment. It")) + text = think(seat, model, effort, system, user) + gh("POST", f"/repos/{REPO}/issues/{pr['number']}/comments", + {"body": f"{text}\n\nreview by {seat} · {model or SEATS[seat]['default']} · via Time"}) + return {"review": "venice", "seat": seat} + return {"review": f"unknown spec {spec!r}"} + + def cmd_heartbeat() -> int: - mode = os.environ.get("TIME_MODE") or "dry-run" + mode = (os.environ.get("TIME_MODE") or "dry-run").strip() cap = int(os.environ.get("TIME_DAILY_CAP") or 5) - today = dt.datetime.now(dt.timezone.utc).date().isoformat() - issues = gh("GET", f"/repos/{REPO}/issues?state=open&labels=ready&per_page=50") - issues = [i for i in issues if "pull_request" not in i] - sent_today, todo = 0, [] - for i in issues: - cs = gh("GET", f"/repos/{REPO}/issues/{i['number']}/comments?per_page=100") - beats = [c for c in cs if MARK in (c.get("body") or "") and '"beat":' in c["body"]] - sent_today += sum(1 for c in beats if c["created_at"].startswith(today)) - if beats: - continue # already picked up once; the next step is the worker's, not the clock's - done = re.search(r"^\s*DONE:\s*(.+)$", i.get("body") or "", re.M | re.I) - todo.append((i, done.group(1).strip() if done else None)) - print(f"tick {today} mode={mode} ready={len(issues)} new={len(todo)} sent_today={sent_today}/{cap}") - for i, done in todo: - if sent_today >= cap: - print("daily cap reached; stopping") - break - if not done: - comment(i["number"], "⏱️ Time: this is labelled `ready` but has no `DONE:` line, so there's nothing to " - "dispatch against yet. Add one (e.g. `DONE: send to @review`) and I'll pick it up.", - {"beat": "needs-done", "mode": mode}) - elif mode == "dry-run": - comment(i["number"], f"⏱️ Time (dry run): would dispatch this to the worker now.\n\n- DONE: {done}\n" - "- Worker: not wired yet (set `TIME_MODE=live` once a worker exists)", - {"beat": "would-dispatch", "mode": mode}) - else: - comment(i["number"], "⏱️ Time: live mode is set but no worker is wired yet, so nothing was dispatched.", - {"beat": "no-worker", "mode": mode}) - sent_today += 1 + stuck_h = float(os.environ.get("TIME_STUCK_HOURS") or 6) + worker_model = (os.environ.get("TIME_CURSOR_MODEL") or "").strip() or None + now = dt.datetime.now(dt.timezone.utc) + today = now.date().isoformat() + cards = [i for i in gh("GET", f"/repos/{REPO}/issues?state=open&labels=ready&per_page=50") if "pull_request" not in i] + prs = gh("GET", f"/repos/{REPO}/pulls?state=all&sort=updated&direction=desc&per_page=50") + launches_today = 0 + plan = [] + for card in cards: + cs = gh("GET", f"/repos/{REPO}/issues/{card['number']}/comments?per_page=100") + beats = beats_of(cs) + launches_today += sum(1 for b in beats if b.get("launched") and b["at"].startswith(today)) + real = [b for b in beats if b["beat"] != "blocked"] # "blocked" = a key was missing; resume once it's added + last = real[-1] if real else None + blocked = any(b["beat"] == "blocked" and (not last or b["at"] >= last["at"]) for b in beats) + if last and retry_requested(cs, last["at"]): + last = None + m = re.search(r"^\s*DONE:\s*(.+)$", card.get("body") or "", re.M | re.I) + plan.append((card, last, m.group(1).strip() if m else None, blocked)) + print(f"tick {now:%Y-%m-%dT%H:%MZ} mode={mode} cards={len(cards)} launches_today={launches_today}/{cap}") + + for card, last, done, blocked in plan: + n, state = card["number"], (last or {}).get("beat") + try: + if state in (None, "needs-done", "would-dispatch") and not done: + if state != "needs-done": + comment(n, "⏱️ Time: this is labelled `ready` but has no `DONE:` line, so there's nothing to dispatch " + "against yet. Add one (e.g. `DONE: PR open, tests green`) and I'll pick it up.", + {"beat": "needs-done", "mode": mode}) + continue + if state in (None, "needs-done") and mode != "live": + comment(n, f"⏱️ Time (dry run): would dispatch this to the worker now.\n\n- DONE: {done}\n" + f"- Worker: Cursor cloud agent{f' ({worker_model})' if worker_model else ''}, opens a PR that closes #{n}\n" + "- Goes for real once `TIME_MODE=live`", {"beat": "would-dispatch", "mode": mode}) + continue + if state in (None, "needs-done", "would-dispatch") and mode == "live": + if launches_today >= cap: + print(f"#{n}: daily cap reached") + continue + r = cursor_launch(BUILD_PROMPT.format(n=n, repo=REPO, title=card["title"], body=(card.get("body") or "")[:20000], + base=default_branch(), done=done), model=worker_model) + launches_today += 1 + link = f" ([agent]({r['agent_url']}))" if r.get("agent_url") else "" + comment(n, f"⏱️ Time: dispatched to a Cursor cloud agent{link}. It will open a PR that closes #{n}; " + "I'll link it here when it appears.", {"beat": "dispatched", "launched": True, **r, "model": worker_model}) + continue + if state in ("dispatched", "stuck"): + found = linked_prs(prs, n) + if found: + pr = found[0] + rv = review(card, pr, done or "") + launches_today += 1 if rv.get("review") == "cursor" else 0 + note = {"off": "No reviewer is set (`TIME_REVIEW`).", "cursor": "A Cursor agent is reviewing it.", + "venice": f"`{rv.get('seat')}` reviewed it on the PR."}.get(rv["review"], rv["review"]) + comment(n, f"⏱️ Time: PR #{pr['number']} is up for this card. {note}", + {"beat": "pr", "pr": pr["number"], "launched": rv.get("review") == "cursor", **rv}) + elif state == "dispatched": + age_h = (now - dt.datetime.fromisoformat(last["at"].replace("Z", "+00:00"))).total_seconds() / 3600 + if age_h > stuck_h: + comment(n, f"⏱️ Time: dispatched {age_h:.0f}h ago and no PR closing #{n} yet. Check the agent" + f"{' (' + last['agent_url'] + ')' if last.get('agent_url') else ''}; comment `/time retry` to " + "dispatch again.", {"beat": "stuck"}) + except LookupError as e: # a key is missing: say so once, plainly + if not blocked: + comment(n, f"⏱️ Time: can't go further on this card: {e}. I'll carry on once it's added.", {"beat": "blocked"}) + except Exception as e: # noqa: BLE001 one card must not stop the others + print(f"#{n}: {type(e).__name__}: {e}") + comment(n, f"⏱️ Time: the worker call failed: `{str(e)[:300]}`. Comment `/time retry` to try again.", + {"beat": "error"}) return 0 +# ---------- doctor ---------- + +def cmd_doctor() -> int: + lines, ok = [], True + + def row(name: str, good: bool, detail: str) -> None: + nonlocal ok + ok &= good or name.startswith("(optional)") + lines.append(f"| {'✅' if good else '⚠️'} | {name} | {detail} |") + + labels = [l["name"] for l in gh("GET", f"/repos/{REPO}/labels?per_page=100")] + row("label `ready`", "ready" in labels, "exists" if "ready" in labels else "missing: `gh label create ready`") + row("TIME_MODE", True, os.environ.get("TIME_MODE") or "dry-run (default)") + row("TIME_DAILY_CAP", True, os.environ.get("TIME_DAILY_CAP") or "5 (default)") + row("TIME_REVIEW", True, os.environ.get("TIME_REVIEW") or "off (default)") + try: + ids = [m["id"] for m in http("GET", f"{VENICE}/models?type=text", os.environ["VENICE_API_KEY"])["data"]] \ + if os.environ.get("VENICE_API_KEY") else None + row("(optional) VENICE_API_KEY (seats)", ids is not None, f"works, {len(ids)} models" if ids else "not set") + except Exception as e: # noqa: BLE001 + row("(optional) VENICE_API_KEY (seats)", False, f"set but failing: {str(e)[:120]}") + try: + models = cursor("GET", "/v1/models") + items = models.get("models") or models.get("data") or models.get("items") or (models if isinstance(models, list) else []) + names = [m.get("id") if isinstance(m, dict) else str(m) for m in items] + row("CURSOR_API_KEY (worker)", True, f"works; models: {', '.join(n for n in names[:12] if n) or 'listed'}") + except LookupError: + row("CURSOR_API_KEY (worker)", False, "not set: `gh secret set CURSOR_API_KEY`") + except Exception as e: # noqa: BLE001 + row("CURSOR_API_KEY (worker)", False, f"set but failing: {str(e)[:160]}") + report = "## Time doctor\n\n| | check | detail |\n|---|---|---|\n" + "\n".join(lines) + \ + "\n\nThe Cursor GitHub app must also be installed on this repo for cloud agents to open PRs (not checkable from here)." + print(report) + if os.environ.get("GITHUB_STEP_SUMMARY"): + Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text(report) + return 0 if ok else 1 + + if __name__ == "__main__": - sys.exit({"mention": cmd_mention, "heartbeat": cmd_heartbeat}[sys.argv[1]]()) + sys.exit({"mention": cmd_mention, "heartbeat": cmd_heartbeat, "doctor": cmd_doctor}[sys.argv[1]]())