Seats review as themselves: TIME_SEAT_<SEAT> token makes the verdict a real Gitea review #116

Merged
time merged 2 commits from time/card-76 into main 2026-10-18 04:27:14 +00:00
3 changed files with 50 additions and 4 deletions

No files matched your search

+24 -1
View File
@@ -1,5 +1,5 @@
"""Offline tests for time.py: fake Gitea + fake Cursor, whole card lifecycle. Run: python3 .github/time/test_time.py"""
import importlib.util, json, os, sys, datetime as dt
import importlib.util, json, os, re, sys, datetime as dt
import concurrent.futures as cf
# The suite assumes a clean environment: a worker's shell inherits the job's TIME_* settings and seat keys, so drop them
@@ -112,6 +112,9 @@ class World:
if method == "POST" and path.endswith("/merge"):
assert body["Do"] == "squash"; self.gh_pr[int(path.split("/")[-2])]["pr"]["merged_at"] = iso(); return {}
if method == "GET" and path.endswith(".diff"): return {}
if method == "POST" and re.search(r"/pulls/\d+/reviews$", path):
if getattr(self, "refuse_reviews", False): raise RuntimeError(f"POST {path} -> 422: approve your own pull is not allowed")
self.reviews_posted = getattr(self, "reviews_posted", []) + [(int(path.split("/")[-2]), token, body)]; return {}
if "/pulls/" in path:
n = int(path.split("/")[5]); d = self.gh_pr[n]
if path.endswith("/reviews?limit=100"): return d.get("reviews", [])
@@ -215,6 +218,26 @@ tm.pr_diff = lambda n, limit=0: "diff --git a b"
w = run(World([card(1, D)], {1: [beat("dispatched", launched=True)]}, [pr]), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck", "TIME_REVIEW": "venice:kimi"})
assert w.posted[0][0] == 7 and w.posted[0][1].startswith("**Approve** (kimi)") and "`kimi` reviewed" in w.posted[1][1]
# 9a a seat with a TIME_SEAT_<SEAT> token reviews as its own user; refused or tokenless, Time comments
def seat_run(text, refuse=False, token="kimi-tok"):
tm.think = lambda seat, model, effort, system, user: text
w = World([card(1, D)], {1: [beat("dispatched", launched=True)]}, [pr]); w.refuse_reviews = refuse
if token: os.environ["TIME_SEAT_KIMI"] = token
try: return run(w, {"TIME_MODE": "live", "CURSOR_API_KEY": "ck", "TIME_REVIEW": "venice:kimi"})
finally: os.environ.pop("TIME_SEAT_KIMI", None)
w = seat_run("**Approve** looks right")
n, tok, b = w.reviews_posted[0]
assert (n, tok, b["event"]) == (7, "kimi-tok", "APPROVED") and b["body"].startswith("**Approve**") and "review by kimi" in b["body"] and "via Time</sub>" in b["body"]
assert not [p_ for p_ in w.posted if p_[0] == 7] and any("`kimi` reviewed" in t for _, t in w.posted)
assert seat_run("Object: breaks x").reviews_posted[0][2]["event"] == "REQUEST_CHANGES"
assert seat_run("Some thoughts, no verdict").reviews_posted[0][2]["event"] == "COMMENT"
w = seat_run("**Approve** fine", refuse=True)
assert not getattr(w, "reviews_posted", []) and w.posted[0][0] == 7 and w.posted[0][1].startswith("**Approve** fine") and "review by kimi" in w.posted[0][1]
w = seat_run("**Approve** fine", token=None)
assert not getattr(w, "reviews_posted", []) and w.posted[0][0] == 7
os.environ.pop("TIME_SEAT_HAIKU", None); assert tm.seat_token("haiku") is None
os.environ["TIME_SEAT_HAIKU"] = "h-tok"; assert tm.seat_token("haiku") == "h-tok"; os.environ.pop("TIME_SEAT_HAIKU")
# 9b the reviewer's input carries the head commit's statuses under # Checks, before the diff; a failed lookup says so
seen = {}
tm.think = lambda seat, model, effort, system, user: (seen.update(user=user, system=system), "**Approve** ok")[1]
+25 -3
View File
@@ -1007,6 +1007,29 @@ def check_lines(sha: str | None) -> str:
return "(checks unavailable)"
def seat_token(seat: str) -> str | None:
"""The Gitea token of the user this seat posts as (`TIME_SEAT_<SEAT>`), or None."""
return os.environ.get(f"TIME_SEAT_{seat.upper()}") or None
def post_review(pr_number: int, seat: str, model: str | None, text: str) -> str:
"""Post a reviewer's verdict. With the seat's token it is a real review by the seat's own user (Approve ->
APPROVED, Object -> REQUEST_CHANGES, else COMMENT); without one, or if Gitea refuses (a seat on its own PR),
it is Time's comment as before. Returns "seat" or "time"."""
body = f"{text}\n\n<sub>review by {seat} · {model or SEATS[seat]['default']} · via Time</sub>"
token = seat_token(seat)
if token:
m = VERDICT.match(text or "")
event = {"approve": "APPROVED", "object": "REQUEST_CHANGES"}.get(m.group(1).lower() if m else "", "COMMENT")
try:
http("POST", f"{GH}/repos/{REPO}/pulls/{pr_number}/reviews", token, {"body": body, "event": event})
return "seat"
except RuntimeError as e:
print(f"review as {seat} refused, commenting as Time: {e}")
gh("POST", f"/repos/{REPO}/issues/{pr_number}/comments", {"body": body})
return "time"
def review(card: dict, pr: dict, done: str) -> dict:
"""TIME_REVIEW: off | venice:<seat> | claude:<seat> [model] [effort] | cursor[:<model id>]"""
spec = (os.environ.get("TIME_REVIEW") or "off").strip()
@@ -1031,9 +1054,8 @@ def review(card: dict, pr: dict, done: str) -> dict:
+ "\nThe checks listed under # Checks are the head commit's real statuses: evidence you can rely on "
"(a success there means that context reported success). A pending check is not yet evidence either way.")
text = think(seat, model, effort, system, user)
gh("POST", f"/repos/{REPO}/issues/{pr['number']}/comments",
{"body": f"{text}\n\n<sub>review by {seat} · {model or SEATS[seat]['default']} · via Time</sub>"})
return {"review": "venice", "seat": seat}
who = post_review(pr["number"], seat, model, text)
return {"review": "venice", "seat": seat, "as": who}
return {"review": f"unknown spec {spec!r}"}
+1
View File
@@ -18,6 +18,7 @@
- The second fix round escalates to Kimi's code model (`venice:kimi code`) instead of grok 4.7: still a different model from the first round, at a fraction of the cost. `TIME_VENTURE_ESCALATE` overrides it.
### Added
- Seats review as themselves: with a `TIME_SEAT_<SEAT>` token (one Gitea user per seat, e.g. `TIME_SEAT_KIMI`) the reviewer's verdict is a real Gitea review by that user (Approve → approved, Object → changes requested, prose → comment), so reviews carry an identity and can count. Without a token, or if Gitea refuses (a seat on its own PR), it is Time's comment as before.
- `TIME_PROTECTED` now governs every protected path except the workflow directories (`.github/workflows/`, `.gitea/workflows/`), which stay a human's merge whatever it says. The default still protects all of `.github/`; a repo that trusts its reviewer can set `TIME_PROTECTED=.github/workflows/,.gitea/workflows/,deploy,infra/,.env,secret` so the engine under `.github/time/` merges itself on Approve + green like any other code.
- Objections go back to the worker. `/time retry` on a card whose PR is open hands the worker the reviewer's latest comments (marked as the reviewer's, strangers' left out) on the same branch; `open_pr` on an open PR updates its description instead of failing; the card goes back to `dispatched`, so the next beat reviews again and an objection older than the push no longer counts.
- A batch's children start in the tick that spawned them (GitHub lists a card made a moment ago only later, so they used to wait a tick).