From f4058061e0ba5fb4bc64fe9bcbff17456089ff15 Mon Sep 17 00:00:00 2001 From: Time Date: Sun, 18 Oct 2026 04:27:12 +0000 Subject: [PATCH] Daily housekeeping: prune merged branches, close spent signals and empty milestones, list the stale (#115) Co-authored-by: Time --- .agents/skills/housekeeping/SKILL.md | 72 ++++++++ .github/time/housekeeping.py | 265 +++++++++++++++++++++++++++ .github/time/test_housekeeping.py | 185 +++++++++++++++++++ 3 files changed, 522 insertions(+) create mode 100644 .agents/skills/housekeeping/SKILL.md create mode 100644 .github/time/housekeeping.py create mode 100644 .github/time/test_housekeeping.py diff --git a/.agents/skills/housekeeping/SKILL.md b/.agents/skills/housekeeping/SKILL.md new file mode 100644 index 0000000..0df826a --- /dev/null +++ b/.agents/skills/housekeeping/SKILL.md @@ -0,0 +1,72 @@ +--- +name: housekeeping +description: The daily sweep of this repo: prunes merged branches, closes spent signal issues and empty milestones, and lists what is stale. Use when asked what housekeeping did, why a branch or issue was closed, how to run the sweep by hand, or what to do with the stale list. +--- + +# Housekeeping + +Deterministic: `.github/time/housekeeping.py` decides, never a model. Every step is idempotent, so a second run finds nothing to do. + +## Trigger + +- `housekeeping.yml`: daily, a jittered minute after the Astra tick (13:13 UTC), and `workflow_dispatch`. It runs + `python3 .github/time/housekeeping.py` with the heartbeat's env, as the `time` user. +- `TIME_MODE=live` makes it act. Anything else (the default) is a dry run: it prints `would delete ...` / `would close ...` + in the run log and changes and comments nothing. +- By hand: Actions โ†’ housekeeping โ†’ Run workflow, or locally with `GITHUB_REPOSITORY`, `GITHUB_TOKEN`, `TIME_TOKEN` set. + +## What the sweep does + +1. **Branches.** Deletes every branch whose PR merged into the default branch (`time/card-*`, `ceo/*`, `labs/*`, anything + with a merged PR; merges made through the MCP keep their head branch, Time's own merges delete it). Never the default + branch, `gh-pages`, a protected branch, a branch with an open PR, or a branch that has commits after its merge. +2. **Signal issues.** Closes `signal` issues ("want the next version?") older than `TIME_VENTURE_MEASURE_DAYS` (default 7) + whose venture has no signups: no ๐Ÿ‘ from a person and no early-access issue for its slug. One closing line names the count. + Issues with signups are left to the venture loop. +3. **Milestones.** Closes milestones with no open issues and at least one closed one. An empty, never-used milestone stays. +4. **Stale list.** No action, one comment on the Astra log (#57): `needs-human` untouched for over 3 days, open PRs idle + 7 days or more, cards parked after their automatic retries, `error` cards older than 2 days. An empty list posts no comment. + +Each run ends with one line on the Astra log: branches deleted, issues closed, milestones closed, stale count. A step that +fails is logged and the others still run; the job exits 1. + +## What the steward does with the stale list + +Housekeeping never acts on it. Read it on #57 once a day and for each entry either: + +- **`needs-human` card or PR:** decide it (merge, close, or comment `/time retry` to send it back to the worker) and remove the label. +- **Idle PR:** merge it, push it, or close it. A PR nobody wants closes; its branch goes at the next sweep. +- **Parked card:** read why it stopped, fix the card (smaller, clearer `DONE:`) or close it. +- **`error` card:** comment `/time retry`, or close it if the error is not worth another try. + +## The workflow + +The workflow file is the CEO's merge. It is the heartbeat's job with this step in place of `time.py heartbeat`: + +```yaml +name: housekeeping +on: + schedule: + - cron: "17 13 * * *" # a few minutes after the Astra tick (13:13) + workflow_dispatch: +concurrency: + group: housekeeping + cancel-in-progress: false +jobs: + sweep: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + sparse-checkout: .github/time + persist-credentials: false + - run: python3 .github/time/housekeeping.py + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # branch and issue writes go out as the `time` user + TIME_MODE: ${{ vars.TIME_MODE }} + TIME_VENTURE_MEASURE_DAYS: ${{ vars.TIME_VENTURE_MEASURE_DAYS }} +``` + +Tests: `python3 .github/time/test_housekeeping.py` (offline fake forge). diff --git a/.github/time/housekeeping.py b/.github/time/housekeeping.py new file mode 100644 index 0000000..670e439 --- /dev/null +++ b/.github/time/housekeeping.py @@ -0,0 +1,265 @@ +"""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 + +Four 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. 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 datetime as dt +import importlib.util +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) + +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 + + +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}") + 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 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() -> dict[str, set[str]]: + """venture slug -> distinct people who opened an early-access issue for it.""" + out: dict[str, set[str]] = {} + for i in paged(f"{repo()}/issues?state=all&labels=signup&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(): + out.setdefault(m.group(1).strip().lower(), set()).add(who) + return out + + +def step_signals(live: bool, now: dt.datetime) -> int: + days = int(float(os.environ.get("TIME_VENTURE_MEASURE_DAYS") or 7)) + due = [i for i in paged(f"{repo()}/issues?state=open&labels=signal&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 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 paged(f"{repo()}/issues?state=open&labels=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 paged(f"{repo()}/issues?state=open&labels=parked&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 paged(f"{repo()}/issues?state=open&labels=ready&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 paged(f"{repo()}/issues?state=open&labels=astra&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} + found: dict = {} + for name, step in (("branches", lambda: step_branches(live)), ("signals", lambda: step_signals(live, now)), + ("milestones", lambda: step_milestones(live))): + 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, {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()) diff --git a/.github/time/test_housekeeping.py b/.github/time/test_housekeeping.py new file mode 100644 index 0000000..5c458a9 --- /dev/null +++ b/.github/time/test_housekeeping.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Offline tests for housekeeping.py: a fake forge (like test_time.py's World) that records every write.""" +import datetime as dt +import importlib.util +import json +import os +import sys +import urllib.parse +from pathlib import Path + +for _k in [k for k in os.environ if k.startswith("TIME_")] + ["ANTHROPIC_API_KEY", "VENICE_API_KEY", "CURSOR_API_KEY"]: + os.environ.pop(_k, None) +os.environ.update(GITHUB_REPOSITORY="o/r", GITHUB_TOKEN="gh", GITHUB_SERVER_URL="https://forge.test", GITHUB_API_URL="https://forge.test/api/v1") + +spec = importlib.util.spec_from_file_location("hk", Path(__file__).resolve().parent / "housekeeping.py") +hk = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hk) +tm = hk.tm + +NOW = dt.datetime.now(dt.timezone.utc) +iso = lambda days=0: (NOW - dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ") +LOG = 57 + + +class Forge: + def __init__(self, branches=(), prs=(), issues=(), milestones=(), reactions=None, comments=None): + self.branches = [{"name": b} if isinstance(b, str) else b for b in branches] + self.prs = list(prs) + self.issues = {i["number"]: {"state": "open", "labels": [], "created_at": iso(0), "updated_at": iso(0), "body": "", **i} for i in issues} + self.milestones = [dict(m) for m in milestones] + self.reactions = reactions or {} + self.cmts = comments or {} + self.writes = [] # every non-GET call + self.posted = [] # (issue, body) + + def page(self, rows, path): + q = urllib.parse.parse_qs(urllib.parse.urlparse(path).query) + p, n = int(q.get("page", ["1"])[0]), int(q.get("limit", ["50"])[0]) + return rows[(p - 1) * n: p * n] + + def http(self, method, url, token, body=None, timeout=0): + path = url.replace(tm.GH, "") + route = path.split("?")[0] + q = urllib.parse.parse_qs(urllib.parse.urlparse(path).query) + if method != "GET": + self.writes.append((method, route, body)) + if route == "/repos/o/r" and method == "GET": + return {"default_branch": "main"} + if route == "/repos/o/r/branches" and method == "GET": + return self.page(self.branches, path) + if route.startswith("/repos/o/r/branches/") and method == "DELETE": + name = urllib.parse.unquote(route.split("/branches/")[1]) + self.branches = [b for b in self.branches if b["name"] != name] + return {} + if route == "/repos/o/r/pulls": + want = q.get("state", ["open"])[0] + return self.page([p for p in self.prs if p["state"] == want], path) + if route == "/repos/o/r/milestones" and method == "GET": + return self.page([m for m in self.milestones if m.get("state", "open") == "open"], path) + if route.startswith("/repos/o/r/milestones/") and method == "PATCH": + for m in self.milestones: + if m["id"] == int(route.split("/")[-1]): + m.update(body) + return {} + if route.endswith("/reactions"): + return self.page(self.reactions.get(int(route.split("/")[-2]), []), path) + if route.endswith("/comments") and method == "GET": + return self.page(self.cmts.get(int(route.split("/")[-2]), []), path) + if route.endswith("/comments") and method == "POST": + self.posted.append((int(route.split("/")[-2]), body["body"])) + return {} + if route == "/repos/o/r/issues" and method == "GET": + rows = [i for i in self.issues.values() + if (q.get("state", ["open"])[0] in ("all", i["state"])) + and all(l in [x for x in i["labels"]] for l in q.get("labels", [""])[0].split(",") if l)] + return self.page([{**i, "labels": [{"name": l} for l in i["labels"]]} for i in rows], path) + if route.startswith("/repos/o/r/issues/") and method == "PATCH": + self.issues[int(route.split("/")[-1])].update(body) + return {} + raise AssertionError(f"unexpected {method} {path}") + + def run(self, mode="live", env=None): + os.environ.pop("TIME_MODE", None) + os.environ.pop("TIME_VENTURE_MEASURE_DAYS", None) + if mode: + os.environ["TIME_MODE"] = mode + os.environ.update(env or {}) + tm.http = self.http + return hk.main() + + +def pr(n, head, state="open", merged=False, base="main", idle=0, merged_days=1): + return {"number": n, "title": f"pr {n}", "state": state, "merged": merged, "merged_at": iso(merged_days) if merged else None, + "base": {"ref": base}, "head": {"ref": head, "repo": {"full_name": "o/r"}}, "created_at": iso(idle), "updated_at": iso(idle)} + + +deleted = lambda f: [urllib.parse.unquote(w[1].split("/branches/")[1]) for w in f.writes if w[0] == "DELETE"] + + +def test_branches(): + f = Forge(branches=["main", "gh-pages", "time/card-53", "ceo/protected-paths", "labs/aicharts-fast", "time/card-90", "feature/open", "feature/closed-unmerged"], + prs=[pr(1, "time/card-53", "closed", True), pr(2, "ceo/protected-paths", "closed", True), pr(3, "labs/aicharts-fast", "closed", True), + pr(4, "main", "closed", True), pr(5, "gh-pages", "closed", True), + pr(6, "feature/open", "open"), pr(7, "feature/open", "closed", True), # merged once, reopened as a new PR: keep + pr(8, "feature/closed-unmerged", "closed", False)]) + assert f.run() == 0 + assert sorted(deleted(f)) == ["ceo/protected-paths", "labs/aicharts-fast", "time/card-53"], deleted(f) + kept = {b["name"] for b in f.branches} + assert {"main", "gh-pages", "time/card-90", "feature/open", "feature/closed-unmerged"} <= kept + assert any("3 branch(es) deleted" in b for _, b in f.posted), f.posted # the summary line on the Astra log + # idempotent: a second run has nothing to do + f.writes.clear(); f.posted.clear() + f.run() + assert deleted(f) == [] and any("0 branch(es) deleted" in b for _, b in f.posted) + # a branch with commits after its merge is kept + f = Forge(branches=[{"name": "time/card-9", "commit": {"timestamp": iso(0)}}], prs=[pr(9, "time/card-9", "closed", True, merged_days=2)]) + f.run(); assert deleted(f) == [] + + +def sig(n, venture, age): + return {"number": n, "title": f"{venture}: want the next version?", "labels": ["signal"], "created_at": iso(age), + "body": f"Join: https://forge.test/o/r/issues/new?template=early-access.yml&title=x&venture={venture}"} + + +def test_signals(): + f = Forge(issues=[sig(10, "spent", 8), sig(11, "fresh", 2), sig(12, "wanted", 9), sig(13, "asked", 9), + {"number": 20, "labels": ["signup"], "user": {"login": "ann"}, "body": "### Venture\n\nasked\n\n### Plan\n\nFree"}], + reactions={12: [{"content": "+1", "user": {"login": "bob"}}, {"content": "+1", "user": {"login": "owner"}}]}) + f.run() + st = {n: f.issues[n]["state"] for n in (10, 11, 12, 13)} + assert st == {10: "closed", 11: "open", 12: "open", 13: "open"}, st + closing = [b for n, b in f.posted if n == 10] + assert closing and "0 signups" in closing[0] and not [b for n, b in f.posted if n in (11, 12, 13)] + assert any("1 signal issue(s) closed" in b for _, b in f.posted) + # the window is TIME_VENTURE_MEASURE_DAYS + f = Forge(issues=[sig(10, "spent", 8)]); f.run(env={"TIME_VENTURE_MEASURE_DAYS": "10"}) + assert f.issues[10]["state"] == "open" + + +def test_milestones(): + f = Forge(milestones=[{"id": 1, "title": "done batch", "open_issues": 0, "closed_issues": 3}, + {"id": 2, "title": "in progress", "open_issues": 1, "closed_issues": 2}, + {"id": 3, "title": "never used", "open_issues": 0, "closed_issues": 0}]) + f.run() + st = {m["id"]: m.get("state", "open") for m in f.milestones} + assert st == {1: "closed", 2: "open", 3: "open"}, st + assert any("1 milestone(s) closed" in b for _, b in f.posted) + + +def test_stale(): + ready_err = {"created_at": iso(5), "user": {"login": "gitea-actions"}, "body": f"x\n\n{tm.MARK}{json.dumps({'beat': 'error'})} -->"} + f = Forge(issues=[{"number": 30, "title": "old human card", "labels": ["needs-human"], "updated_at": iso(4)}, + {"number": 31, "title": "fresh human card", "labels": ["needs-human"], "updated_at": iso(1)}, + {"number": 32, "title": "errored card", "labels": ["ready"], "updated_at": iso(5)}, + {"number": 33, "title": "fresh error", "labels": ["ready"], "updated_at": iso(0)}], + comments={32: [ready_err], 33: [{**ready_err, "created_at": iso(0)}]}, + prs=[pr(40, "feature/idle", "open", idle=7), pr(41, "feature/new", "open", idle=1)]) + f.run() + texts = [b for n, b in f.posted if n == LOG and "stale list" in b] + assert len(texts) == 1, f.posted + t = texts[0] + assert "#30" in t and "#40" in t and "#32" in t, t + assert "#31" not in t and "#41" not in t and "#33" not in t, t + assert any("3 stale" in b for _, b in f.posted) or any("4 stale" in b for _, b in f.posted), f.posted + # an empty list posts no stale comment, only the one summary line + f = Forge(); f.run() + assert [b for _, b in f.posted if "stale list" in b] == [] and len(f.posted) == 1 and "0 stale" in f.posted[0][1] + + +def test_dry_run(): + f = Forge(branches=["main", "time/card-53"], prs=[pr(1, "time/card-53", "closed", True)], + issues=[sig(10, "spent", 8), {"number": 30, "labels": ["needs-human"], "updated_at": iso(9)}], + milestones=[{"id": 1, "title": "done batch", "open_issues": 0, "closed_issues": 3}]) + for mode in ("dry-run", None): # explicit and unset + assert f.run(mode) == 0 + assert f.writes == [] and f.posted == [], (f.writes, f.posted) + assert {b["name"] for b in f.branches} == {"main", "time/card-53"} and f.issues[10]["state"] == "open" + + +if __name__ == "__main__": + tests = [test_branches, test_signals, test_milestones, test_stale, test_dry_run] + for t in tests: + t() + print("ok", t.__name__) + print(f"{len(tests)} housekeeping tests passed")