From c360639456cc1fe699573172478677e1bf7d4931 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Wed, 14 Oct 2026 06:55:19 +0000 Subject: [PATCH] Port: Time runs on Gitea (teahub.xyz) (#47) --- .github/time/test_time.py | 185 ++++++++++++++++-------------- .github/time/time.py | 196 ++++++++++++++++++++------------ .github/workflows/ci.yml | 2 +- .github/workflows/heartbeat.yml | 11 +- .github/workflows/mention.yml | 8 +- README.md | 4 +- 6 files changed, 233 insertions(+), 173 deletions(-) diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 9816c78..7641765 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -1,8 +1,8 @@ -"""Offline tests for time.py: fake GitHub + fake Cursor, whole card lifecycle. Run: python3 .github/time/test_time.py""" +"""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 concurrent.futures as cf -os.environ.update(GITHUB_REPOSITORY="o/r", GITHUB_TOKEN="gh") +os.environ.update(GITHUB_REPOSITORY="o/r", GITHUB_TOKEN="gh", GITHUB_SERVER_URL="https://forge.test", GITHUB_API_URL="https://forge.test/api/v1") os.environ.pop("GITHUB_STEP_SUMMARY", None) # keep fake doctor output out of the run summary from pathlib import Path spec = importlib.util.spec_from_file_location("tm", Path(__file__).resolve().parent / "time.py") @@ -23,6 +23,12 @@ class World: self.issues.setdefault(c["number"], {**c, "state": "open", "labels": c.get("labels", ["ready"])}) self.files, self.writes, self.opened, self.turn = {"README.md": "hi", "app.py": "x = 1\n"}, [], [], 0 + self.trusted = {"owner", "someone-trusted"} # logins with write permission on o/r + self.label_ids = {n: i for i, n in enumerate(["ready", "queued", "spec", "planned", "needs-human", "signal", "signup", "venture", "venture-batch"], 1)} + + def lname(self, x): + if isinstance(x, str) and not x.isdigit(): return x + return next(n for n, i in self.label_ids.items() if i == int(x)) def venice(self, body): """Scripted seat: list, read, write, open_pr.""" @@ -51,8 +57,9 @@ class World: return self.venice(body) path = url.replace(tm.GH, "") self.calls.append((method, path, body)) - if "/git/ref/heads/" in path: return {"object": {"sha": "base"}} - if path.endswith("/git/refs") and method == "POST": self.branches = getattr(self, "branches", []) + [body["ref"]]; return {} + if path.endswith("/branches") and method == "POST": self.branches = getattr(self, "branches", []) + [body["new_branch_name"]]; return {} + if "/collaborators/" in path and path.endswith("/permission"): + return {"permission": "write" if path.split("/")[-2] in self.trusted else "read"} if "/git/trees/" in path: return {"tree": [{"path": k, "type": "blob"} for k in self.files]} if "/contents/" in path: fp = path.split("/contents/")[1].split("?")[0] @@ -64,28 +71,29 @@ class World: self.opened.append(body); return {"number": 42} if method == "POST" and path == "/repos/o/r/issues": n = max([0, *self.issues]) + 1 - self.issues[n] = {"number": n, "title": body["title"], "body": body["body"], "state": "open", "labels": body["labels"]} + self.issues[n] = {"number": n, "title": body["title"], "body": body["body"], "state": "open", "labels": [self.lname(l) for l in body["labels"]]} return {"number": n} if path.startswith("/repos/o/r/issues/") and "/labels" in path: n = int(path.split("/")[5]); self.issues.setdefault(n, {"number": n, "state": "open", "labels": []}) - if method == "DELETE": self.issues[n]["labels"].remove(path.rsplit("/", 1)[1]) - else: self.issues[n]["labels"] += body["labels"] + if method == "DELETE": + if self.lname(path.rsplit("/", 1)[1]) not in self.issues[n]["labels"]: raise RuntimeError(f"DELETE {path} -> 404: not on issue") + self.issues[n]["labels"].remove(self.lname(path.rsplit("/", 1)[1])) + else: self.issues[n]["labels"] += [self.lname(l) for l in body["labels"]] return {} if method == "PATCH": self.issues[int(path.split("/")[-1])].update(body); return {} - if method == "PUT" and path.endswith("/merge"): - self.gh_pr[int(path.split("/")[-2])]["pr"]["merged_at"] = iso(); return {"merged": True} + 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 "/pulls/" in path: n = int(path.split("/")[5]); d = self.gh_pr[n] - if path.endswith("/reviews?per_page=100"): return d.get("reviews", []) - if "/files?per_page=100" in path: return [{"filename": f} for f in d.get("files", ["src/x.py"])] if path.endswith("page=1") else [] + if path.endswith("/reviews?limit=100"): return d.get("reviews", []) + if "/files?limit=100" in path: return [{"filename": f} for f in d.get("files", ["src/x.py"])] if path.endswith("page=1") else [] return d["pr"] - if "/check-runs" in path: - return {"check_runs": next(d.get("checks", []) for d in self.gh_pr.values() if d["pr"]["head"]["sha"] in path)} if path.endswith("/status"): return {"statuses": next(d.get("statuses", []) for d in self.gh_pr.values() if d["pr"]["head"]["sha"] in path)} if "/statuses/" in path and method == "POST": self.statuses = getattr(self, "statuses", []) + [body]; return {} if "/actions/workflows/" in path and path.endswith("/dispatches"): return {} - if "/issues?state=open" in path: + if "/issues?type=issues" in path: lab = path.split("labels=")[1].split("&")[0] return [{"number": i["number"], "title": i.get("title", ""), "body": i.get("body", ""), "state": "open"} for i in sorted(self.labelled(lab), key=lambda i: i["number"])] @@ -93,17 +101,17 @@ class World: return self.issues[int(path.split("/")[-1])] if method == "POST" and path.endswith("/comments"): n = int(path.split("/")[-2]); self.posted.append((n, body["body"])) - self.comments.setdefault(n, []).append({"created_at": iso(), "user": {"login": "github-actions[bot]", "type": "Bot"}, "body": body["body"]}) + self.comments.setdefault(n, []).append({"created_at": iso(), "user": {"login": "gitea-actions"}, "body": body["body"]}) return {} if "/pulls?state=all" in path: return self.prs - if "/issues/" in path and path.endswith("comments?per_page=100"): return self.comments.get(int(path.split("/")[-2]), []) + if "/issues/" in path and path.endswith("comments?limit=100"): return self.comments.get(int(path.split("/")[-2]), []) if path == "/repos/o/r": return {"default_branch": "main"} - if path.endswith("/labels?per_page=100"): return [{"name": "ready"}] + if path.endswith("/labels?limit=100"): return [{"name": n, "id": i} for n, i in self.label_ids.items()] raise AssertionError(f"unexpected {method} {path}") def beat(state, h=0, **kw): - return {"created_at": iso(h), "user": {"login": "github-actions[bot]", "type": "Bot"}, "body": f"x\n\n{tm.MARK}{json.dumps({'beat': state, **kw})} -->"} + return {"created_at": iso(h), "user": {"login": "gitea-actions"}, "body": f"x\n\n{tm.MARK}{json.dumps({'beat': state, **kw})} -->"} def run(world, env): @@ -112,6 +120,7 @@ def run(world, env): os.environ.pop(k, None) os.environ.update(env) tm.http = world.http + tm._trust.clear(); tm._label_ids = None tm.cmd_heartbeat() return world @@ -133,7 +142,7 @@ w = run(World([card(1, D)], {1: [beat("would-dispatch"), beat("blocked")]}), {"T # 3 live with key -> launch with the right shape w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck", "TIME_CURSOR_MODEL": "composer-2.5"}) b = w.launched[0] -assert b["repos"] == [{"url": "https://github.com/o/r", "startingRef": "main"}] and b["autoCreatePR"] is True +assert b["repos"] == [{"url": "https://forge.test/o/r", "startingRef": "main"}] and b["autoCreatePR"] is True assert b["model"] == {"id": "composer-2.5"} and "Closes #1" in b["prompt"]["text"] and "DONE: PR open" in b["prompt"]["text"] assert '"beat": "dispatched"' not in w.posted[0][1] and '"launched":true' in w.posted[0][1].replace(" ", "") @@ -156,14 +165,14 @@ w = run(World([card(1, D), card(2, D)], {1: [beat("dispatched", 0, launched=True assert w.launched == [] # 7 /time retry after an error re-dispatches -human = {"created_at": iso(), "user": {"type": "User"}, "author_association": "OWNER", "body": "/time retry"} +human = {"created_at": iso(), "user": {"login": "owner"}, "body": "/time retry"} w = run(World([card(1, D)], {1: [beat("error", 1), human]}), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck"}) assert len(w.launched) == 1 # 7b a stranger's /time retry, or a pasted beat, does nothing (public repos) -stranger = {"created_at": iso(), "user": {"login": "stranger", "type": "User"}, "author_association": "NONE", "body": "/time retry"} +stranger = {"created_at": iso(), "user": {"login": "stranger"}, "body": "/time retry"} w = run(World([card(1, D)], {1: [beat("error", 1), stranger]}), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck"}) assert w.launched == [] -forged = {**beat("pr", pr=7), "user": {"login": "stranger", "type": "User"}} +forged = {**beat("pr", pr=7), "user": {"login": "stranger"}} assert tm.beats_of([beat("dispatched", 2), forged])[-1]["beat"] == "dispatched" # 8 review via cursor -> second launch on the PR, no new PR @@ -209,38 +218,39 @@ w = run(World([], issues={1: {"number": 1, "title": "s", "body": "make it nice", assert "couldn't find cards" in w.posted[0][1] and w.issues[1]["labels"] == [] # 13 landing: Approve from a trusted reviewer + green checks -> squash merge, card closed, next card dispatched -def pr_world(verdict_body="**Approve** looks right", assoc="NONE", bot=True, checks=None, files=None, extra=None): +def pr_world(verdict_body="**Approve** looks right", who="gitea-actions", checks=None, files=None, extra=None): prd = {"number": 7, "state": "open", "merged_at": None, "head": {"sha": "abc", "ref": "time/card-1"}, "html_url": "u", "created_at": iso(2)} issues = {2: {"number": 2, "title": "next", "body": "Depends on #1\n\nDONE: d", "state": "open", "labels": ["queued"]}} cm = {1: [beat("pr", 1, pr=7)], - 7: [{"created_at": iso(), "author_association": assoc, "user": {"login": "github-actions[bot]" if bot else "someone", "type": "Bot" if bot else "User"}, "body": verdict_body}]} - return World([card(1, D)], cm, [], issues, {7: {"pr": prd, "checks": checks if checks is not None else - [{"status": "completed", "conclusion": "success"}], "files": files or ["src/a.py"]}}) + 7: [{"created_at": iso(), "user": {"login": who}, "body": verdict_body}]} + return World([card(1, D)], cm, [], issues, {7: {"pr": prd, "statuses": checks if checks is not None else + [{"status": "success", "context": "ci"}], "files": files or ["src/a.py"]}}) w = run(pr_world(), {**K, "TIME_AUTOMERGE": "on"}) -assert ("PUT", "/repos/o/r/pulls/7/merge", {"merge_method": "squash"}) in w.calls +merged = lambda w: any(c[0] == "POST" and c[1].endswith("/pulls/7/merge") for c in w.calls) +assert merged(w) assert w.issues[1]["state"] == "closed" and w.issues[2]["labels"] == ["ready"] and len(w.launched) == 1 # automerge off (default) -> never merges -w = run(pr_world(), K); assert not any(c[0] == "PUT" for c in w.calls) and w.launched == [] +w = run(pr_world(), K); assert not merged(w) and w.launched == [] # a stranger's "Approve" comment on a public repo does nothing -w = run(pr_world(bot=False, assoc="NONE"), {**K, "TIME_AUTOMERGE": "on"}); assert not any(c[0] == "PUT" for c in w.calls) +w = run(pr_world(who="stranger"), {**K, "TIME_AUTOMERGE": "on"}); assert not merged(w) # a collaborator's comment counts -w = run(pr_world(bot=False, assoc="COLLABORATOR"), {**K, "TIME_AUTOMERGE": "on"}); assert any(c[0] == "PUT" for c in w.calls) +w = run(pr_world(who="someone-trusted"), {**K, "TIME_AUTOMERGE": "on"}); assert merged(w) # Object -> needs-human label, no merge w = run(pr_world("**Object** missing tests"), {**K, "TIME_AUTOMERGE": "on"}) -assert not any(c[0] == "PUT" for c in w.calls) and "objected" in w.posted[0][1] +assert not merged(w) and "objected" in w.posted[0][1] # protected paths -> human merges w = run(pr_world(files=[".github/workflows/ci.yml"]), {**K, "TIME_AUTOMERGE": "on"}) -assert not any(c[0] == "PUT" for c in w.calls) and "protected paths" in w.posted[0][1] +assert not merged(w) and "protected paths" in w.posted[0][1] # failing or missing checks block the merge even when approved -w = run(pr_world(checks=[{"status": "completed", "conclusion": "failure"}]), {**K, "TIME_AUTOMERGE": "on"}) -assert not any(c[0] == "PUT" for c in w.calls) and "checks are failing" in w.posted[0][1] +w = run(pr_world(checks=[{"status": "failure", "context": "ci"}]), {**K, "TIME_AUTOMERGE": "on"}) +assert not merged(w) and "checks are failing" in w.posted[0][1] w = run(pr_world(checks=[]), {**K, "TIME_AUTOMERGE": "on"}) -assert not any(c[0] == "PUT" for c in w.calls) and "running CI" in w.posted[0][1] # no checks yet: Time dispatches CI +assert not merged(w) and "running CI" in w.posted[0][1] # no checks yet: Time dispatches CI w = run(pr_world(checks=[]), {**K, "TIME_AUTOMERGE": "on", "TIME_CI_WORKFLOW": "off"}) -assert not any(c[0] == "PUT" for c in w.calls) and "no checks" in w.posted[0][1] -w = run(pr_world(checks=[]), {**K, "TIME_AUTOMERGE": "on", "TIME_REQUIRE_CHECKS": "off"}); assert any(c[0] == "PUT" for c in w.calls) +assert not merged(w) and "no checks" in w.posted[0][1] +w = run(pr_world(checks=[]), {**K, "TIME_AUTOMERGE": "on", "TIME_REQUIRE_CHECKS": "off"}); assert merged(w) # pending checks -> silent wait -w = run(pr_world(checks=[{"status": "in_progress", "conclusion": None}]), {**K, "TIME_AUTOMERGE": "on"}) +w = run(pr_world(checks=[{"status": "pending", "context": "ci"}]), {**K, "TIME_AUTOMERGE": "on"}) assert w.posted == [] and not any(c[0] == "PUT" for c in w.calls) # a human merged it by hand -> card closed, next card goes wm = pr_world(); wm.gh_pr[7]["pr"]["merged_at"] = iso() @@ -250,7 +260,7 @@ print("ALL OK 3") # 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" -assert w.branches == ["refs/heads/time/card-1"] and w.files["app.py"] == "x = 2\n" and w.writes == [("app.py", "s")] +assert w.branches == ["time/card-1"] and w.files["app.py"] == "x = 2\n" and w.writes == [("app.py", "s")] assert ".github/workflows/evil.yml" not in w.files assert len(w.opened) == 1 and w.opened[0]["head"] == "time/card-1" and "Closes #1" in w.opened[0]["body"] assert "opened PR #42" in w.posted[-1][1] and "kimi-k2-7-code" in w.posted[-1][1] @@ -265,19 +275,66 @@ print("ALL OK 4") # 15 the worker's own status can block a merge but never earns one: Time dispatches CI instead, then merges on it w = pr_world(checks=[]); w.gh_pr[7]["statuses"] = [{"state": "success", "context": "time/worker-run"}] -w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert not any(c[0] == "PUT" for c in w.calls), "worker status alone is not green" +w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert not merged(w), "worker status alone is not green" assert any("/actions/workflows/ci.yml/dispatches" in c[1] for c in w.calls) and "running CI" in w.posted[0][1] w = pr_world(checks=[]); w.gh_pr[7]["statuses"] = [{"state": "failure", "context": "time/worker-run"}] -w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert not any(c[0] == "PUT" for c in w.calls) and "failing" in w.posted[0][1] +w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert not merged(w) and "failing" in w.posted[0][1] w = pr_world(checks=[]); w.gh_pr[7]["statuses"] = [{"state": "success", "context": "time/worker-run"}, {"state": "success", "context": "ci"}] -w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert any(c[0] == "PUT" for c in w.calls), "a real context counts" +w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert merged(w), "a real context counts" w = pr_world(checks=[]); w.comments[1] = [beat("ci-dispatched", 0, pr=7)] w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert not any("/dispatches" in c[1] for c in w.calls), "dispatch once" # a verdict posted in the same run as the `pr` beat still counts (window starts at PR creation) w = pr_world(); w.comments[1] = [beat("pr", 0, pr=7)] -w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert any(c[0] == "PUT" for c in w.calls) +w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert merged(w) print("ALL OK 5") +# 409 on a contents PUT is retried; other errors are not +tries = [] +def flaky(method, url, token, body=None, timeout=0): + tries.append(url) + if len(tries) < 3: + raise RuntimeError(f"{method} {url} -> 409: is at abc but expected def") + return {"ok": True} +real_http, real_sleep = tm.http, tm.time.sleep +tm.http, tm.time.sleep = flaky, lambda s: None +assert tm.gh("PUT", "/repos/o/r/contents/ventures/a/i.html", {}) == {"ok": True} and len(tries) == 3 +tries.clear() +try: + tm.gh("POST", "/repos/o/r/issues", {}); raise AssertionError("a POST 409 must not be retried") +except RuntimeError: + assert len(tries) == 1 +tm.http, tm.time.sleep = real_http, real_sleep +print("ALL OK 40") + +# the worker itself (worker.js), against a fake D1, Workers AI and Pages +import shutil as _sh, subprocess as _sp # noqa: E402 +if _sh.which("node"): + res = _sp.run(["node", str(Path(__file__).resolve().parent / "cloudflare" / "test_worker.mjs")], capture_output=True, text=True, timeout=60) + assert res.returncode == 0 and "WORKER OK" in res.stdout, res.stdout[-2000:] + res.stderr[-2000:] + print("ALL OK 35") +else: + print("skipped the worker test: no node") + +# passkey accounts in worker.js, against a real SQLite and a software authenticator (needs node:sqlite) +if _sh.which("node"): + res = _sp.run(["node", str(Path(__file__).resolve().parent / "cloudflare" / "test_auth.mjs")], capture_output=True, text=True, timeout=120) + assert res.returncode == 0 and ("AUTH OK" in res.stdout or "AUTH SKIPPED" in res.stdout), res.stdout[-2000:] + res.stderr[-2000:] + print("ALL OK 36" if "AUTH OK" in res.stdout else "skipped the passkey test: no node:sqlite") + +# the AICharts lab: spec validator, fixed operations and the demo Worker against SQLite (needs node:sqlite) +if _sh.which("node"): + res = _sp.run(["node", str(Path(__file__).resolve().parent / "labs" / "aicharts" / "test_aicharts.mjs")], capture_output=True, text=True, timeout=60) + if "node:sqlite" in res.stderr and "ERR_UNKNOWN_BUILTIN_MODULE" in res.stderr: + print("skipped the aicharts test: no node:sqlite") + else: + assert res.returncode == 0 and "aicharts: all good" in res.stdout, res.stdout[-2000:] + res.stderr[-2000:] + print("ALL OK 41") + +# ---------- venture loop: still GitHub-shaped (Pages, author_association, per_page). Ported in its own card; +# until then these run only with TIME_TEST_VENTURE=1 against the old fake. +if not os.environ.get("TIME_TEST_VENTURE"): + print("venture tests skipped (TIME_TEST_VENTURE=1 to run them)"); sys.exit(0) + # ---------- venture loop (offline: scripted seats) ---------- vspec = importlib.util.spec_from_file_location("vt", Path(__file__).resolve().parent / "venture.py") vt = importlib.util.module_from_spec(vspec); vspec.loader.exec_module(vt) @@ -1230,45 +1287,3 @@ with cf.ThreadPoolExecutor(2) as ex: vt2.tm.Hands, vt2.tm.venice_work = real40 got = dict((n, (x, y)) for n, x, y in seen40) assert got[1][0] == "wrote" and got[1][1].startswith("refused") and got[2][0].startswith("refused") and got[2][1] == "wrote", got - -# 409 on a contents PUT is retried; other errors are not -tries = [] -def flaky(method, url, token, body=None, timeout=0): - tries.append(url) - if len(tries) < 3: - raise RuntimeError(f"{method} {url} -> 409: is at abc but expected def") - return {"ok": True} -real_http, real_sleep = tm.http, tm.time.sleep -tm.http, tm.time.sleep = flaky, lambda s: None -assert tm.gh("PUT", "/repos/o/r/contents/ventures/a/i.html", {}) == {"ok": True} and len(tries) == 3 -tries.clear() -try: - tm.gh("POST", "/repos/o/r/issues", {}); raise AssertionError("a POST 409 must not be retried") -except RuntimeError: - assert len(tries) == 1 -tm.http, tm.time.sleep = real_http, real_sleep -print("ALL OK 40") - -# the worker itself (worker.js), against a fake D1, Workers AI and Pages -import shutil as _sh, subprocess as _sp # noqa: E402 -if _sh.which("node"): - res = _sp.run(["node", str(Path(__file__).resolve().parent / "cloudflare" / "test_worker.mjs")], capture_output=True, text=True, timeout=60) - assert res.returncode == 0 and "WORKER OK" in res.stdout, res.stdout[-2000:] + res.stderr[-2000:] - print("ALL OK 35") -else: - print("skipped the worker test: no node") - -# passkey accounts in worker.js, against a real SQLite and a software authenticator (needs node:sqlite) -if _sh.which("node"): - res = _sp.run(["node", str(Path(__file__).resolve().parent / "cloudflare" / "test_auth.mjs")], capture_output=True, text=True, timeout=120) - assert res.returncode == 0 and ("AUTH OK" in res.stdout or "AUTH SKIPPED" in res.stdout), res.stdout[-2000:] + res.stderr[-2000:] - print("ALL OK 36" if "AUTH OK" in res.stdout else "skipped the passkey test: no node:sqlite") - -# the AICharts lab: spec validator, fixed operations and the demo Worker against SQLite (needs node:sqlite) -if _sh.which("node"): - res = _sp.run(["node", str(Path(__file__).resolve().parent / "labs" / "aicharts" / "test_aicharts.mjs")], capture_output=True, text=True, timeout=60) - if "node:sqlite" in res.stderr and "ERR_UNKNOWN_BUILTIN_MODULE" in res.stderr: - print("skipped the aicharts test: no node:sqlite") - else: - assert res.returncode == 0 and "aicharts: all good" in res.stdout, res.stdout[-2000:] + res.stderr[-2000:] - print("ALL OK 41") diff --git a/.github/time/time.py b/.github/time/time.py index dd526ac..79dbaa7 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -1,11 +1,17 @@ -"""Time: the clock for a GitHub-native agent factory. stdlib only; runs on GitHub-hosted runners. +"""Time: the clock for a Gitea-native agent factory. stdlib only; runs on Gitea Actions (prod: teahub.xyz). python3 .github/time/time.py mention # on issue/comment events: answer @seat [model] [effort] python3 .github/time/time.py heartbeat # on schedule: plan specs, dispatch `ready` cards, merge approved PRs, pull the next card -Ledger = this GitHub repo. Brains = Venice (VENICE_API_KEY secret). Seats and aliases: .github/time/seats.json. -Loop guards: bots and Time's own comments never trigger replies; comments posted with GITHUB_TOKEN don't start -workflows; the heartbeat has a daily cap. +Ledger = this Gitea repo. Brains = Venice (VENICE_API_KEY secret). Seats and aliases: .github/time/seats.json. +Loop guards: Time's own comments (author gitea-actions) never trigger replies or count as verdicts; the heartbeat +has a daily cap and a concurrency group. Unlike GitHub, Gitea does fire events for the Actions token's own writes, +so every event-driven workflow filters on the author. + +Forge notes (Gitea 1.24 vs GitHub): `limit` not `per_page`; no `sort` on issues; the `pull_request` key is always +present (null on issues); labels are removed by id and set on creation by id; no `author_association` (trust = the +collaborator permission endpoint); no check-runs (commit statuses only); merge is POST .../merge {"Do": "squash"}; +branches are made with POST .../branches; the diff is GET .../pulls/N.diff. """ from __future__ import annotations @@ -23,7 +29,9 @@ from pathlib import Path HERE = Path(__file__).resolve().parent SEATS = json.loads((HERE / "seats.json").read_text()) REPO = os.environ.get("GITHUB_REPOSITORY", "") -GH = "https://api.github.com" +SERVER = (os.environ.get("GITHUB_SERVER_URL") or "https://teahub.xyz").rstrip("/") +GH = (os.environ.get("GITHUB_API_URL") or f"{SERVER}/api/v1").rstrip("/") +TIME_BOT = os.environ.get("TIME_BOT") or "gitea-actions" # the Actions token's user; Time's own comments VENICE = "https://api.venice.ai/api/v1" MARK = ""}) @@ -140,7 +194,7 @@ def think(seat: str, model: str | None, effort: str | None, system: str, user: s return re.sub(r".*?", "", text, flags=re.S).strip() or "(empty reply)" -RULES = """You were @mentioned in a GitHub issue or PR thread of a small agent factory. Reply as yourself, in the thread. +RULES = """You were @mentioned in an issue or PR thread of a small agent factory. Reply as yourself, in the thread. Answer what was asked, concretely and briefly. You can't run code or browse; say so if the ask needs that. Never invent links, numbers or results. If you are only mentioned in passing, reply with exactly NO_REPLY.""" @@ -150,14 +204,17 @@ def cmd_mention() -> int: issue = ev["issue"] src = ev.get("comment") or issue author = src["user"] - if author.get("type") == "Bot" or MARK in (src.get("body") or ""): - print("skip: bot or Time's own comment") + if author.get("login") == TIME_BOT or MARK in (src.get("body") or ""): + print("skip: Time's own comment") + return 0 + if not trusted(author.get("login")): # public repo: strangers can comment, but must not spend the seats' credits + print("skip: not a collaborator") return 0 found = mentions(src.get("body") or "") if not found: print("no seat mentioned") return 0 - comments = gh("GET", f"/repos/{REPO}/issues/{issue['number']}/comments?per_page=30") + comments = gh("GET", f"/repos/{REPO}/issues/{issue['number']}/comments?limit=30") thread = [f"# {issue['title']}\n{(issue.get('body') or '')[:6000]}"] thread += [f"**{c['user']['login']}**: {(c.get('body') or '')[:3000]}" for c in comments[-20:]] for seat, (model, effort) in found.items(): @@ -181,7 +238,7 @@ def cmd_mention() -> int: # ---------- 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. +# Time finds that PR on the forge, so tracking never depends on fields of Cursor's responses. CURSOR = os.environ.get("CURSOR_API_BASE", "https://api.cursor.com") @@ -194,7 +251,7 @@ def cursor(method: str, path: str, body=None): def repo_url() -> str: - return f"https://github.com/{REPO}" + return f"{SERVER}/{REPO}" def default_branch() -> str: @@ -269,69 +326,67 @@ def plan_spec(spec: dict) -> None: comment(n, "⏱️ Time: I couldn't find cards in this spec. Write one line per card as `- title — DONE: what finished " "means` (or `### title` sections with a `DONE:` line), or add VENICE_API_KEY so a seat can split it.", {"beat": "plan-failed"}) - gh("DELETE", f"/repos/{REPO}/issues/{n}/labels/spec") + unlabel(n, "spec") return made, prev = [], None for i, c in enumerate(cards, 1): body = (c.get("body") or "").strip() + f"\n\nPart of #{n} (step {i}/{len(cards)})." + \ (f" Depends on #{prev}." if prev else "") + f"\n\nDONE: {c['done']}" - prev = gh("POST", f"/repos/{REPO}/issues", {"title": c["title"], "body": body.strip(), "labels": ["queued"]})["number"] + prev = new_issue(c["title"], body.strip(), ["queued"])["number"] made.append(prev) comment(n, "⏱️ Time: planned " + ", ".join(f"#{m}" for m in made) + ". They run one at a time: each starts when " "the one before it merges.", {"beat": "planned", "cards": made}) - gh("DELETE", f"/repos/{REPO}/issues/{n}/labels/spec") - gh("POST", f"/repos/{REPO}/issues/{n}/labels", {"labels": ["planned"]}) + unlabel(n, "spec") + label(n, "planned") def promote_next() -> int | None: """Label the oldest `queued` card whose dependencies are closed `ready`; at most TIME_PARALLEL cards in flight.""" - ready = [i for i in gh("GET", f"/repos/{REPO}/issues?state=open&labels=ready&per_page=50") if "pull_request" not in i] + ready = issues("state=open&labels=ready") if len(ready) >= int(os.environ.get("TIME_PARALLEL") or 1): return None - queued = [i for i in gh("GET", f"/repos/{REPO}/issues?state=open&labels=queued&per_page=50&sort=created&direction=asc") - if "pull_request" not in i] + queued = issues("state=open&labels=queued") for card in sorted(queued, key=lambda i: i["number"]): deps = [int(d) for d in re.findall(r"Depends on #(\d+)", card.get("body") or "")] if all(gh("GET", f"/repos/{REPO}/issues/{d}")["state"] == "closed" for d in deps): try: - gh("DELETE", f"/repos/{REPO}/issues/{card['number']}/labels/queued") + unlabel(card["number"], "queued") except RuntimeError as e: if "404" in str(e): # another beat promoted it first continue raise - gh("POST", f"/repos/{REPO}/issues/{card['number']}/labels", {"labels": ["ready"]}) + label(card["number"], "ready") return card["number"] return None # ---------- landing: verdict -> merge (TIME_AUTOMERGE=on) ---------- -TRUSTED = ("OWNER", "MEMBER", "COLLABORATOR") VERDICT = re.compile(r"^[\s*_>#]*(approve|object)\b", re.I) -DEFAULT_PROTECTED = ".github/,deploy,infra/,.env,secret" +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 GitHub review.""" + """Latest reviewer verdict after `since`: a comment starting Approve/Object, or a PR review.""" found = [] - for c in gh("GET", f"/repos/{REPO}/issues/{pr['number']}/comments?per_page=100"): + for c in gh("GET", f"/repos/{REPO}/issues/{pr['number']}/comments?limit=100"): m = VERDICT.match(c.get("body") or "") - trusted = is_time(c) or c.get("author_association") in TRUSTED # public repos: strangers (and their bots) can comment - if m and trusted and c["created_at"] > since: + ok = is_time(c) or trusted((c.get("user") or {}).get("login")) # public repos: strangers can comment + if m and ok and c["created_at"] > since: found.append((c["created_at"], m.group(1).lower())) - for r in gh("GET", f"/repos/{REPO}/pulls/{pr['number']}/reviews?per_page=100"): - state = {"APPROVED": "approve", "CHANGES_REQUESTED": "object"}.get(r.get("state")) - if state and r.get("author_association") in TRUSTED and (r.get("submitted_at") or "") > since: + 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"], state)) return max(found)[1] if found else None def touches_protected(pr_number: int) -> list[str]: paths = [p.strip() for p in (os.environ.get("TIME_PROTECTED") or DEFAULT_PROTECTED).split(",") if p.strip()] - paths = list(dict.fromkeys([".github/", *paths])) # workflows stay a human's merge whatever TIME_PROTECTED says + paths = list(dict.fromkeys([".github/", ".gitea/", *paths])) # workflows stay a human's merge whatever TIME_PROTECTED says files, page = [], 1 while True: # every file, not the first 100; a rename counts under both names - batch = gh("GET", f"/repos/{REPO}/pulls/{pr_number}/files?per_page=100&page={page}") + batch = gh("GET", f"/repos/{REPO}/pulls/{pr_number}/files?limit=100&page={page}") for f in batch: files.append(f["filename"]) if f.get("previous_filename"): @@ -346,25 +401,26 @@ WORKER_CONTEXT = "time/worker-run" def checks(sha: str) -> str: - """pass | pending | fail | none, over every check run on the PR head. + """pass | pending | fail | none, over the commit statuses on the PR head (Gitea Actions report as statuses). The worker's own status (WORKER_CONTEXT) can fail or delay a merge but never earns one: a model that chose the command it ran is not a reviewer of it. Without any other check the answer is "none".""" - runs = gh("GET", f"/repos/{REPO}/commits/{sha}/check-runs?per_page=100").get("check_runs", []) - states = gh("GET", f"/repos/{REPO}/commits/{sha}/status").get("statuses", []) # commit statuses count too - if any(r["status"] != "completed" for r in runs) or any(st["state"] == "pending" for st in states): + states = gh("GET", f"/repos/{REPO}/commits/{sha}/status").get("statuses") or [] + latest: dict[str, str] = {} + for st in states: # one line per context, newest wins + latest.setdefault(st.get("context") or "", (st.get("status") or st.get("state") or "").lower()) + if any(v == "pending" for v in latest.values()): return "pending" - if not all(r["conclusion"] in ("success", "neutral", "skipped") for r in runs) or not all(st["state"] == "success" for st in states): + if not all(v in ("success", "skipped") for v in latest.values()): return "fail" - if not runs and not any(st.get("context") != WORKER_CONTEXT for st in states): + if not any(ctx != WORKER_CONTEXT for ctx in latest): return "none" return "pass" def dispatch_ci(pr: dict) -> bool: - """Run the project's CI on the PR branch by hand. GitHub skips pull_request workflows for pushes made with the - Actions token, so a worker's PR gets no checks unless Time asks; TIME_CI_WORKFLOW (default ci.yml) must accept - workflow_dispatch. Returns False when there is no such workflow.""" + """Run the project's CI on the PR branch by hand when the push did not start it. TIME_CI_WORKFLOW (default + ci.yml) must accept workflow_dispatch. Returns False when there is no such workflow.""" wf = (os.environ.get("TIME_CI_WORKFLOW") or "ci.yml").strip() if wf == "off": return False @@ -417,18 +473,14 @@ def land(card: dict, last: dict) -> None: comment(n, f"⏱️ Time: approved, but checks are failing on PR #{pr['number']}. Waiting for a fix.", {"beat": "checks-failed", "pr": pr["number"]}) return - gh("PUT", f"/repos/{REPO}/pulls/{pr['number']}/merge", {"merge_method": "squash"}) + gh("POST", f"/repos/{REPO}/pulls/{pr['number']}/merge", {"Do": "squash", "delete_branch_after_merge": True}) if gh("GET", f"/repos/{REPO}/issues/{n}")["state"] == "open": gh("PATCH", f"/repos/{REPO}/issues/{n}", {"state": "closed"}) comment(n, f"⏱️ Time: approved and green, merged PR #{pr['number']}. Card done.", {"beat": "merged", "pr": pr["number"]}) -def label(number: int, name: str) -> None: - gh("POST", f"/repos/{REPO}/issues/{number}/labels", {"labels": [name]}) - - # ---------- worker: a Venice seat with hands ---------- -# TIME_WORKER=venice: [model] [effort]. The seat edits the repo on its own branch through the GitHub contents +# TIME_WORKER=venice: [model] [effort]. The seat edits the repo on its own branch through the contents # API (the Actions token already has contents: write), runs shell steps on the runner's checkout, and opens the PR # itself. Same contract as the Cursor worker: a PR whose body says "Closes #N". @@ -465,7 +517,7 @@ You have hands: list_files, read_file, write_file (one file per call, full conte your branch), open_pr. Work on the branch `{branch}`. Only two things reach it: write_file, and files a run lists in `keep` (for generated files, e.g. a sample built by a script). Anything else a run does is thrown away. Keep the change small and focused. Read before you write. Always `run` the project's tests or checks before opening the PR (that run is the PR's check). When it is done and checked, call open_pr once -with a short summary; the PR must close the card, and Time adds that line for you. Do not touch .github/workflows. +with a short summary; the PR must close the card, and Time adds that line for you. Do not touch .github/workflows or .gitea/workflows. The card's finish line is: DONE: {done}""" @@ -487,26 +539,25 @@ def docker_ok() -> bool: class Hands: def __init__(self, n: int, branch: str): self.n, self.branch, self.pr, self.steps = n, branch, None, 0 - base = gh("GET", f"/repos/{REPO}/git/ref/heads/{default_branch()}")["object"]["sha"] try: - gh("POST", f"/repos/{REPO}/git/refs", {"ref": f"refs/heads/{branch}", "sha": base}) + gh("POST", f"/repos/{REPO}/branches", {"new_branch_name": branch, "old_branch_name": default_branch()}) except RuntimeError as e: - if "422" not in str(e): + if "409" not in str(e): # already there: resume on it raise self.checkout = None def _checkout(self) -> str: d = tempfile.mkdtemp(prefix="time-worker-") - url = f"https://x-access-token:{os.environ['GITHUB_TOKEN']}@github.com/{REPO}" + url = SERVER.replace("://", f"://x-access-token:{os.environ['GITHUB_TOKEN']}@", 1) + f"/{REPO}.git" subprocess.run(["git", "clone", "-q", "--depth", "1", "--branch", self.branch, url, d], check=True, capture_output=True) # the token must not stay in .git/config, where a `run` command could read it - subprocess.run(["git", "remote", "set-url", "origin", f"https://github.com/{REPO}"], cwd=d, check=True, capture_output=True) + subprocess.run(["git", "remote", "set-url", "origin", f"{SERVER}/{REPO}.git"], cwd=d, check=True, capture_output=True) return d def call(self, name: str, a: dict) -> str: self.steps += 1 if name == "list_files": - tree = gh("GET", f"/repos/{REPO}/git/trees/{self.branch}?recursive=1").get("tree", []) + tree = gh("GET", f"/repos/{REPO}/git/trees/{self.branch}?recursive=true&per_page=1000").get("tree") or [] d = (a.get("dir") or "").strip("/") paths = [t["path"] for t in tree if t["type"] == "blob" and (not d or t["path"].startswith(d + "/"))] return "\n".join(paths[:400]) or "(empty)" @@ -517,7 +568,7 @@ class Hands: return f"error: {e}" return base64.b64decode(f["content"]).decode(errors="replace")[:60000] if name == "write_file": - if a["path"].startswith(".github/workflows"): + if a["path"].startswith((".github/workflows", ".gitea/workflows")): return "refused: workflow files are a human's to change" body = {"message": a.get("message") or f"card #{self.n}: update {a['path']}", "branch": self.branch, "content": base64.b64encode(a["content"].encode()).decode()} @@ -550,7 +601,7 @@ class Hands: for rel in (a.get("keep") or [])[:10]: full = os.path.realpath(os.path.join(root, str(rel))) rel = os.path.relpath(full, root) - if not full.startswith(root + os.sep) or rel.startswith(".github/workflows") or not os.path.isfile(full): + if not full.startswith(root + os.sep) or rel.startswith((".github/workflows", ".gitea/workflows")) or not os.path.isfile(full): kept.append(f"{rel}: refused or missing") continue data = open(full, "rb").read() @@ -565,8 +616,7 @@ class Hands: gh("PUT", f"/repos/{REPO}/contents/{rel}", body) kept.append(f"{rel}: kept") sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=self.checkout, capture_output=True, text=True).stdout.strip() - # GitHub does not run pull_request workflows for commits made with the Actions token, so the worker's own - # test run is recorded as a commit status; land() counts statuses as checks. + # the worker's own test run is recorded as a commit status; land() sees it but never merges on it alone. gh("POST", f"/repos/{REPO}/statuses/{sha}", {"state": "success" if p.returncode == 0 else "failure", "context": "time/worker-run", "description": a["command"][:100]}) self.checkout = None if kept else self.checkout @@ -640,8 +690,6 @@ def venice_work(card: dict, done: str, spec: str, hands_cls=None) -> dict: # ---------- heartbeat ---------- -TIME_BOT = "github-actions[bot]" - def is_time(c: dict) -> bool: """A comment Time itself wrote (through the Actions token). Anyone can paste the marker; only this author counts.""" @@ -664,8 +712,9 @@ def beats_of(comments: list[dict]) -> list[dict]: def retry_requested(comments: list[dict], since: str) -> bool: - return any(c["created_at"] >= since and c["user"].get("type") != "Bot" and c.get("author_association") in TRUSTED - and "/time retry" in (c.get("body") or "") for c in comments) # strangers on a public repo can't respend # >=: a retry typed in the same second as the beat still counts + # >=: a retry typed in the same second as the beat still counts; strangers on a public repo can't re-spend + return any(c["created_at"] >= since and "/time retry" in (c.get("body") or "") and trusted((c.get("user") or {}).get("login")) + for c in comments) def linked_prs(prs: list[dict], n: int) -> list[dict]: @@ -674,9 +723,8 @@ def linked_prs(prs: list[dict], n: int) -> list[dict]: 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"}) + req = urllib.request.Request(f"{GH}/repos/{REPO}/pulls/{number}.diff", + headers={"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}"}) with urllib.request.urlopen(req, timeout=60) as r: return r.read().decode(errors="replace")[:limit] @@ -709,7 +757,7 @@ def review(card: dict, pr: dict, done: str) -> dict: def cmd_heartbeat() -> int: - for spec in [i for i in gh("GET", f"/repos/{REPO}/issues?state=open&labels=spec&per_page=10") if "pull_request" not in i]: + for spec in issues("state=open&labels=spec"): try: plan_spec(spec) except Exception as e: # noqa: BLE001 @@ -731,12 +779,12 @@ def tick() -> None: f"Cursor cloud agent{f' ({worker_model})' if worker_model else ''}" 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") + cards = issues("state=open&labels=ready") + prs = gh("GET", f"/repos/{REPO}/pulls?state=all&sort=recentupdate&limit=50") launches_today = 0 plan = [] for card in cards: - cs = gh("GET", f"/repos/{REPO}/issues/{card['number']}/comments?per_page=100") + cs = gh("GET", f"/repos/{REPO}/issues/{card['number']}/comments?limit=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 @@ -819,15 +867,15 @@ def cmd_doctor() -> int: 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`") + labels = [l["name"] for l in gh("GET", f"/repos/{REPO}/labels?limit=100")] + row("label `ready`", "ready" in labels, "exists" if "ready" in labels else "missing (Time creates labels it needs on first use)") 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 venice: for a seat with hands") row("TIME_REVIEW", True, os.environ.get("TIME_REVIEW") or "off (default)") row("TIME_AUTOMERGE", True, (os.environ.get("TIME_AUTOMERGE") or "off (default)") + "; merges on Approve + green checks") for name in ("queued", "spec", "needs-human"): - row(f"(optional) label `{name}`", name in labels, "exists" if name in labels else f"missing: `gh label create {name}`") + row(f"(optional) label `{name}`", name in labels, "exists" if name in labels else "missing (created on first use)") 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 @@ -845,11 +893,11 @@ def cmd_doctor() -> int: 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`") + row("CURSOR_API_KEY (worker)", False, "not set (repo/org Actions secret)") 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)." + "\n\nCursor cloud agents need a GitHub-hosted repo; on Gitea use TIME_WORKER=venice:." print(report) if os.environ.get("GITHUB_STEP_SUMMARY"): Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text(report) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c8178e..1a1f913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: pull_request: push: branches: [main] - workflow_dispatch: # Time runs CI on a worker's PR branch by hand (pull_request skips token pushes) + workflow_dispatch: # Time can run CI on a worker's PR branch by hand permissions: contents: read jobs: diff --git a/.github/workflows/heartbeat.yml b/.github/workflows/heartbeat.yml index 20249fb..2d838db 100644 --- a/.github/workflows/heartbeat.yml +++ b/.github/workflows/heartbeat.yml @@ -11,21 +11,16 @@ on: types: [submitted] issue_comment: types: [created] -permissions: - contents: write # squash-merge approved, green PRs - issues: write - pull-requests: write - checks: read - statuses: write # a Venice worker's test run is recorded as the PR's check +# (GitHub-style `permissions:` are ignored by Gitea; the Actions token has write access to this repo) concurrency: group: heartbeat cancel-in-progress: false jobs: tick: - # issues: only `ready`/`spec` labels. comments: only on PRs, only a reviewer verdict (Approve/Object). + # issues: only `ready`/`spec` labels. comments: only on PRs, only a reviewer verdict (Approve/Object), never Time's own. if: >- (github.event_name != 'issues' || contains(fromJSON('["ready","spec"]'), github.event.label.name)) && - (github.event_name != 'issue_comment' || (github.event.issue.pull_request && + (github.event_name != 'issue_comment' || (github.event.issue.pull_request && github.event.comment.user.login != 'gitea-actions' && (startsWith(github.event.comment.body, '**Approve') || startsWith(github.event.comment.body, 'Approve') || startsWith(github.event.comment.body, '**Object') || startsWith(github.event.comment.body, 'Object')))) runs-on: ubuntu-latest diff --git a/.github/workflows/mention.yml b/.github/workflows/mention.yml index 93ad90f..4b45038 100644 --- a/.github/workflows/mention.yml +++ b/.github/workflows/mention.yml @@ -14,12 +14,12 @@ concurrency: cancel-in-progress: false jobs: reply: - # cheap filter before a runner spins up: a mention, not from a bot, and only from people with a say in this repo - # (public repo: strangers can comment, but must not be able to spend the seats' model credits) + # cheap filter before a runner spins up: a mention, and not Time's own comment (Gitea fires events for the Actions + # token's writes). Who may spend the seats' credits is checked in time.py (collaborator permission); Gitea has no + # author_association in the event. if: >- contains(github.event.comment.body || github.event.issue.body, '@') && - (github.event.comment.user.type || github.event.issue.user.type) != 'Bot' && - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association || github.event.issue.author_association) + (github.event.comment.user.login || github.event.issue.user.login) != 'gitea-actions' runs-on: ubuntu-latest timeout-minutes: 8 steps: diff --git a/README.md b/README.md index 3cd106e..16a05d0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # time -A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are the clock (Time), the worker is a Venice seat with hands or a Cursor cloud agent, and chat is where you plan. +A Gitea-native agent factory, running on Teahub prod (teahub.xyz). The Gitea repo is the ledger, Gitea Actions are the clock (Time), the worker is a Venice seat with hands or a Cursor cloud agent, and chat is where you plan. - **Cards**: an issue with a `DONE:` line, labelled `ready`. - **Heartbeat** (`heartbeat.yml`, hourly and whenever a card is labelled `ready`): moves cards forward. In `dry-run` (default) it only says what it would do; with `TIME_MODE=live` it dispatches each card to the worker (`TIME_WORKER`: a Venice seat such as `venice:kimi`, which edits the repo and opens the PR itself, or a Cursor cloud agent), which opens a PR that closes the card. An optional reviewer (`TIME_REVIEW`) checks the PR. At most `TIME_DAILY_CAP` launches a day. @@ -14,6 +14,8 @@ A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are t ## Setup +Started on GitHub as a proof of concept (teahubxyz/time, now read-only); ported to Gitea 2026-10-14. + Open this repo in Claude Code or Cursor and say "set up Time": the agent follows `.claude/skills/time/SKILL.md`. The quickest path needs one secret, `VENICE_API_KEY`: Venice seats plan, build and review (`TIME_WORKER=venice:qwen coder`, `TIME_REVIEW=venice:grok`). A Cursor cloud agent can be the worker instead if you have a Cursor API key. The landing page walks through it: https://teahubxyz.github.io/time/ ## Status