diff --git a/.github/time/test_time.py b/.github/time/test_time.py index d140338..f37df81 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -305,6 +305,47 @@ wm = pr_world(); wm.gh_pr[7]["pr"]["merged_at"] = iso() w = run(wm, K); assert w.issues[1]["state"] == "closed" and w.issues[2]["labels"] == ["ready"] print("ALL OK 3") +# 13b a ready card with an open PR that closes it adopts the PR: `pr` beat (adopted), a review, no worker, no launch +import io, contextlib +tm.think = lambda seat, model, effort, system, user: "**Approve** matches the card" +tm.pr_diff = lambda n, limit=0: "diff --git a b" +def adopt_world(prbody="Summary\n\nCloses #1", state="open", cbody=D, merged_at=None): + prd = {"number": 7, "state": state, "merged_at": merged_at, "head": {"sha": "abc", "ref": "codex/x"}, "html_url": "u", + "created_at": iso(2), "title": "x", "body": prbody} + return World([card(1, cbody)], {}, [prd], None, {7: {"pr": prd, "statuses": [{"status": "success", "context": "ci"}], "files": [".github/time/time.py"]}}) +KA = {**K, "TIME_REVIEW": "venice:kimi", "TIME_WORKER": "cursor"} +out = io.StringIO() +with contextlib.redirect_stdout(out): + w = run(adopt_world(), KA) +assert "launches_today=0/5" in out.getvalue() +b = tm.beats_of(w.comments[1]) +assert [x["beat"] for x in b] == ["pr"] and b[0]["pr"] == 7 and b[0]["adopted"] is True and not b[0].get("launched"), b +assert w.launched == [] and w.opened == [] and w.comments[7][-1]["body"].startswith("**Approve**"), "reviewed, nothing built" +# next tick, automerge on, Approve + green -> merged (engine paths included: TIME_PROTECTED lets them through) +w = run(w, {**KA, "TIME_AUTOMERGE": "on", "TIME_PROTECTED": NARROW}) +assert merged(w) and w.issues[1]["state"] == "closed" and w.launched == [] +os.environ.pop("TIME_PROTECTED", None) +# a ready card with no PR is dispatched as today +w = run(World([card(1, D)]), K); assert len(w.launched) == 1 and tm.beats_of(w.comments[1])[0]["beat"] == "dispatched" +# a PR that closes another card, a closed PR and a merged one are not adopted +for wx in (adopt_world("Closes #11"), adopt_world(state="closed"), adopt_world(merged_at=iso(1))): + wx = run(wx, KA); assert len(wx.launched) == 1 and "pr" not in [x["beat"] for x in tm.beats_of(wx.comments[1])] +# `PR: #N` in the card adopts without `Closes`; a closed one named there does not +w = run(adopt_world("no closing line", cbody=D + "\n\nPR: #7"), KA) +assert tm.beats_of(w.comments[1])[0]["adopted"] is True and w.launched == [] +w = run(adopt_world("no closing line", state="closed", cbody=D + "\n\nPR: #7"), KA); assert len(w.launched) == 1 +# dry run says what it would adopt, and builds nothing +w = run(adopt_world(), {}) +assert "would adopt open PR #7" in w.posted[0][1] and w.launched == [] and w.comments.get(7) is None +# dry run first, then live: the `would-dispatch` beat doesn't stop adoption, and no worker is launched +w = run(adopt_world(), {}); n0 = len(w.posted) +w = run(w, {}); assert len(w.posted) == n0, "dry run doesn't repeat itself" +w = run(w, KA) +assert w.launched == [] and tm.beats_of(w.comments[1])[-1]["adopted"] is True and w.comments[7][-1]["body"].startswith("**Approve**") +# `PR: #N` must stand alone on its line (quoted or example text doesn't adopt) +w = run(adopt_world("no closing line", cbody=D + "\n\nPR: #7 is an example"), KA); assert len(w.launched) == 1 +print("ALL OK 13b") + # 14 TIME_WORKER=venice:kimi -> the seat edits on its branch, is refused on workflows, opens a PR that closes the card w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), {**K, "TIME_WORKER": "venice:kimi code", "VENICE_API_KEY": "vk"}) assert w.launched == [], "no Cursor launch" diff --git a/.github/time/time.py b/.github/time/time.py index cb2c413..c728846 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -988,6 +988,26 @@ def linked_prs(prs: list[dict], n: int) -> list[dict]: return [p for p in prs if pat.search(p.get("body") or "")] +def adoptable(prs: list[dict], card: dict) -> dict | None: + """An open PR built outside the worker that already answers this card: one whose body closes it, or the one the card's + own body names with `PR: #N`. Closed and merged PRs are never adopted.""" + is_open = lambda p: p.get("state", "open") == "open" and not p.get("merged_at") + found = [p for p in linked_prs(prs, card["number"]) if is_open(p)] + if found: + return found[0] + m = re.search(r"^\s*PR:\s*#(\d+)\s*$", card.get("body") or "", re.M | re.I) + if not m: + return None + num = int(m.group(1)) + p = next((p for p in prs if p.get("number") == num), None) + if p is None: # older than the list tick() reads + try: + p = gh("GET", f"/repos/{REPO}/pulls/{num}") + except Exception: # noqa: BLE001 - a bad number just means nothing to adopt + return None + return p if is_open(p) else None + + def pr_diff(number: int, limit: int = 100_000) -> str: req = urllib.request.Request(f"{GH}/repos/{REPO}/pulls/{number}.diff", headers={"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}"}) @@ -1090,6 +1110,23 @@ def tick() -> None: for card, last, done, blocked, capped, revision, revised_at in plan: n, state = card["number"], (last or {}).get("beat") try: + # work built outside the worker (Codex, a seat's chat): an open PR that already closes this card is reviewed and + # landed like any card PR, instead of launching a worker to build it again. No launch, no worker budget. + # `would-dispatch` is only a dry-run marker (nothing was built), so going live still adopts; a dry run doesn't repeat itself + if (state in (None, "needs-done") or (state == "would-dispatch" and mode == "live")) and not revision \ + and (adopt := adoptable(prs, card)): + if mode != "live": + comment(n, f"⏱️ Time (dry run): would adopt open PR #{adopt['number']}, which already answers this card, " + "and review it instead of dispatching a worker.\n\n" + "- Goes for real once `TIME_MODE=live`", {"beat": "would-dispatch", "mode": mode, "adopt": adopt["number"]}) + 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"]) + 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 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 " diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d5f3d..c9685c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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_` 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). @@ -34,4 +35,5 @@ - The studio index at `/ventures/` wears the Time site's blueprint theme (title block, grid paper, type), with each launched venture's laptop screenshot. - The headless-Chrome check fails a launch page where something sits on top of the product (a page fixed to the screen's height lets the next section slide over it). - The board sees the studio's own ventures: pitches that repeat one (by slug, name or an earlier slug) are set aside before the vote, since the week's trending list barely moves from day to day. +- Cards adopt an open PR that already closes them. A `ready` card with no beats and an open PR whose body closes it (or a card whose body says `PR: #N`) gets a `pr` beat with `adopted: true` and a review in the same tick, then lands through `land()` on Approve + green like any card PR, instead of launching a worker to build it again. No launch is counted and no worker budget is spent; closed and merged PRs are never adopted; dry-run says what it would adopt. Work built outside the worker (Codex, a seat's chat) no longer waits for a human merge. - Hands-off upgrade support with spec planner, auto-merge, and Venice worker integration