Hands-off Time: spec planner, auto-merge on Approve + green checks, next card, update workflow
- spec issues (label spec) become queued cards, parsed deterministically, seat fallback - TIME_AUTOMERGE=on: squash-merge when the latest trusted verdict is Approve and all checks pass - Object, red or missing checks, or protected paths go to needs-human - merged card frees the slot; the next unblocked queued card gets ready and dispatches in the same run - update.yml pulls the engine from teahubxyz/time into project repos via PR - ci.yml runs Time's own tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
This commit is contained in:
1 parent
9108adf4c6
commit
889e93799e
9 files changed
+379
-14
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: time
|
||||
description: Set up and run Time, the GitHub-native agent factory in this repo (issues as cards, a `ready` label, a DONE line, a Cursor cloud agent as the worker, @seat mentions, an hourly heartbeat in GitHub Actions). Use when asked to set up Time or the factory, connect Cursor, check its status, write a card, tag a seat, or switch it to live.
|
||||
description: Set up and run Time, the GitHub-native agent factory in this repo (a spec becomes cards, a Cursor cloud agent builds each one, a reviewer approves, Time merges on green and starts the next card; @seat mentions; an hourly heartbeat in GitHub Actions). Use when asked to set up Time or the factory, connect Cursor, check its status, write a spec or a card, tag a seat, switch it to live or hands-off, or update Time.
|
||||
---
|
||||
|
||||
# Time
|
||||
@@ -11,6 +11,8 @@ You (Claude Code, Cursor, or any agent with `gh`) are the hands for setup and th
|
||||
## You are here
|
||||
|
||||
```
|
||||
chat ─writes─▶ spec issue ─label `spec`─▶ planner ─▶ cards (`queued`, one PR each, in order)
|
||||
│ first unblocked card gets `ready`
|
||||
chat ─writes─▶ card = issue (spec + DONE:) ─label `ready`─▶ heartbeat (on label + hourly)
|
||||
│
|
||||
dry-run ◀─────────── TIME_MODE ───────────▶ live ▼
|
||||
@@ -18,17 +20,20 @@ You (Claude Code, Cursor, or any agent with `gh`) are the hands for setup and th
|
||||
│ next heartbeat finds it
|
||||
▼
|
||||
review (TIME_REVIEW): Cursor model | Venice seat | off
|
||||
│ TIME_AUTOMERGE=on
|
||||
Approve + checks green ─▶ squash merge ─▶ next `queued` card gets `ready`
|
||||
Object · protected path · red checks ─▶ `needs-human`
|
||||
@seat [model] [effort] in any comment ─▶ mention.yml ─▶ seat replies in the thread (Venice)
|
||||
```
|
||||
|
||||
Card states (Time's comments on the issue): `would-dispatch` → `dispatched` → `pr` · side states `needs-done`, `stuck`, `blocked` (a key is missing; resumes by itself once added), `error` (comment `/time retry`).
|
||||
Card states (Time's comments on the issue): `would-dispatch` → `dispatched` → `pr` → `merged` · landing states `needs-human`, `no-checks`, `checks-failed` · side states `needs-done`, `stuck`, `blocked` (a key is missing; resumes by itself once added), `error` (comment `/time retry`).
|
||||
|
||||
Files: `.github/workflows/{heartbeat,mention,doctor}.yml`, `.github/time/time.py` (stdlib), `.github/time/seats.json`, `.github/time/test_time.py` (offline tests).
|
||||
Files: `.github/workflows/{heartbeat,mention,doctor,update}.yml`, `.github/time/time.py` (stdlib), `.github/time/seats.json`, `.github/time/test_time.py` (offline tests).
|
||||
|
||||
## Setup (idempotent; do only what's missing)
|
||||
|
||||
1. `gh auth status` — logged in with admin on this repo. If not: ask the human to run `gh auth login`.
|
||||
2. Label: `gh label create ready --color 0e8a16 --description "Time picks this up on the next heartbeat" || true`
|
||||
2. Labels: `gh label create ready --color 0e8a16 --description "Time picks this up on the next heartbeat" || true`, and for hands-off mode `gh label create spec --color 5319e7 || true; gh label create queued --color c5def5 || true; gh label create planned --color ededed || true; gh label create needs-human --color d93f0b || true`
|
||||
3. Settings (`gh variable list` first; set only missing ones):
|
||||
- `gh variable set TIME_MODE --body dry-run`
|
||||
- `gh variable set TIME_DAILY_CAP --body 5` — max agent launches per day
|
||||
@@ -72,6 +77,27 @@ Label it `ready`. No `DONE:` line → Time asks for one instead of dispatching.
|
||||
|
||||
Not a Time seat: `@cursor`. If Cursor's GitHub app is installed, `@cursor` in a comment is Cursor's own integration and it answers directly.
|
||||
|
||||
## Hands-off: spec in, merged work out
|
||||
|
||||
1. CI: the project needs at least one check on pull requests (tests or lint). Without checks Time won't merge
|
||||
(`TIME_REQUIRE_CHECKS=off` overrides, not recommended). Add `.github/workflows/ci.yml` for the project's stack.
|
||||
2. A reviewer: `gh variable set TIME_REVIEW --body cursor:<model id>` (or `venice:kimi`).
|
||||
3. `gh variable set TIME_AUTOMERGE --body on`. Time squash-merges a card's PR when the reviewer's latest verdict is
|
||||
Approve and every check is green, then gives `ready` to the next card. Approve/Object counts from bots and from the
|
||||
repo's owner, members and collaborators only.
|
||||
4. Human gate: PRs touching `TIME_PROTECTED` paths (default `.github/,deploy,infra/,.env,secret`) are never merged by
|
||||
Time; they get `needs-human`. So are PRs the reviewer objects to.
|
||||
5. Specs: write an issue, label it `spec`. One card per line, `- title — DONE: what finished means`, or `### title`
|
||||
sections that contain a `DONE:` line. Time creates `queued` cards that run one at a time (`TIME_PARALLEL`, default 1).
|
||||
A spec that isn't card-shaped is split by a seat when `VENICE_API_KEY` is set (`TIME_PLANNER`, default kimi).
|
||||
|
||||
## Updating Time
|
||||
|
||||
The engine lives centrally in teahubxyz/time. `update.yml` (weekly, or `gh workflow run update.yml`) opens a PR with
|
||||
the latest `time.py`, tests, seats and this skill from `TIME_UPSTREAM`@`TIME_CHANNEL` (default `main`; pin a tag
|
||||
for stability). Actions can't edit workflow files, so the PR lists any that changed for you to copy. Improvements go
|
||||
to teahubxyz/time as PRs so every project gets them. Needs "Allow GitHub Actions to create pull requests" on.
|
||||
|
||||
## Going live
|
||||
|
||||
Only when the human says so: `gh variable set TIME_MODE --body live`. The daily cap still applies. Back to `dry-run` the same way. Stop everything: `gh workflow disable heartbeat.yml`.
|
||||
@@ -79,7 +105,7 @@ Only when the human says so: `gh variable set TIME_MODE --body live`. The daily
|
||||
## Rules
|
||||
|
||||
- Never print, echo, commit or paste a secret. Secrets go in with `gh secret set`, values typed by the human.
|
||||
- Ask before: going live, raising the daily cap, changing the heartbeat schedule, adding a self-hosted runner. A public repo must never get a self-hosted runner.
|
||||
- Ask before: going live, turning on auto-merge, raising the daily cap, changing the heartbeat schedule, adding a self-hosted runner. A public repo must never get a self-hosted runner.
|
||||
- Runner minutes: private repos have a monthly free allowance, each job rounds up to a minute; keep the heartbeat hourly unless asked. Cursor agent runs bill to the human's Cursor plan.
|
||||
- Changed `time.py`? Run `python3 .github/time/test_time.py` before committing.
|
||||
- Status: `gh issue list -l ready`, `gh run list -L 10`, and the latest Time comment on each card.
|
||||
@@ -12,9 +12,17 @@ iso = lambda h=0: (NOW - dt.timedelta(hours=h)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
class World:
|
||||
def __init__(self, cards, comments=None, prs=None):
|
||||
def __init__(self, cards, comments=None, prs=None, issues=None, gh_pr=None):
|
||||
self.cards, self.comments, self.prs = cards, comments or {}, prs or []
|
||||
self.posted, self.launched = [], []
|
||||
self.issues = issues or {} # extra issues by number: {"state", "labels", "body", ...}
|
||||
self.gh_pr = gh_pr or {} # number -> {"pr", "reviews", "files", "checks"}
|
||||
self.calls = []
|
||||
for c in cards:
|
||||
self.issues.setdefault(c["number"], {**c, "state": "open", "labels": c.get("labels", ["ready"])})
|
||||
|
||||
def labelled(self, name):
|
||||
return [i for i in self.issues.values() if i["state"] == "open" and name in i["labels"]]
|
||||
|
||||
def http(self, method, url, token, body=None, timeout=0):
|
||||
if url.startswith(tm.CURSOR):
|
||||
@@ -24,15 +32,40 @@ class World:
|
||||
if url.endswith("/v1/models"):
|
||||
return {"models": [{"id": "composer-2.5"}, {"id": "claude-sonnet-5-5"}]}
|
||||
path = url.replace(tm.GH, "")
|
||||
self.calls.append((method, path, body))
|
||||
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"]}
|
||||
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"]
|
||||
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 "/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 path.endswith("/files?per_page=100"): return [{"filename": f} for f in d.get("files", ["src/x.py"])]
|
||||
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 "/issues?state=open" 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"])]
|
||||
if method == "GET" and path.startswith("/repos/o/r/issues/") and path.count("/") == 5:
|
||||
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"]})
|
||||
return {}
|
||||
if "/issues?state=open" in path: return self.cards
|
||||
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 path == "/repos/o/r": return {"default_branch": "main"}
|
||||
if "/labels" in path: return [{"name": "ready"}]
|
||||
if path.endswith("/labels?per_page=100"): return [{"name": "ready"}]
|
||||
raise AssertionError(f"unexpected {method} {path}")
|
||||
|
||||
|
||||
@@ -41,7 +74,8 @@ def beat(state, h=0, **kw):
|
||||
|
||||
|
||||
def run(world, env):
|
||||
for k in ("TIME_MODE", "CURSOR_API_KEY", "TIME_REVIEW", "TIME_DAILY_CAP", "TIME_CURSOR_MODEL"):
|
||||
for k in ("TIME_MODE", "CURSOR_API_KEY", "TIME_REVIEW", "TIME_DAILY_CAP", "TIME_CURSOR_MODEL", "TIME_AUTOMERGE",
|
||||
"TIME_REQUIRE_CHECKS", "VENICE_API_KEY"):
|
||||
os.environ.pop(k, None)
|
||||
os.environ.update(env)
|
||||
tm.http = world.http
|
||||
@@ -117,3 +151,57 @@ print("ALL OK")
|
||||
w = run(World([card(1, D)], {1: [beat("would-dispatch", 2), beat("blocked", 1)]}), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck"})
|
||||
assert len(w.launched) == 1, w.posted
|
||||
print("ALL OK 2")
|
||||
|
||||
# ---------- planner, landing, next card ----------
|
||||
K = {"TIME_MODE": "live", "CURSOR_API_KEY": "ck"}
|
||||
|
||||
# 12 spec lines -> queued cards with dependencies; the first one is promoted and dispatched in the same run
|
||||
spec_body = "Build orbit v0\n\n- [ ] Add login page — DONE: PR open, tests green\n- Add logout | DONE: PR open\n- Docs -- DONE: README updated"
|
||||
w = run(World([], issues={1: {"number": 1, "title": "spec", "body": spec_body, "state": "open", "labels": ["spec"]}}), K)
|
||||
made = [n for n in w.issues if n != 1]
|
||||
assert made == [2, 3, 4] and w.issues[1]["labels"] == ["planned"], w.issues
|
||||
assert "Depends on #2" in w.issues[3]["body"] and "DONE: README updated" in w.issues[4]["body"]
|
||||
assert w.issues[2]["labels"] == ["ready"] and w.issues[3]["labels"] == ["queued"] and len(w.launched) == 1
|
||||
assert "Add login page" in w.launched[0]["prompt"]["text"]
|
||||
# section-style spec also parses
|
||||
assert [c["title"] for c in tm.parse_spec("intro\n### One\nchange a\nDONE: x\n### Two\nDONE: y")] == ["One", "Two"]
|
||||
# unparseable spec without a key -> one helpful comment, spec label removed
|
||||
w = run(World([], issues={1: {"number": 1, "title": "s", "body": "make it nice", "state": "open", "labels": ["spec"]}}), K)
|
||||
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):
|
||||
prd = {"number": 7, "state": "open", "merged_at": None, "head": {"sha": "abc"}, "html_url": "u"}
|
||||
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": {"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"]}})
|
||||
w = run(pr_world(), {**K, "TIME_AUTOMERGE": "on"})
|
||||
assert ("PUT", "/repos/o/r/pulls/7/merge", {"merge_method": "squash"}) in w.calls
|
||||
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 == []
|
||||
# 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)
|
||||
# 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)
|
||||
# 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]
|
||||
# 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]
|
||||
# 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=[]), {**K, "TIME_AUTOMERGE": "on"})
|
||||
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)
|
||||
# pending checks -> silent wait
|
||||
w = run(pr_world(checks=[{"status": "in_progress", "conclusion": None}]), {**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()
|
||||
w = run(wm, K); assert w.issues[1]["state"] == "closed" and w.issues[2]["labels"] == ["ready"]
|
||||
print("ALL OK 3")
|
||||
+171
-2
@@ -1,7 +1,7 @@
|
||||
"""Time: the clock for a GitHub-native agent factory. stdlib only; runs on GitHub-hosted runners.
|
||||
|
||||
python3 .github/time/time.py mention # on issue/comment events: answer @seat [model] [effort]
|
||||
python3 .github/time/time.py heartbeat # on schedule: look at `ready` issues and dispatch (dry-run by default)
|
||||
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
|
||||
@@ -207,6 +207,157 @@ def cursor_launch(prompt: str, *, model: str | None, pr_url: str | None = None,
|
||||
"status": (out.get("run") or {}).get("status") or agent.get("status")}
|
||||
|
||||
|
||||
# ---------- planner: a `spec` issue -> queued cards ----------
|
||||
# Cascade: parse the spec deterministically first; only ask a seat when it isn't already card-shaped.
|
||||
|
||||
CARD_LINE = re.compile(r"^\s*[-*]\s*(?:\[ \]\s*)?(?P<title>.+?)\s+(?:—|--|-|\|)\s*DONE:\s*(?P<done>.+)$", re.I)
|
||||
PLAN_PROMPT = """Split this spec into small cards, one pull request each, in build order.
|
||||
Reply with ONLY a JSON list: [{"title": "...", "body": "what to change, as a short list", "done": "what finished means"}]"""
|
||||
|
||||
|
||||
def parse_spec(body: str) -> list[dict]:
|
||||
"""`- title — DONE: x` lines, or `### title` sections that contain a `DONE:` line."""
|
||||
cards = [{"title": m["title"].strip(), "body": "", "done": m["done"].strip()}
|
||||
for m in map(CARD_LINE.match, body.splitlines()) if m]
|
||||
if cards:
|
||||
return cards
|
||||
for sec in re.split(r"^#{2,3}\s+", body, flags=re.M)[1:]:
|
||||
title, _, rest = sec.partition("\n")
|
||||
m = re.search(r"^\s*DONE:\s*(.+)$", rest, re.M | re.I)
|
||||
if m:
|
||||
cards.append({"title": title.strip(), "body": rest[:m.start()].strip(), "done": m.group(1).strip()})
|
||||
return cards
|
||||
|
||||
|
||||
def plan_spec(spec: dict) -> None:
|
||||
n = spec["number"]
|
||||
cards = parse_spec(spec.get("body") or "")
|
||||
if not cards and os.environ.get("VENICE_API_KEY"):
|
||||
seat = (os.environ.get("TIME_PLANNER") or "kimi").split()[0]
|
||||
raw = think(seat, None, None, PLAN_PROMPT, f"# {spec['title']}\n{(spec.get('body') or '')[:20000]}")
|
||||
try:
|
||||
cards = json.loads(raw[raw.index("["): raw.rindex("]") + 1])
|
||||
except ValueError:
|
||||
cards = []
|
||||
if not cards:
|
||||
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")
|
||||
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"]
|
||||
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"]})
|
||||
|
||||
|
||||
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]
|
||||
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]
|
||||
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):
|
||||
gh("DELETE", f"/repos/{REPO}/issues/{card['number']}/labels/queued")
|
||||
gh("POST", f"/repos/{REPO}/issues/{card['number']}/labels", {"labels": ["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"
|
||||
|
||||
|
||||
def verdict(pr: dict, since: str) -> str | None:
|
||||
"""Latest reviewer verdict after `since`: a comment starting Approve/Object, or a GitHub review."""
|
||||
found = []
|
||||
for c in gh("GET", f"/repos/{REPO}/issues/{pr['number']}/comments?per_page=100"):
|
||||
m = VERDICT.match(c.get("body") or "")
|
||||
trusted = c["user"].get("type") == "Bot" or c.get("author_association") in TRUSTED # public repos: strangers can comment
|
||||
if m and trusted 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:
|
||||
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()]
|
||||
files = [f["filename"] for f in gh("GET", f"/repos/{REPO}/pulls/{pr_number}/files?per_page=100")]
|
||||
return [f for f in files if any(f.startswith(p) or p in f for p in paths)]
|
||||
|
||||
|
||||
def checks(sha: str) -> str:
|
||||
"""pass | pending | fail | none, over every check run on the PR head."""
|
||||
runs = gh("GET", f"/repos/{REPO}/commits/{sha}/check-runs?per_page=100").get("check_runs", [])
|
||||
if not runs:
|
||||
return "none"
|
||||
if any(r["status"] != "completed" for r in runs):
|
||||
return "pending"
|
||||
return "pass" if all(r["conclusion"] in ("success", "neutral", "skipped") for r in runs) else "fail"
|
||||
|
||||
|
||||
def land(card: dict, last: dict) -> None:
|
||||
"""Move a card whose PR is up toward merged. Silent while nothing has changed."""
|
||||
n, pr = card["number"], gh("GET", f"/repos/{REPO}/pulls/{last['pr']}")
|
||||
if pr.get("merged_at"):
|
||||
if card.get("state", "open") == "open":
|
||||
gh("PATCH", f"/repos/{REPO}/issues/{n}", {"state": "closed"})
|
||||
comment(n, f"⏱️ Time: PR #{pr['number']} merged. Card done.", {"beat": "merged", "pr": pr["number"]})
|
||||
return
|
||||
if pr["state"] == "closed" or (os.environ.get("TIME_AUTOMERGE") or "off").strip() != "on":
|
||||
return
|
||||
v = verdict(pr, last["at"])
|
||||
if v is None:
|
||||
return
|
||||
if v == "object":
|
||||
if last["beat"] != "needs-human":
|
||||
label(pr["number"], "needs-human")
|
||||
comment(n, f"⏱️ Time: the reviewer objected on PR #{pr['number']}. Over to a human (or comment `/time retry` "
|
||||
"to send it back to the worker).", {"beat": "needs-human", "pr": pr["number"]})
|
||||
return
|
||||
if (hit := touches_protected(pr["number"])):
|
||||
if last["beat"] != "needs-human":
|
||||
label(pr["number"], "needs-human")
|
||||
comment(n, f"⏱️ Time: approved, but PR #{pr['number']} touches protected paths ({', '.join(hit[:5])}), so a "
|
||||
"human merges this one.", {"beat": "needs-human", "pr": pr["number"]})
|
||||
return
|
||||
c = checks(pr["head"]["sha"])
|
||||
need = (os.environ.get("TIME_REQUIRE_CHECKS") or "on").strip() != "off"
|
||||
if c == "pending" or (c == "none" and need):
|
||||
if c == "none" and last["beat"] != "no-checks":
|
||||
comment(n, f"⏱️ Time: approved, but PR #{pr['number']} has no checks, and I only merge on green. Add a CI "
|
||||
"workflow (or set `TIME_REQUIRE_CHECKS=off`).", {"beat": "no-checks", "pr": pr["number"]})
|
||||
return
|
||||
if c == "fail":
|
||||
if last["beat"] != "checks-failed":
|
||||
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"})
|
||||
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]})
|
||||
|
||||
|
||||
# ---------- heartbeat ----------
|
||||
|
||||
def beats_of(comments: list[dict]) -> list[dict]:
|
||||
@@ -269,6 +420,19 @@ 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]:
|
||||
try:
|
||||
plan_spec(spec)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"spec #{spec['number']}: {type(e).__name__}: {e}")
|
||||
for _ in range(3): # a merge can free the slot for the next card; dispatch it in the same run
|
||||
tick()
|
||||
if promote_next() is None:
|
||||
break
|
||||
return 0
|
||||
|
||||
|
||||
def tick() -> None:
|
||||
mode = (os.environ.get("TIME_MODE") or "dry-run").strip()
|
||||
cap = int(os.environ.get("TIME_DAILY_CAP") or 5)
|
||||
stuck_h = float(os.environ.get("TIME_STUCK_HOURS") or 6)
|
||||
@@ -317,6 +481,9 @@ def cmd_heartbeat() -> int:
|
||||
comment(n, f"⏱️ Time: dispatched to a Cursor cloud agent{link}. It will open a PR that closes #{n}; "
|
||||
"I'll link it here when it appears.", {"beat": "dispatched", "launched": True, **r, "model": worker_model})
|
||||
continue
|
||||
if state in ("pr", "needs-human", "no-checks", "checks-failed"):
|
||||
land(card, last)
|
||||
continue
|
||||
if state in ("dispatched", "stuck"):
|
||||
found = linked_prs(prs, n)
|
||||
if found:
|
||||
@@ -340,7 +507,6 @@ def cmd_heartbeat() -> int:
|
||||
print(f"#{n}: {type(e).__name__}: {e}")
|
||||
comment(n, f"⏱️ Time: the worker call failed: `{str(e)[:300]}`. Comment `/time retry` to try again.",
|
||||
{"beat": "error"})
|
||||
return 0
|
||||
|
||||
|
||||
# ---------- doctor ----------
|
||||
@@ -358,6 +524,9 @@ def cmd_doctor() -> int:
|
||||
row("TIME_MODE", True, os.environ.get("TIME_MODE") or "dry-run (default)")
|
||||
row("TIME_DAILY_CAP", True, os.environ.get("TIME_DAILY_CAP") or "5 (default)")
|
||||
row("TIME_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}`")
|
||||
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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
name: ci
|
||||
# Time's own tests. Also the check that lets Time auto-merge changes to itself.
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: python3 .github/time/test_time.py
|
||||
@@ -21,3 +21,4 @@ jobs:
|
||||
TIME_MODE: ${{ vars.TIME_MODE }}
|
||||
TIME_DAILY_CAP: ${{ vars.TIME_DAILY_CAP }}
|
||||
TIME_REVIEW: ${{ vars.TIME_REVIEW }}
|
||||
TIME_AUTOMERGE: ${{ vars.TIME_AUTOMERGE }}
|
||||
@@ -1,21 +1,32 @@
|
||||
name: heartbeat
|
||||
# Time's clock: hourly, and right away when a card gets the `ready` label (dry-run until TIME_MODE=live).
|
||||
# Time's clock: hourly, right away when a card gets `ready` or a spec gets `spec`, and when a PR is reviewed
|
||||
# (dry-run until TIME_MODE=live; merges only with TIME_AUTOMERGE=on).
|
||||
on:
|
||||
schedule:
|
||||
- cron: "7 * * * *"
|
||||
workflow_dispatch:
|
||||
issues:
|
||||
types: [labeled]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write # squash-merge approved, green PRs
|
||||
issues: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
concurrency:
|
||||
group: heartbeat
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
tick:
|
||||
if: github.event_name != 'issues' || github.event.label.name == 'ready'
|
||||
# issues: only `ready`/`spec` labels. comments: only on PRs, only a reviewer verdict (Approve/Object).
|
||||
if: >-
|
||||
(github.event_name != 'issues' || contains(fromJSON('["ready","spec"]'), github.event.label.name)) &&
|
||||
(github.event_name != 'issue_comment' || (github.event.issue.pull_request &&
|
||||
(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
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -32,3 +43,8 @@ jobs:
|
||||
TIME_CURSOR_MODEL: ${{ vars.TIME_CURSOR_MODEL }}
|
||||
TIME_REVIEW: ${{ vars.TIME_REVIEW }}
|
||||
TIME_STUCK_HOURS: ${{ vars.TIME_STUCK_HOURS }}
|
||||
TIME_AUTOMERGE: ${{ vars.TIME_AUTOMERGE }}
|
||||
TIME_REQUIRE_CHECKS: ${{ vars.TIME_REQUIRE_CHECKS }}
|
||||
TIME_PROTECTED: ${{ vars.TIME_PROTECTED }}
|
||||
TIME_PARALLEL: ${{ vars.TIME_PARALLEL }}
|
||||
TIME_PLANNER: ${{ vars.TIME_PLANNER }}
|
||||
@@ -0,0 +1,47 @@
|
||||
name: update
|
||||
# Pull a fresh Time engine from the central repo and open a PR with the diff (weekly, or run it by hand).
|
||||
# Upstream: TIME_UPSTREAM (default teahubxyz/time) at TIME_CHANNEL (default main; pin a tag like v1 for stability).
|
||||
# Workflow files can't be changed by Actions' own token, so if those differ the PR lists them for a human to copy.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 9 * * 1"
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
update:
|
||||
if: github.repository != (vars.TIME_UPSTREAM || 'teahubxyz/time')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
env:
|
||||
UPSTREAM: ${{ vars.TIME_UPSTREAM || 'teahubxyz/time' }}
|
||||
CHANNEL: ${{ vars.TIME_CHANNEL || 'main' }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: git clone -q --depth 1 --branch "$CHANNEL" "https://github.com/$UPSTREAM" /tmp/up
|
||||
- name: copy engine, open PR if changed
|
||||
run: |
|
||||
set -e
|
||||
for f in .github/time/time.py .github/time/test_time.py .github/time/seats.json .claude/skills/time/SKILL.md; do
|
||||
mkdir -p "$(dirname "$f")"; cp "/tmp/up/$f" "$f"
|
||||
done
|
||||
python3 .github/time/test_time.py > /dev/null
|
||||
wf=$(for f in /tmp/up/.github/workflows/*.yml; do b=.github/workflows/$(basename "$f"); cmp -s "$f" "$b" || echo "- \`$b\`"; done)
|
||||
if git diff --quiet; then
|
||||
echo "Time engine is up to date." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
[ -n "$wf" ] && printf 'Workflow files differ upstream, copy by hand:\n%s\n' "$wf" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
sha=$(git -C /tmp/up rev-parse --short HEAD)
|
||||
branch="time-update-$sha"
|
||||
git switch -c "$branch"
|
||||
git -c user.name=time -c user.email=time@users.noreply.github.com commit -qam "Update Time to $UPSTREAM@$sha"
|
||||
git push -q origin "$branch"
|
||||
body="Fresh Time engine from \`$UPSTREAM@$CHANNEL\` ($sha). Offline tests pass."
|
||||
[ -n "$wf" ] && body="$body
|
||||
|
||||
These workflow files differ upstream. Actions can't edit workflows, so copy them by hand from https://github.com/$UPSTREAM/tree/$CHANNEL/.github/workflows:
|
||||
$wf"
|
||||
gh pr create --title "Update Time ($sha)" --body "$body" --head "$branch"
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -5,7 +5,9 @@ A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are t
|
||||
- **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 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.
|
||||
- **Mentions** (`mention.yml`): `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply (Venice).
|
||||
- **Hands-off** (`TIME_AUTOMERGE=on`): label an issue `spec` and Time splits it into cards that run one at a time. A card's PR merges itself once the reviewer approves and checks are green, then the next card starts. Objections, red checks and protected paths (workflows, deploy, secrets) go to a human.
|
||||
- **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings and keys.
|
||||
- **Update** (`update.yml`, weekly or manual): opens a PR with the latest engine from teahubxyz/time, so every project stays on one Time instead of drifting copies.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
Reference in new issue
Block a user