From 6fed6705e8c26f9cf751672f027c5aaca8a0636d Mon Sep 17 00:00:00 2001 From: Time Date: Sun, 18 Oct 2026 04:45:07 +0000 Subject: [PATCH] card #77: kept .github/time/time.py, .github/time/seats.json (generated) --- .github/time/seats.json | 6 +- .github/time/time.py | 122 ++++++++++++++++++++++++++++++++++------ 2 files changed, 109 insertions(+), 19 deletions(-) diff --git a/.github/time/seats.json b/.github/time/seats.json index 8f69359..1675a45 100644 --- a/.github/time/seats.json +++ b/.github/time/seats.json @@ -14,5 +14,9 @@ "haiku": {"family": "claude-haiku", "default": "claude-haiku-5-5", "label": "Claude Haiku (Anthropic)", "provider": "anthropic", "price": [0.10, 0.50]}, "sonnet": {"family": "claude-sonnet", "default": "claude-sonnet-5-5", "label": "Claude Sonnet (Anthropic)", "provider": "anthropic", "price": [2, 10]}, "opus": {"family": "claude-opus", "default": "claude-opus-5-5", "label": "Claude Opus (Anthropic)", "provider": "anthropic", "price": [4, 20]}, - "fable": {"family": "claude-fable", "default": "claude-fable-5-1", "label": "Claude Fable (Anthropic)", "provider": "anthropic", "price": [10, 50]} + "fable": {"family": "claude-fable", "default": "claude-fable-5-1", "label": "Claude Fable (Anthropic)", "provider": "anthropic", "price": [10, 50]}, + "luna": {"family": "codex-luna", "default": "luna", "label": "Codex Luna (OpenAI)", "provider": "codex", "route": "cli"}, + "terra": {"family": "codex-terra", "default": "terra", "label": "Codex Terra (OpenAI)", "provider": "codex", "route": "cli"}, + "sol": {"family": "codex-sol", "default": "sol", "label": "Codex Sol (OpenAI)", "provider": "codex", "route": "cli"}, + "astra": {"family": "codex-astra", "default": "astra", "label": "Codex Astra (OpenAI)", "provider": "codex", "route": "cli"} } diff --git a/.github/time/time.py b/.github/time/time.py index fd5101d..678ffc3 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -171,7 +171,8 @@ def mentions(text: str) -> dict[str, tuple[str | None, str | None]]: return {} text = re.sub(r"```.*?```|`[^`]*`", "", text, flags=re.S) text = "\n".join(l for l in text.splitlines() if not l.lstrip().startswith(">")) - pat = re.compile(r"(?#]*(approve|object)\b", re.I) DEFAULT_PROTECTED = ".github/,.gitea/,deploy,infra/,.env,secret" -def verdict(pr: dict, since: str) -> str | None: - """Latest reviewer verdict after `since`: a comment starting Approve/Object, or a PR review.""" +def votes(pr: dict, since: str) -> dict[str, str]: + """Each trusted identity's latest verdict after `since`: {login: "approve" | "object"}. A comment starting + Approve/Object or a PR review; Time's own comments are one identity (`gitea-actions`), so a seat without its own + Gitea user shares one vote with every other such seat.""" found = [] for c in gh("GET", f"/repos/{REPO}/issues/{pr['number']}/comments?limit=100"): m = VERDICT.match(c.get("body") or "") - ok = is_time(c) or trusted((c.get("user") or {}).get("login")) # public repos: strangers can comment + login = (c.get("user") or {}).get("login") + ok = is_time(c) or trusted(login) # public repos: strangers can comment if m and ok and c["created_at"] >= since: - found.append((c["created_at"], len(found), m.group(1).lower())) # list order breaks a same-second tie: the newer comment wins + found.append((c["created_at"], len(found), TIME_BOT if is_time(c) else login, m.group(1).lower())) # list order breaks a same-second tie: the newer comment wins for r in gh("GET", f"/repos/{REPO}/pulls/{pr['number']}/reviews?limit=100"): state = {"APPROVED": "approve", "REQUEST_CHANGES": "object", "CHANGES_REQUESTED": "object"}.get(r.get("state")) - if state and trusted((r.get("user") or {}).get("login")) and (r.get("submitted_at") or "") >= since: - found.append((r["submitted_at"], len(found), state)) - return max(found)[2] if found else None + login = (r.get("user") or {}).get("login") + if state and trusted(login) and (r.get("submitted_at") or "") >= since: + found.append((r["submitted_at"], len(found), login, state)) + latest: dict[str, str] = {} + for _, _, who, v in sorted(found): + latest[who] = v + return latest + + +def quorum() -> int: + """TIME_REVIEW_QUORUM: approvals needed, net of objections (default 1).""" + try: + return max(1, int(os.environ.get("TIME_REVIEW_QUORUM") or 1)) + except ValueError: + return 1 + + +def decide(cast: dict[str, str]) -> str | None: + a, o = sum(v == "approve" for v in cast.values()), sum(v == "object" for v in cast.values()) + return "approve" if a - o >= quorum() else "object" if o > a else None + + +def verdict(pr: dict, since: str) -> str | None: + """The council's verdict after `since`: each trusted identity's latest vote counts once. `approve` when approves + minus objects reaches the quorum, `object` when objects exceed approves, else None (no vote yet, a tie, or short of quorum).""" + return decide(votes(pr, since)) def touches_protected(pr_number: int) -> list[str]: @@ -605,9 +632,25 @@ def land(card: dict, last: dict, revised_at: str | None = None) -> None: return if pr["state"] == "closed" or (os.environ.get("TIME_AUTOMERGE") or "off").strip() != "on": return - v = verdict(pr, since) + cast = votes(pr, since) + v = decide(cast) if v is None: - return + if not cast or last.get("split") or len(cast) < max(1, len(review_specs("TIME_REVIEW"))): + return # nobody has voted, the split was already reported, or a standing seat has yet to vote + a, o = sum(x == "approve" for x in cast.values()), sum(x == "object" for x in cast.values()) + tie = (os.environ.get("TIME_REVIEW_TIEBREAK") or "").strip() + called = tie.partition(":")[0] in SEAT_KINDS + if not called and a != o: + return # short of quorum with no objection, and nobody to call: wait + if called: # the standing seats split: the tie-break seat votes, once + review_one(card, pr, done_of(card), tie) + v = decide(votes(pr, since)) + if v != "approve": + label(pr["number"], "needs-human") + comment(n, f"⏱️ Time: the council is split {a}–{o} on PR #{pr['number']}" + + (f"; the tie-break seat (`{tie}`) did not settle it" if called else "") + "; over to a human.", + {"beat": "needs-human", "pr": pr["number"], "split": True}) + return if v == "object": if last["beat"] != "needs-human": label(pr["number"], "needs-human") @@ -1050,12 +1093,55 @@ def post_review(pr_number: int, seat: str, model: str | None, text: str) -> str: return "time" +SEAT_KINDS = ("venice", "claude", "codex") +CODEX_DEFAULT_SEAT = "terra" + + +def done_of(card: dict) -> str: + m = re.search(r"^\s*DONE:\s*(.+)$", card.get("body") or "", re.M | re.I) + return m.group(1).strip() if m else "" + + +def review_specs(var: str = "TIME_REVIEW") -> list[str]: + """A comma-separated list of seat specs; empty when unset or `off`.""" + spec = (os.environ.get(var) or "").strip() + return [] if spec.lower() in ("", "off") else [x.strip() for x in spec.split(",") if x.strip()] + + +def review_note(rv: dict) -> str: + if rv["review"] == "off": + return "No reviewer is set (`TIME_REVIEW`)." + seats = rv.get("seats") or ([rv["seat"]] if rv.get("seat") else []) + parts = (["A Cursor agent is reviewing it."] if rv["review"] == "cursor" else []) + \ + ([", ".join(f"`{x}`" for x in seats) + " reviewed it on the PR."] if seats else []) + return " ".join(parts) or rv["review"] + + def review(card: dict, pr: dict, done: str) -> dict: - """TIME_REVIEW: off | venice: | claude: [model] [effort] | cursor[:]""" - spec = (os.environ.get("TIME_REVIEW") or "off").strip() - if spec in ("", "off"): + """TIME_REVIEW: a comma-separated list of seat specs, each `venice: | claude: [model] [effort] | codex[:] + | cursor[:]`; every listed seat reviews the PR and posts as itself. Returns what ran: the seats in `seats`.""" + specs = review_specs("TIME_REVIEW") + if not specs: return {"review": "off"} + ran = [review_one(card, pr, done, spec) for spec in specs] + out: dict = {} + for r in ran: + out.update(r) + seats = [r["seat"] for r in ran if r.get("seat")] + kinds = [r["review"] for r in ran] + out["review"] = "cursor" if "cursor" in kinds else "venice" if seats else kinds[0] + if seats: + out.update(seat=seats[0], seats=seats, **{"as": next(r["as"] for r in ran if r.get("seat"))}) + return out + + +def review_one(card: dict, pr: dict, done: str, spec: str) -> dict: + """One seat reviews the PR and posts its verdict as itself.""" kind, _, rest = spec.partition(":") + if kind == "codex" or SEATS.get(kind, {}).get("route") == "cli": + seat = (rest.split() or [CODEX_DEFAULT_SEAT])[0] if kind == "codex" else kind + raise LookupError(f"the `{seat}` seat reviews through a headless Codex CLI run, which isn't wired in yet; " + "until then list Claude or Venice seats in `TIME_REVIEW`") 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) @@ -1144,8 +1230,7 @@ def tick() -> None: continue rv = review(card, adopt, 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"]) + note = review_note(rv) comment(n, f"⏱️ Time: adopted PR #{adopt['number']}, built outside the worker. {note}", {"beat": "pr", "pr": adopt["number"], "adopted": True, "launched": rv.get("review") == "cursor", **rv}) continue @@ -1211,8 +1296,7 @@ def tick() -> None: 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"]) + note = review_note(rv) 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": @@ -1252,7 +1336,9 @@ def cmd_doctor() -> int: 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_WORKER", True, os.environ.get("TIME_WORKER") or "cursor (default); or claude: / venice: for a seat with hands") - row("TIME_REVIEW", True, os.environ.get("TIME_REVIEW") or "off (default)") + row("TIME_REVIEW", True, os.environ.get("TIME_REVIEW") or "off (default); a comma-separated list of seats, each votes") + row("TIME_REVIEW_TIEBREAK", True, os.environ.get("TIME_REVIEW_TIEBREAK") or "none (default); a seat called only when the standing seats split") + row("TIME_REVIEW_QUORUM", True, os.environ.get("TIME_REVIEW_QUORUM") or "1 (default); approvals needed, net of objections") today, day_cap, month, month_cap = budget() row("TIME_DAILY_USD", True, os.environ.get("TIME_DAILY_USD") or "5 (default)") row("TIME_MONTHLY_USD", True, os.environ.get("TIME_MONTHLY_USD") or "100 (default)")