Files
time/.github/time/housekeeping.py
T
Claude e69fbd32eb
ci / test (pull_request) Successful in 45s
heartbeat / tick (pull_request_review) Successful in 15s
Router: record each pick's outcome, so a learning policy has data
Part of #72, follows #168. The router's policy is still one function
(`router.score`); a restless bandit needs rewards, and nothing recorded
what a pick turned into. This card is the data, not the policy.

- `router.py`: the shape of an outcome and the arithmetic over the log.
  `picks()` reads every live pick out of Astra's beats (a card picked
  twice keeps its first pick; a dry run picked nothing), `outcome()`
  settles one pick into a line and returns None while its card is open,
  `read_outcomes()`/`summary()`/`outcome_lines()` are the read side.
- `housekeeping.py` step 4: one line per settled pick appended to
  `time/ledger/router-outcomes.jsonl` in one commit, dry-run writes
  nothing. A pick already in the file is never written twice.
- `astra.py`: one summary line per kind (picks, merged share, median
  days, median $) over the ranked table, so the owner sees what a kind
  returns before changing its weight.

Two notes on what the card asked for:

- Spend proxy: the card offered the card's worker beats' `usd` or the
  runs' time window. Beats carry no `usd` today, so this uses the runs'
  time window — each `dispatched` beat until the next beat on the card
  — which attributes a run's spend to the card that ran it instead of
  charging it the whole factory's spend over the days it was open.
  Review and planning calls on the card are not counted; two cards
  whose runs overlap are each charged their own window. Documented in
  `time/ledger/README.md`.
- The line carries `pr` as well as the shape the card listed: the card
  asks for the PR number and the line is the only place to keep it.
- The file lives under `time/ledger/`, as the card says, since that is
  the one path `ci.yml` ignores and the sweep appends to it daily. No
  workflow change is needed: `housekeeping.yml` already passes
  `GITHUB_TOKEN` (the push) and `TIME_VENTURE_MEASURE_DAYS` (day 7).

A venture pick waits until its venture's day 7 has passed and then
carries the signups counted at that day; with no registry entry for the
card the line goes in with no count rather than waiting forever.

Tests: `test_housekeeping.py` covers a merged pick, an unmerged pick, a
pick still in flight (no line), idempotence, the venture's day-7 count
and dry-run; `test_time.py` section 172 covers the pure functions, the
summary's arithmetic and the log's header. All three suites green.

Closes #172

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198aoK6ddbLN4d6q8r3J7vg
2026-10-19 19:02:04 +00:00

413 lines
19 KiB
Python

"""Housekeeping: the daily sweep. Deterministic on purpose: no model decides what to delete or close. stdlib only.
python3 .github/time/housekeeping.py # daily (housekeeping.yml), or by hand
Five steps, each idempotent (a second run finds nothing to do) and each logged:
1. branches delete every branch whose PR merged into the default branch. Never the default branch, `gh-pages`, a
protected branch, a branch with an open PR, or a branch that has commits newer than its merge.
2. signals close `signal` issues whose venture's measuring window (TIME_VENTURE_MEASURE_DAYS, default 7) has passed
with no signups (no 👍 on the issue, no early-access issue for the venture), with one closing line.
3. milestones close milestones with no open issues and at least one closed one.
4. outcomes append one line to `time/ledger/router-outcomes.jsonl` per router pick that has settled (#172): merged
or not, its PR, days from pick to close, the spend booked while its worker ran, and a venture's signups
at day 7. A pick whose card is still open is in flight and waits for a later sweep.
5. stale list, never act: `needs-human` untouched for 3 days, open PRs idle 7 days, cards parked after their
automatic retries, cards whose last beat is `error` for 2 days. One comment on the Astra log; none if empty.
Then one summary line on the Astra log. Only TIME_MODE=live changes anything; anything else (the default) prints what it
would do and writes nothing, comments included. A step that fails is logged and the others still run (exit code 1).
"""
from __future__ import annotations
import base64
import datetime as dt
import importlib.util
import json
import os
import re
import sys
import urllib.parse
from pathlib import Path
HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
tm = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tm)
_rspec = importlib.util.spec_from_file_location("router", HERE / "router.py")
router = importlib.util.module_from_spec(_rspec)
_rspec.loader.exec_module(router)
NEVER = {"gh-pages"} # besides the default branch and protected ones
LOG_TITLE = "Astra log"
LOG_FALLBACK = 57 # the Astra log's number here, if it cannot be found by title
NEEDS_HUMAN_DAYS, PR_IDLE_DAYS, ERROR_DAYS = 3, 7, 2
VENTURES = "ventures/ventures.json" # the venture registry (venture.py writes it), on the Pages branch
PAGES_BRANCH = "gh-pages"
def log(msg: str) -> None:
print(msg, flush=True)
def repo() -> str:
return f"/repos/{tm.REPO}"
def paged(path: str, pages: int = 40) -> list[dict]:
sep = "&" if "?" in path else "?"
out: list[dict] = []
for page in range(1, pages + 1):
batch = tm.gh("GET", f"{path}{sep}limit=50&page={page}") or [] # Gitea answers an issue with no reactions with null
out += batch
if len(batch) < 50:
break
return out
def parse(ts: str | None) -> dt.datetime | None:
if not ts:
return None
try:
d = dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
except ValueError:
return None
return d if d.tzinfo else d.replace(tzinfo=dt.timezone.utc)
def age_days(ts: str | None, now: dt.datetime) -> float:
d = parse(ts)
return (now - d).total_seconds() / 86400 if d else 0.0
def stamp(ts: str | dt.datetime | None) -> str:
"""A timestamp written the way the ledger writes them, so one string comparison orders beats, ledger lines and
windows alike whatever offset the API sent."""
d = ts if isinstance(ts, dt.datetime) else parse(ts)
return d.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if d else ""
def measure_days() -> int:
"""The venture measuring window: how long a signal issue is given, and the day a pick's signups are counted on."""
return int(float(os.environ.get("TIME_VENTURE_MEASURE_DAYS") or 7))
def repo_file(path: str, ref: str = "") -> str | None:
"""A file in the repo, read through the API (the workflow checks out only `.github/time`). None if there is none."""
try:
f = tm.gh("GET", f"{repo()}/contents/{urllib.parse.quote(path)}" + (f"?ref={ref}" if ref else ""))
except RuntimeError as e:
if "-> 404" in str(e):
return None
raise
return base64.b64decode(f.get("content") or "").decode(errors="replace")
def labelled(name: str, query: str = "state=open") -> list[dict]:
"""Issues (and PRs) that carry `name`. Each row is checked too: Gitea ignores a filter on a label the repo does not
have and returns everything, which on the first live run listed every open issue as a parked card."""
return [i for i in paged(f"{repo()}/issues?{query}&labels={urllib.parse.quote(name)}") if tm.has_label(i, name)]
def same_repo(pr: dict) -> bool:
return ((pr.get("head") or {}).get("repo") or {}).get("full_name", tm.REPO) == tm.REPO # a fork's branch is not ours
# ---------- 1. branches ----------
def step_branches(live: bool) -> int:
default = tm.default_branch()
open_heads = {p["head"]["ref"] for p in paged(f"{repo()}/pulls?state=open") if same_repo(p)}
merged: dict[str, dt.datetime | None] = {} # head ref -> when its latest merged PR merged
for p in paged(f"{repo()}/pulls?state=closed&sort=recentupdate"):
if (p.get("merged") or p.get("merged_at")) and (p.get("base") or {}).get("ref") == default and same_repo(p):
at = parse(p.get("merged_at"))
ref = p["head"]["ref"]
merged[ref] = max([d for d in (merged.get(ref), at) if d], default=None)
deleted = 0
for b in paged(f"{repo()}/branches"):
name = b["name"]
if name == default or name in NEVER or b.get("protected") or name not in merged:
continue
if name in open_heads:
log(f"branches: keep {name} (open PR)")
continue
tip = parse((b.get("commit") or {}).get("timestamp"))
if tip and merged[name] and tip > merged[name]:
log(f"branches: keep {name} (commits after its merge)")
continue
if not live:
log(f"branches: would delete {name}")
continue
try:
tm.gh("DELETE", f"{repo()}/branches/{urllib.parse.quote(name, safe='')}")
except RuntimeError as e:
if "-> 404" not in str(e): # already gone is fine
raise
continue
log(f"branches: deleted {name}")
deleted += 1
return deleted
# ---------- 2. signal issues ----------
def person(user: dict | None) -> str | None:
login = ((user or {}).get("login") or "").lower()
if not login or (user or {}).get("type") == "Bot" or login in (tm.REPO.split("/")[0].lower(), tm.TIME_USER.lower(), tm.TIME_BOT.lower()):
return None
return login
def signup_people(until: str = "9999") -> dict[str, set[str]]:
"""venture slug -> distinct people who opened an early-access issue for it before `until` (all of them by default)."""
out: dict[str, set[str]] = {}
for i in labelled("signup", "state=all&type=issues"):
m = re.search(r"^###\s*Venture\s*$\n+(.*?)(?=^###\s|\Z)", i.get("body") or "", re.M | re.S)
who = person(i.get("user"))
if m and who and m.group(1).strip() and stamp(i.get("created_at")) < until:
out.setdefault(m.group(1).strip().lower(), set()).add(who)
return out
def step_signals(live: bool, now: dt.datetime) -> int:
days = measure_days()
due = [i for i in labelled("signal", "state=open&type=issues")
if not i.get("pull_request") and age_days(i.get("created_at"), now) >= days]
if not due:
return 0
signups = signup_people()
closed = 0
for i in due:
n = i["number"]
m = re.search(r"[?&]venture=([\w.-]+)", i.get("body") or "")
if not m:
log(f"signals: keep #{n} (cannot tell which venture it belongs to)")
continue
people = set(signups.get(m.group(1).lower(), set()))
people |= {w for r in paged(f"{repo()}/issues/{n}/reactions") if r.get("content") in ("+1", "👍")
and (w := person(r.get("user")))}
if people:
log(f"signals: keep #{n} ({len(people)} signup(s))")
continue
if not live:
log(f"signals: would close #{n} (0 signups after {days} days)")
continue
tm.gh("PATCH", f"{repo()}/issues/{n}", {"state": "closed"})
tm.comment(n, f"Closed by housekeeping: 0 signups in the {days}-day measuring window.", {"housekeeping": "signal"})
log(f"signals: closed #{n}")
closed += 1
return closed
# ---------- 3. milestones ----------
def step_milestones(live: bool) -> int:
closed = 0
for m in paged(f"{repo()}/milestones?state=open"):
if int(m.get("open_issues") or 0) or not int(m.get("closed_issues") or 0):
continue
if not live:
log(f"milestones: would close {m.get('title')!r}")
continue
tm.gh("PATCH", f"{repo()}/milestones/{m['id']}", {"state": "closed"})
log(f"milestones: closed {m.get('title')!r}")
closed += 1
return closed
# ---------- 4. the router's outcomes ----------
# What a pick turned into, so a learning policy (a restless bandit) has rewards to read. The ledger has no card field,
# so a card's spend is the dollars booked while its worker ran: each `dispatched` beat until the next beat on the card
# (the chosen proxy of the two the card offered; review and planning calls on the card are not counted).
def run_windows(beats: list[dict], settled: str) -> list[tuple[str, str]]:
"""The card's worker runs as [start, end) in UTC: a `dispatched` beat until the next beat on the card, the last one
until the card settled."""
at = [stamp(b.get("at")) for b in beats]
out = [(at[k], next((s for s in at[k + 1:] if s), settled)) for k, b in enumerate(beats)
if b.get("beat") == "dispatched" and at[k]]
return [(a, z) for a, z in out if a < z]
def months(since: str, until: str) -> list[str]:
"""The ledger months a span touches, `2026-10` first."""
out, m = [], since[:7]
while m <= until[:7] and len(out) < 24:
out.append(m)
m = f"{int(m[:4]) + (m[5:] == '12')}-{int(m[5:]) % 12 + 1:02d}"
return out
def window_usd(windows: list[tuple[str, str]]) -> float:
"""Dollars the ledger booked inside any of these windows."""
if not windows:
return 0.0
rows = [r for m in months(min(a for a, _ in windows), max(z for _, z in windows)) for r in tm.ledger_lines(m)]
return sum(r.get("usd") or 0 for r in rows if any(a <= str(r.get("at") or "") < z for a, z in windows))
def ventures() -> list[dict]:
"""The venture registry on the Pages branch. Read-only here; unreadable is an empty studio."""
try:
reg = json.loads(repo_file(VENTURES, PAGES_BRANCH) or "{}")
except (ValueError, RuntimeError) as e:
log(f"outcomes: venture registry unreadable: {str(e)[:120]}")
return []
return [v for v in (reg.get("ventures") if isinstance(reg, dict) else None) or [] if isinstance(v, dict)]
def signups_by(v: dict, until: str) -> int:
"""Distinct people (not bots, not the owner) who asked for early access for this venture or 👍'd its signal issue
before `until`. The slug match is the signal step's, so a signup that named the venture some other way is missed."""
people = set(signup_people(until).get(str(v.get("slug") or "").lower(), set()))
if v.get("signal"):
people |= {w for r in paged(f"{repo()}/issues/{v['signal']}/reactions") if r.get("content") in ("+1", "👍")
and stamp(r.get("created_at")) < until and (w := person(r.get("user")))}
return len(people)
def day_after(day: str, days: int) -> str:
"""Midnight `days` days after a date, as a ledger timestamp; "" when the date is not one."""
try:
return (dt.date.fromisoformat(day[:10]) + dt.timedelta(days=days)).strftime("%Y-%m-%dT00:00:00Z")
except ValueError:
return ""
def step_outcomes(live: bool, now: dt.datetime) -> int:
"""One line per settled pick in `router.OUTCOMES`, appended in one commit. A pick already in the file is never
written twice; a pick whose card is still open, or whose venture has not reached day 7, waits for a later sweep."""
picks = router.picks(tm.beats_of(paged(f"{repo()}/issues/{log_issue()}/comments")))
if not picks:
return 0
have = {r["n"] for r in router.read_outcomes(repo_file(router.OUTCOMES))}
rows, registry = [], None
for n, p in sorted(picks.items()):
if n in have:
continue
try:
card = tm.gh("GET", f"{repo()}/issues/{n}")
except RuntimeError as e:
if "-> 404" not in str(e):
raise
log(f"outcomes: skip #{n} (no such card)")
continue
if (card.get("state") or "open") != "closed":
log(f"outcomes: keep #{n} (still in flight)")
continue
settled = stamp(card.get("closed_at")) or stamp(card.get("updated_at")) or stamp(now)
beats = tm.beats_of(paged(f"{repo()}/issues/{n}/comments"))
signups = None
if p["kind"] == "venture":
registry = ventures() if registry is None else registry
v = next((x for x in registry if x.get("card") == n), None)
day7 = day_after(str(v.get("launched") or v.get("planned") or p["picked"]), measure_days()) if v else ""
if day7 and day7 > stamp(now):
log(f"outcomes: keep #{n} (its venture's day {measure_days()} is {day7[:10]})")
continue
signups = signups_by(v, day7 or "9999") if v else None
row = router.outcome(p, card, beats, window_usd(run_windows(beats, settled)), signups)
if row:
rows.append(row)
log(f"outcomes: #{n} settled ({'merged' if row['merged'] else 'not merged'}, {row['days']} day(s), "
f"${row['usd'] or 0:.2f}" + (f", {row['signups']} signup(s)" if row["signups"] is not None else "") + ")")
if not rows:
return 0
if not live:
log(f"outcomes: would record {len(rows)} settled pick(s)")
return 0
tm.git_commit(tm.default_branch(), {}, f"router: {len(rows)} settled pick(s)",
append={router.OUTCOMES: "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in rows).encode()})
log(f"outcomes: recorded {len(rows)} settled pick(s) in {router.OUTCOMES}")
return len(rows)
# ---------- 5. the stale list ----------
def stale(now: dt.datetime) -> dict[str, list[tuple[int, float, str]]]:
"""category -> [(number, age in days, title)]. Read-only."""
out: dict[str, list[tuple[int, float, str]]] = {}
def add(cat: str, number: int, age: float, title: str) -> None:
out.setdefault(cat, []).append((number, age, title))
for i in labelled("needs-human"): # issues and PRs alike
age = age_days(i.get("updated_at") or i.get("created_at"), now)
if age > NEEDS_HUMAN_DAYS:
add(f"`needs-human`, untouched for over {NEEDS_HUMAN_DAYS} days", i["number"], age, i.get("title", ""))
for p in paged(f"{repo()}/pulls?state=open"):
age = age_days(p.get("updated_at") or p.get("created_at"), now)
if age >= PR_IDLE_DAYS:
add(f"open PRs idle {PR_IDLE_DAYS} days or more", p["number"], age, p.get("title", ""))
for i in labelled("parked", "state=open&type=issues"):
add("parked cards", i["number"], age_days(i.get("updated_at") or i.get("created_at"), now), i.get("title", ""))
for card in labelled("ready", "state=open&type=issues"): # last beat: error for a while, or parked
beats = [b for b in tm.beats_of(paged(f"{repo()}/issues/{card['number']}/comments")) if b["beat"] not in ("blocked", "capped")]
last = beats[-1] if beats else None
if not last:
continue
age = age_days(last["at"], now)
if last["beat"] == "parked":
add("parked cards", card["number"], age, card.get("title", ""))
elif last["beat"] == "error" and age > ERROR_DAYS:
add(f"`error` cards older than {ERROR_DAYS} days", card["number"], age, card.get("title", ""))
for cat, rows in out.items(): # a card can be listed under two names (parked label and beat): once per category
out[cat] = list({r[0]: r for r in rows}.values())
return out
def stale_text(found: dict[str, list[tuple[int, float, str]]]) -> str:
lines = ["🧹 Housekeeping: the stale list (nothing was changed; for the steward)", ""]
for cat, rows in found.items():
lines.append(f"- {cat}: " + "; ".join(f"#{n} {t[:60]} ({a:.0f}d)" for n, a, t in sorted(rows)))
return "\n".join(lines)
def log_issue() -> int:
for i in labelled("astra", "state=open&type=issues"):
if i.get("title") == LOG_TITLE:
return i["number"]
return LOG_FALLBACK
# ---------- the run ----------
def main() -> int:
live = (os.environ.get("TIME_MODE") or "dry-run").strip() == "live"
now = dt.datetime.now(dt.timezone.utc)
log(f"housekeeping {now:%Y-%m-%dT%H:%MZ} mode={'live' if live else 'dry-run'}")
failed = 0
done = {"branches": 0, "signals": 0, "milestones": 0, "outcomes": 0}
found: dict = {}
for name, step in (("branches", lambda: step_branches(live)), ("signals", lambda: step_signals(live, now)),
("milestones", lambda: step_milestones(live)), ("outcomes", lambda: step_outcomes(live, now))):
try:
done[name] = step()
except Exception as e: # noqa: BLE001 one step must not stop the others
failed += 1
log(f"{name}: failed: {type(e).__name__}: {str(e)[:200]}")
try:
found = stale(now)
except Exception as e: # noqa: BLE001
failed += 1
log(f"stale: failed: {type(e).__name__}: {str(e)[:200]}")
count = len({r[0] for rows in found.values() for r in rows})
if found:
log(stale_text(found))
line = (f"🧹 Housekeeping: {done['branches']} branch(es) deleted, {done['signals']} signal issue(s) closed, "
f"{done['milestones']} milestone(s) closed, {done['outcomes']} router pick(s) settled, {count} stale."
+ (f" {failed} step(s) failed; see the run log." if failed else ""))
log(line)
if live:
try:
target = log_issue()
if found:
tm.comment(target, stale_text(found), {"housekeeping": "stale", "count": count})
tm.comment(target, line, {"housekeeping": "run", **done, "stale": count})
except Exception as e: # noqa: BLE001
failed += 1
log(f"log: failed: {type(e).__name__}: {str(e)[:200]}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())