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