From c7e2154c83db870e2333fda0854a6e3147c08248 Mon Sep 17 00:00:00 2001 From: abrichr Date: Fri, 21 Aug 2026 17:38:42 -0400 Subject: [PATCH 1/2] feat(sweep): daily stranded-work detector for remote branches and local trees One scheduled job that asks whether work is going stale anywhere we own, following the default-branch-sweep pattern of one issue rewritten in place. - remote detector (hosted-safe): branches that are not the default branch, have no open pull request, and have been quiet past --stale-days are listed as stranded; queue/dependabot machinery and fresh branches stay quiet - local detector (--local-root): bin/oa-fresh ported so clones behind origin, unpushed commits, and uncommitted files land in the same issue; duplicate clones of one remote are marked instead of double-counted; a runner without workspace visibility says so instead of implying the trees are fine - sync dispatch input fast-forwards clean default-branch clones only, using bin/oa-fresh's exact safety rule, and refuses loudly on any runner without OA_SRC_ROOT; nothing else in this job mutates anything 20 offline classifier tests cover the fire and stay-quiet conditions plus real-git scratch-repository tests proving sync cannot touch dirty or feature-branch trees. --- .../workflows/workspace-staleness-sweep.yml | 179 ++++++ scripts/sweep_workspace_staleness.py | 526 ++++++++++++++++++ tests/test_sweep_workspace_staleness.py | 253 +++++++++ 3 files changed, 958 insertions(+) create mode 100644 .github/workflows/workspace-staleness-sweep.yml create mode 100644 scripts/sweep_workspace_staleness.py create mode 100644 tests/test_sweep_workspace_staleness.py diff --git a/.github/workflows/workspace-staleness-sweep.yml b/.github/workflows/workspace-staleness-sweep.yml new file mode 100644 index 0000000..9e25a6b --- /dev/null +++ b/.github/workflows/workspace-staleness-sweep.yml @@ -0,0 +1,179 @@ +name: Workspace staleness sweep + +# One scheduled job that asks whether work is going stale anywhere we own -- +# on GitHub AND in the local workspace trees -- and files ONE issue. +# +# Why here, and why one: on 2026-08-21 the workspace's bin/oa-fresh found 32 +# local clones behind their origin carrying unpushed or uncommitted work, and +# stale-tree misreads have produced four confident wrong findings this year. +# oa-fresh answers the local question but is a local script nothing schedules; +# no cross-repository detector watches for stranded remote branches at all. This +# repository already files ONE issue per day from two other scheduled sweeps, +# so this copies that pattern. See scripts/sweep_workspace_staleness.py for the +# full rationale and the safety rules. +# +# WHAT THE HOSTED RUN CAN AND CANNOT SEE +# -------------------------------------- +# The daily hosted run covers REMOTE strandings: branches that are not the +# default branch, have no open pull request, and have gone quiet past +# --stale-days. It cannot see local clones, so its issue says exactly that +# instead of implying the workspace is fine. Local detection needs a +# self-hosted runner attached to the workspace machine; anyone can get the full +# report locally today with: +# +# python scripts/sweep_workspace_staleness.py --local-root /Users/abrichr/oa/src +# +# SYNC MODE (manual dispatch only) +# -------------------------------- +# The `sync` input fast-forwards clean default-branch clones under OA_SRC_ROOT, +# using bin/oa-fresh's exact safety rule: never touches a dirty tree, a feature +# branch, or a clone with local commits, and the only mutation attempted is +# `git merge --ff-only`, which cannot lose work. A hosted runner has no +# workspace, so sync refuses loudly there by design. When a self-hosted runner +# is registered on the workspace machine, export OA_SRC_ROOT=/path/to/workspace +# in its environment and change the sweep job's `runs-on` below from +# `ubuntu-latest` to `[self-hosted]`; nothing else needs to change. +# +# COST: one ubuntu-latest runner, standard library only, no dependency install, +# no lockfile, no cache. A few hundred API reads a day against a 1000/hour +# token budget, with an explicit per_page on every call. + +on: + schedule: + # 07:41 UTC: offset from default-branch-sweep.yml at 07:11 and + # published-version-claims.yml at 06:41 so scheduled jobs do not overlap. + - cron: '41 7 * * *' + workflow_dispatch: + inputs: + sync: + description: >- + Fast-forward clean default-branch clones under OA_SRC_ROOT + (bin/oa-fresh safety rules). Refuses loudly on any runner where + OA_SRC_ROOT is not set. + type: boolean + default: false + stale_days: + description: 'Branches quiet longer than this many days count as stranded' + type: number + default: 14 + pull_request: + paths: + - 'scripts/sweep_workspace_staleness.py' + - 'tests/test_sweep_workspace_staleness.py' + - '.github/workflows/workspace-staleness-sweep.yml' + +concurrency: + group: workspace-staleness-sweep-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + self-test: + # A detector nobody has seen fire is a detector nobody should trust. The + # offline classification tests also run in Docs CI; running them here keeps + # a change to the detector self-contained. + name: Prove the classifiers fire and stay quiet + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - run: python -m pytest tests/test_sweep_workspace_staleness.py -q + + sweep: + name: Sweep for stranded work + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + # The sync input mutates local trees by fast-forward, so it may ONLY run + # where those trees exist. A hosted runner has no OA_SRC_ROOT: fail + # loudly here instead of silently sweeping nothing and reporting success. + # The runner's own environment carries OA_SRC_ROOT, which the `env.` + # expression context cannot see -- hence a shell check, not an `if:`. + - name: Refuse sync without a workspace root + if: github.event_name == 'workflow_dispatch' && inputs.sync + run: | + if [ -z "${OA_SRC_ROOT:-}" ]; then + echo "::error::sync=true was requested but OA_SRC_ROOT is not set on this runner. Register a self-hosted runner on the workspace machine with OA_SRC_ROOT=/path/to/workspace, switch runs-on to [self-hosted], then dispatch again. Nothing was modified." + exit 2 + fi + + - name: Sweep + id: sweep + env: + GITHUB_TOKEN: ${{ github.token }} + OA_SWEEP_TOKEN: ${{ secrets.OA_SWEEP_TOKEN }} + run: | + STALE_DAYS="${{ github.event_name == 'workflow_dispatch' && inputs.stale_days || 14 }}" + ARGS=(--markdown workspace-staleness-sweep.md \ + --github-output "${GITHUB_OUTPUT}" \ + --run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + --stale-days "${STALE_DAYS}") + if [ -n "${OA_SRC_ROOT:-}" ]; then + ARGS+=(--local-root "${OA_SRC_ROOT}") + if [ "${{ github.event_name == 'workflow_dispatch' && inputs.sync || false }}" = "true" ]; then + ARGS+=(--sync) + fi + fi + python scripts/sweep_workspace_staleness.py "${ARGS[@]}" + + # One issue for the whole organisation, rewritten in place. A new issue + # every day is the same as no issue: it stops being read. Editing a body + # does not notify, so a long-lived gap does not become a daily ping either. + - name: Open, reopen, or update the single sweep issue + if: steps.sweep.outputs.alert == 'true' + env: + GH_TOKEN: ${{ github.token }} + TITLE: "Workspace staleness sweep - stranded branches or stale trees found" + run: | + set -euo pipefail + # Compare the full title in jq rather than searching for it: a colon + # inside a GitHub search phrase is parsed as a qualifier. + EXISTING_JSON=$(gh issue list --repo "${GITHUB_REPOSITORY}" --state all \ + --limit 1000 --json number,title,state \ + --jq '[.[] | select(.title == env.TITLE)][0] // {}') + EXISTING=$(jq -r '.number // empty' <<< "${EXISTING_JSON}") + EXISTING_STATE=$(jq -r '.state // empty' <<< "${EXISTING_JSON}") + if [ -n "${EXISTING}" ]; then + if [ "${EXISTING_STATE}" = "CLOSED" ]; then + gh issue reopen "${EXISTING}" --repo "${GITHUB_REPOSITORY}" + fi + gh issue edit "${EXISTING}" --repo "${GITHUB_REPOSITORY}" \ + --body-file workspace-staleness-sweep.md + echo "Updated issue #${EXISTING}." + else + gh issue create --repo "${GITHUB_REPOSITORY}" \ + --title "${TITLE}" --body-file workspace-staleness-sweep.md + fi + + # Silence when everything is current. Posting "all clear" daily is how an + # alert gets muted. + - name: Close the issue once everything is current + if: steps.sweep.outputs.clear == 'true' + env: + GH_TOKEN: ${{ github.token }} + TITLE: "Workspace staleness sweep - stranded branches or stale trees found" + run: | + set -euo pipefail + EXISTING=$(gh issue list --repo "${GITHUB_REPOSITORY}" --state open \ + --limit 1000 --json number,title \ + --jq '[.[] | select(.title == env.TITLE)][0].number // empty') + if [ -n "${EXISTING}" ]; then + gh issue close "${EXISTING}" --repo "${GITHUB_REPOSITORY}" \ + --comment "No stranded branches and no stale trees as of ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}. Closing automatically." + else + echo "Nothing is stranded and no issue is open." + fi diff --git a/scripts/sweep_workspace_staleness.py b/scripts/sweep_workspace_staleness.py new file mode 100644 index 0000000..ed49cc7 --- /dev/null +++ b/scripts/sweep_workspace_staleness.py @@ -0,0 +1,526 @@ +#!/usr/bin/env python3 +"""Sweep for stranded work: stale branches on GitHub and stale local trees. + +WHY THIS EXISTS +--------------- +On 2026-08-21 the workspace check ``bin/oa-fresh`` found 32 local clones behind +their origin, several by hundreds of commits, carrying unpushed or uncommitted +work. Reading a stale tree and reporting what is inside it has produced four +confident wrong findings in this workspace. ``bin/oa-fresh`` answers the local +question, but it is a local script: nothing runs it on a schedule, so it only +helps whoever remembers to run it. + +This repository already owns the only committed multi-repository registry and +already files ONE issue per day from a scheduled sweep +(``default-branch-sweep.yml``, ``published-version-claims.yml``), so this copies +that pattern for the stranded-work question. + +TWO DETECTORS, TWO PLACES THEY CAN RUN +-------------------------------------- +1. **Remote detector (runs anywhere, default).** A branch pushed to GitHub that + is not the default branch, has no open pull request, and has had no commit + for ``--stale-days`` days is stranded: nobody is reviewing it, nothing will + merge it, and every day it ages the harder salvage becomes. This needs only + the REST API, so the hosted daily cron runs it. + +2. **Local detector (needs the trees; opt-in via ``--local-root``).** For each + first-level git clone under a workspace root it reports how far behind its + origin the clone is, how many local commits are unpushed, and how many files + are uncommitted. This is ``bin/oa-fresh`` ported here so one issue can carry + both halves of the answer. A hosted runner cannot see these trees: until a + self-hosted runner attached to the workspace machine runs this repository, + the daily issue says plainly that local trees were not visible instead of + implying they are fine. + +SYNC MODE IS THE ONLY MUTATION, AND IT CANNOT LOSE WORK +------------------------------------------------------- +With ``--sync``, a tree is moved only when it is ON ITS DEFAULT BRANCH, CLEAN, +and has NO LOCAL COMMITS -- exactly ``bin/oa-fresh``'s rule -- and the only +mutation attempted is ``git merge --ff-only``, which refuses unless the move is +a pure fast-forward. The script never stashes, never discards, never checks out +a branch, never force-pushes, and never touches a dirty tree or a feature +branch. Anything else is reported for a human. + +COST +---- +Standard library only. The remote detector spends two list reads plus one read +per branch tip per repository; a few hundred API reads a day against the token +budget, with an explicit per_page on every call. Local mode costs one fetch per +clone, same as running ``bin/oa-fresh`` by hand. + +ONE ISSUE, REWRITTEN IN PLACE +----------------------------- +Same rule as the other sweeps: a new issue every day is the same as no issue; +editing a body does not notify; silence when everything is current keeps the +alert unmuted. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +ORG = "OpenAdaptAI" + +# Branch-name prefixes that are machinery, not someone's stranded work. +IGNORED_BRANCH_PREFIXES = ("gh-readonly-queue/", "dependabot/", "l10n_") + +# Remote classification outcomes worth asserting in tests. +DEFAULT = "default" +OPEN_PR = "open-pr" +STALE_UNMERGED = "stale-unmerged" +ACTIVE = "active" +IGNORED = "ignored" + + +class Reader: + """Minimal authenticated GET client with an explicit per_page everywhere.""" + + def __init__(self, token: str | None) -> None: + self.token = token + self.calls = 0 + + def get(self, path: str, params: dict[str, str] | None = None) -> Any: + import urllib.request + + query = "" + if params: + query = "?" + "&".join(f"{key}={value}" for key, value in params.items()) + request = urllib.request.Request(f"https://api.github.com{path}{query}") + request.add_header("Accept", "application/vnd.github+json") + request.add_header("User-Agent", "openadapt-ops-staleness-sweep") + if self.token: + request.add_header("Authorization", f"Bearer {self.token}") + self.calls += 1 + try: + with urllib.request.urlopen(request) as response: + return json.load(response) + except Exception: + return None + + def get_all(self, path: str, params: dict[str, str] | None = None) -> list: + """List with pagination; one page covers most repositories.""" + merged_params = dict(params or {}) + merged_params.setdefault("per_page", "100") + items: list = [] + page = 1 + while True: + merged_params["page"] = str(page) + batch = self.get(path, merged_params) + if not isinstance(batch, list) or not batch: + break + items.extend(batch) + if len(batch) < 100: + break + page += 1 + return items + + +def parse_time(stamp: str) -> datetime: + return datetime.fromisoformat(stamp.replace("Z", "+00:00")) + + +def classify_branch( + name: str, + default_branch: str, + open_pr_head_refs: set[str], + last_commit_date: datetime, + now: datetime, + stale_days: float, +) -> str: + """Classify one remote branch as stranded, machinery, or live work. + + A branch is STALE_UNMERGED only when it is not the default, not ignored + machinery, carries no open pull request, and its newest commit predates the + cutoff. Everything else stays quiet: a fresh branch without a pull request + may be an hour old, and alarming on those would bury the real strandings. + """ + if name == default_branch: + return DEFAULT + if any(name.startswith(prefix) for prefix in IGNORED_BRANCH_PREFIXES): + return IGNORED + if name in open_pr_head_refs: + return OPEN_PR + if last_commit_date < now - timedelta(days=stale_days): + return STALE_UNMERGED + return ACTIVE + + +def owned_repositories(reader: Reader) -> list[dict]: + """Every repository in the organisation we own; archived ones excluded.""" + repositories = reader.get_all(f"/orgs/{ORG}/repos", {"type": "all"}) + if not isinstance(repositories, list): + return [] + return [repo for repo in repositories if not repo.get("archived", False)] + + +def sweep_repository_remote( + reader: Reader, repo: dict, now: datetime, stale_days: float +) -> dict: + """Return the stranded-branch rows for one repository.""" + full_name = repo["full_name"] + default_branch = repo.get("default_branch") or "main" + pulls = reader.get_all(f"/repos/{full_name}/pulls", {"state": "open"}) + open_pr_head_refs = { + pull.get("head", {}).get("ref") for pull in pulls if isinstance(pull, dict) + } - {None} + branches = reader.get_all(f"/repos/{full_name}/branches", {"per_page": "100"}) + rows: list[dict] = [] + if not isinstance(branches, list): + # A private repository returns 404 to a token without access. That is a + # visibility boundary to report, never a finding. + return {"repo": full_name, "readable": False, "rows": []} + for branch in branches: + if not isinstance(branch, dict): + continue + name = branch.get("name", "") + sha = (branch.get("commit") or {}).get("sha") + detail = reader.get(f"/repos/{full_name}/commits/{sha}") if sha else None + dates: list[str] = [] + if isinstance(detail, dict): + commit = detail.get("commit") or {} + author_date = ((commit.get("author") or {}).get("date")) or "" + committer_date = ((commit.get("committer") or {}).get("date")) or "" + dates = [d for d in (author_date, committer_date) if d] + if not dates: + continue + last_date = max(parse_time(d) for d in dates) + verdict = classify_branch( + name, default_branch, open_pr_head_refs, last_date, now, stale_days + ) + if verdict != STALE_UNMERGED: + continue + age_days = (now - last_date).total_seconds() / 86400 + rows.append( + { + "repo": full_name, + "branch": name, + "last_commit": last_date.date().isoformat(), + "age_days": round(age_days), + "url": f"https://github.com/{full_name}/tree/{name}", + } + ) + return {"repo": full_name, "readable": True, "rows": rows} + + +# -------------------------------------------------------------------------- +# Local-tree detector (bin/oa-fresh, ported) +# -------------------------------------------------------------------------- + + +def git(tree_path: Path, *args: str) -> tuple[int, str]: + completed = subprocess.run( # noqa: S603 + ["git", "-C", str(tree_path), *args], + capture_output=True, + text=True, + timeout=180, + ) + return completed.returncode, completed.stdout + completed.stderr + + +def local_trees(root: Path) -> list[Path]: + """First-level directories under root that are git clones.""" + found: list[Path] = [] + for entry in sorted(root.iterdir()): + if entry.is_dir() and (entry / ".git").exists(): + found.append(entry) + return found + + +def parse_status_porcelain(text: str) -> int: + """Count changed entries in ``git status --porcelain`` output.""" + return len([line for line in text.splitlines() if line.strip()]) + + +def count_commits(tree_path: Path, range_spec: str) -> int: + completed = subprocess.run( # noqa: S603 + ["git", "-C", str(tree_path), "rev-list", "--count", range_spec], + capture_output=True, + text=True, + timeout=60, + ) + try: + return int(completed.stdout.strip()) + except ValueError: + return -1 + + +def detect_default_branch(tree_path: Path) -> tuple[str, str]: + """Return (name, tracking-ref) for origin's default branch.""" + code, out = git(tree_path, "ls-remote", "--symref", "origin", "HEAD") + if code == 0: + for line in out.splitlines(): + if line.startswith("ref:"): + ref = line[len("ref:") :].split("\t")[0].strip() + if ref.startswith("refs/heads/"): + name = ref[len("refs/heads/") :] + return name, f"origin/{name}" + return "main", "origin/main" + + +def inspect_tree(tree_path: Path) -> dict: + """Read-only staleness report for one clone, mirroring bin/oa-fresh.""" + code, raw_branch = git(tree_path, "symbolic-ref", "--short", "HEAD") + branch = raw_branch.strip() if (code == 0 and raw_branch.strip()) else "(detached)" + git(tree_path, "fetch", "origin", "--quiet") + default_name, default_ref = detect_default_branch(tree_path) + ahead_source = default_ref + if branch != "(detached)": + code, _ = git(tree_path, "rev-parse", "--verify", "--quiet", f"origin/{branch}") + if code == 0: + ahead_source = f"origin/{branch}" + behind = count_commits(tree_path, f"HEAD..{default_ref}") + unpushed = count_commits(tree_path, f"{ahead_source}..HEAD") + _code, status_text = git(tree_path, "status", "--porcelain") + dirty = parse_status_porcelain(status_text) + _code, raw_remote = git(tree_path, "remote", "get-url", "origin") + return { + "name": tree_path.name, + "path": str(tree_path), + "branch": branch, + "behind": max(behind, 0), + "unpushed": max(unpushed, 0), + "dirty": dirty, + "remote_url": raw_remote.strip(), + "on_default": branch == default_name, + "duplicate_of": "", + } + + +def sync_tree(report: dict) -> str: + """bin/oa-fresh's exact safety rule, then one fast-forward attempt. + + Returns a short outcome string. Anything other than 'fast-forwarded' means + the tree was left exactly as it was. + """ + if report["dirty"]: + return "skipped: dirty tree" + if not report["on_default"]: + return "skipped: not on the default branch" + if report["unpushed"]: + return "skipped: has local commits" + code, _output = git( + Path(report["path"]), "merge", "--ff-only", "origin/" + report["branch"] + ) + return "fast-forwarded" if code == 0 else "refused fast-forward" + + +# -------------------------------------------------------------------------- +# Rendering +# -------------------------------------------------------------------------- + + +def render_local_table(local_rows: list[dict]) -> list[str]: + lines = ["### Local workspace trees with problems", ""] + lines.append("| Clone | Branch | Behind | Unpushed | Uncommitted |") + lines.append("|---|---|---|---|---|") + for row in sorted( + local_rows, + key=lambda item: (-item["behind"], -item["unpushed"], item["name"]), + ): + name = row["name"] + if row.get("duplicate_of"): + name = f"{name} (same remote as `{row['duplicate_of']}`)" + lines.append( + f"| {name} | {row['branch']} | {row['behind']} " + f"| {row['unpushed']} | {row['dirty']} |" + ) + return lines + + +def render( + remote_rows: list[dict], + unreadable: list[str], + local_rows: list[dict] | None, + synced: list[tuple[str, str]], + stale_days: float, + run_url: str, +) -> str: + now = datetime.now(timezone.utc).date().isoformat() + lines: list[str] = [ + "# Workspace staleness sweep", + "", + f"Swept {now}. Stranded-branch cutoff: {stale_days:g} days.", + "", + ] + if remote_rows: + lines.append("| Repository | Stranded branch | Last commit | Age (days) |") + lines.append("|---|---|---|---|") + for row in sorted( + remote_rows, key=lambda item: (-item["age_days"], item["repo"]) + ): + lines.append( + f"| {row['repo']} | [{row['branch']}]({row['url']}) " + f"| {row['last_commit']} | {row['age_days']} |" + ) + lines.append("") + lines.append( + "A stranded branch is not the default branch, has no open pull " + "request, and has had no commit past the cutoff. Triage it: merge, " + "supersede deliberately, or archive it. Nothing is deleted by this " + "sweep." + ) + else: + lines.append("No stranded remote branches past the cutoff.") + lines.append("") + if unreadable: + lines.append( + "Not readable this run: " + + ", ".join(sorted(unreadable)) + + ". The repository token cannot read private repositories; set " + "the `OA_SWEEP_TOKEN` secret with read access to cover them." + ) + lines.append("") + if local_rows is None: + lines.append( + "**Local workspace trees were not visible from this runner**, so " + "clones-behind-origin, unpushed-commit, and uncommitted-file counts " + "are absent from this report. Run " + "`scripts/sweep_workspace_staleness.py --local-root ` on " + "the workspace machine, or attach a self-hosted runner there with " + "`OA_SRC_ROOT` set, to fill this gap." + ) + elif local_rows: + lines.extend(render_local_table(local_rows)) + lines.append("") + else: + lines.append("Local trees: every checked-out clone is current and clean.") + if synced: + lines.append("") + lines.append("Fast-forward attempts this run:") + lines.append("") + for name, outcome in synced: + lines.append(f"- `{name}`: {outcome}") + if run_url: + lines.append("") + lines.append(f"Produced by run {run_url}.") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--markdown", help="write the issue body to this file") + parser.add_argument("--github-output", help="append alert= and clear= to this file") + parser.add_argument("--run-url", default="", help="link back to the producing run") + parser.add_argument( + "--stale-days", + type=float, + default=14.0, + help="a remote branch with no commit this old and no open PR is stranded", + ) + parser.add_argument( + "--local-root", + default="", + help="also inspect first-level git clones under this directory", + ) + parser.add_argument( + "--sync", + action="store_true", + help=( + "with --local-root: fast-forward clean default-branch clones; " + "no other mutation of any kind" + ), + ) + parser.add_argument( + "--offline-fixture", + help="read a JSON fixture of remote rows instead of the API (for tests)", + ) + args = parser.parse_args(argv) + + if args.sync and not args.local_root: + parser.error("--sync requires --local-root") + + token = ( + os.environ.get("OA_SWEEP_TOKEN") + or os.environ.get("GH_TOKEN") + or os.environ.get("GITHUB_TOKEN") + ) + + synced: list[tuple[str, str]] = [] + local_rows: list[dict] | None = None + if args.local_root: + root = Path(args.local_root).expanduser().resolve() + if not root.is_dir(): + # Fail loud rather than render a body that implies the trees are + # fine: a silent all-clear from a wrong path is the exact failure + # mode this sweep exists to prevent. + print(f"sweep: --local-root {root} is not a directory", file=sys.stderr) + return 2 + reports = [inspect_tree(tree) for tree in local_trees(root)] + seen_urls: dict[str, str] = {} + for report in reports: + url = report["remote_url"] + if url and url in seen_urls: + report["duplicate_of"] = seen_urls[url] + else: + seen_urls[url] = report["name"] + if args.sync: + for report in reports: + if report["behind"] > 0: + outcome = sync_tree(report) + synced.append((report["name"], outcome)) + if outcome == "fast-forwarded": + report["behind"] = 0 + local_rows = [ + report + for report in reports + if report["behind"] or report["unpushed"] or report["dirty"] + ] + + now = datetime.now(timezone.utc) + unreadable: list[str] = [] + remote_rows: list[dict] = [] + if args.offline_fixture: + fixture = json.loads(Path(args.offline_fixture).read_text(encoding="utf-8")) + remote_rows = fixture.get("rows", []) + unreadable = fixture.get("unreadable", []) + else: + reader = Reader(token) + repositories = owned_repositories(reader) + if not repositories: + print( + f"sweep: the {ORG} repository listing returned nothing; refusing " + "to report 'everything is fine' from an empty list", + file=sys.stderr, + ) + return 2 + for repo in repositories: + result = sweep_repository_remote(reader, repo, now, args.stale_days) + if not result["readable"]: + unreadable.append(result["repo"]) + remote_rows.extend(result["rows"]) + + body = render( + remote_rows, + unreadable, + local_rows, + synced, + args.stale_days, + args.run_url, + ) + print(body) + alert = bool(remote_rows) or bool(local_rows) + print( + f"\nswept: {len(remote_rows)} stranded remote branches, " + f"{len(local_rows or [])} problem local trees, " + f"{len(synced)} fast-forward attempts", + file=sys.stderr, + ) + if args.markdown: + with open(args.markdown, "w", encoding="utf-8") as handle: + handle.write(body + "\n") + if args.github_output: + with open(args.github_output, "a", encoding="utf-8") as handle: + handle.write(f"alert={'true' if alert else 'false'}\n") + handle.write(f"clear={'false' if alert else 'true'}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sweep_workspace_staleness.py b/tests/test_sweep_workspace_staleness.py new file mode 100644 index 0000000..53b5cb3 --- /dev/null +++ b/tests/test_sweep_workspace_staleness.py @@ -0,0 +1,253 @@ +"""The stranded-work sweep must fire on real strandings and stay quiet otherwise. + +A daily issue that cries wolf gets muted, and a muted alert is worse than none. +These tests prove the classifiers FAIL on the exact conditions this sweep exists +to catch -- including the 2026-08-21 incident that motivated it -- and stay +quiet on the lookalikes that would make it noise: fresh agent branches, open +pull requests, queue and dependency machinery, and clean current local trees. +""" + +import pathlib +import subprocess +import sys +from datetime import datetime, timedelta, timezone + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from sweep_workspace_staleness import ( # noqa: E402 + ACTIVE, + DEFAULT, + IGNORED, + OPEN_PR, + STALE_UNMERGED, + classify_branch, + detect_default_branch, + inspect_tree, + parse_status_porcelain, + render, + sync_tree, +) + +NOW = datetime(2026, 8, 21, 12, 0, tzinfo=timezone.utc) +OLD = NOW - timedelta(days=30) +FRESH = NOW - timedelta(hours=2) + + +def classify(name="feat/experiment", default="main", prs=(), date=OLD, days=14.0): + return classify_branch(name, default, set(prs), date, NOW, days) + + +# -------------------------------------------------------------------------- +# Remote classification: it must fire on strandings +# -------------------------------------------------------------------------- + + +def test_stale_unmerged_branch_without_pr_fires(): + assert classify() == STALE_UNMERGED + + +def test_age_just_past_cutoff_fires(): + assert classify(date=NOW - timedelta(days=14, hours=1)) == STALE_UNMERGED + + +# -------------------------------------------------------------------------- +# Remote classification: it must stay quiet on the lookalikes +# -------------------------------------------------------------------------- + + +def test_default_branch_never_fires(): + assert classify(name="main", date=OLD) == DEFAULT + + +def test_open_pr_head_is_live_work(): + assert classify(prs=("feat/experiment",)) == OPEN_PR + + +def test_fresh_branch_without_pr_is_not_stranded(): + assert classify(date=FRESH) == ACTIVE + + +def test_queue_machinery_is_ignored(): + assert classify(name="gh-readonly-queue/main/pr-123") == IGNORED + + +def test_dependabot_branch_is_ignored(): + assert classify(name="dependabot/pip/uv-0.12.4") == IGNORED + + +def test_cutoff_boundary_inside_window_stays_quiet(): + assert classify(date=NOW - timedelta(days=13, hours=23)) == ACTIVE + + +# -------------------------------------------------------------------------- +# Local-tree parsing +# -------------------------------------------------------------------------- + + +def test_status_porcelain_counts_entries(): + text = " M a.py\n?? b.txt\n\nM c.py" + assert parse_status_porcelain(text) == 3 + + +def test_status_porcelain_empty_tree_is_zero(): + assert parse_status_porcelain("") == 0 + + +# -------------------------------------------------------------------------- +# Local trees: real git repositories in a scratch directory +# -------------------------------------------------------------------------- + + +def _git(cwd, *args): + completed = subprocess.run( + ["git", "-C", str(cwd), *args], + capture_output=True, + text=True, + check=True, + ) + return completed.stdout + + +def _configure_identity(path): + _git(path, "config", "user.email", "sweep@example.com") + _git(path, "config", "user.name", "Sweep Test") + + +def _make_origin(tmp_path, branch="main"): + """A real bare remote carrying one commit, like any repository on GitHub.""" + bare = tmp_path / f"origin-{branch}.git" + _git(tmp_path, "init", "-q", "-b", branch, "--bare", str(bare)) + seed = tmp_path / f"seed-{branch}" + seed.mkdir() + _git(seed, "init", "-q", "-b", branch) + _configure_identity(seed) + (seed / "f.txt").write_text("one\n") + _git(seed, "add", ".") + _git(seed, "commit", "-qm", "one") + _git(seed, "push", "-q", str(bare), branch) + return bare + + +def _clone(tmp_path, bare, name, branch="main"): + clone = tmp_path / name + _git(tmp_path, "clone", "-q", "-b", branch, str(bare), str(clone)) + _configure_identity(clone) + return clone + + +def test_inspect_and_sync_fast_forward_a_clean_default_clone(tmp_path): + origin = _make_origin(tmp_path) + clone = _clone(tmp_path, origin, "clone") + + # Origin moves ahead; the clone does not know yet. + other = _clone(tmp_path, origin, "other") + (other / "g.txt").write_text("two\n") + _git(other, "add", ".") + _git(other, "commit", "-qm", "two") + _git(other, "push", "-q", "origin", "main") + + report = inspect_tree(clone) + assert report["on_default"] is True + assert report["behind"] == 1 + assert report["unpushed"] == 0 + assert report["dirty"] == 0 + + assert sync_tree(report) == "fast-forwarded" + after = inspect_tree(clone) + assert after["behind"] == 0 + + +def test_sync_refuses_a_dirty_tree(tmp_path): + origin = _make_origin(tmp_path) + clone = _clone(tmp_path, origin, "clone") + (clone / "f.txt").write_text("local edit\n") + + report = inspect_tree(clone) + assert report["dirty"] >= 1 + outcome = sync_tree(report) + assert outcome.startswith("skipped") + # The dirty state must survive untouched. + assert "local edit" in (clone / "f.txt").read_text() + + +def test_sync_refuses_a_feature_branch(tmp_path): + origin = _make_origin(tmp_path) + clone = _clone(tmp_path, origin, "clone") + _git(clone, "switch", "-qc", "feat/side") + + report = inspect_tree(clone) + outcome = sync_tree(report) + assert outcome.startswith("skipped") + assert report["branch"] == "feat/side" + + +def test_unpushed_commits_are_counted_not_synced(tmp_path): + origin = _make_origin(tmp_path) + clone = _clone(tmp_path, origin, "clone") + (clone / "h.txt").write_text("mine\n") + _git(clone, "add", ".") + _git(clone, "commit", "-qm", "local only") + + report = inspect_tree(clone) + assert report["unpushed"] == 1 + assert report["behind"] == 0 + assert sync_tree(report) == "skipped: has local commits" + + +def test_detect_default_branch_reads_the_remote(tmp_path): + origin = _make_origin(tmp_path, branch="trunk") + clone = _clone(tmp_path, origin, "clone", branch="trunk") + name, ref = detect_default_branch(clone) + assert name == "trunk" + assert ref == "origin/trunk" + + +# -------------------------------------------------------------------------- +# Rendering: silence and honesty rules +# -------------------------------------------------------------------------- + + +ROW = { + "repo": "OpenAdaptAI/openadapt-example", + "branch": "feat/old", + "last_commit": "2026-07-01", + "age_days": 51, + "url": "https://github.com/OpenAdaptAI/openadapt-example/tree/feat/old", +} + +LOCAL_ROW = { + "name": "openadapt-example", + "branch": "feat/wip", + "behind": 7, + "unpushed": 2, + "dirty": 3, +} + + +def test_render_names_local_invisibility_instead_of_faking_clean(): + body = render([ROW], [], None, [], 14, "https://run.example") + assert "were not visible from this runner" in body + assert "current and clean" not in body + + +def test_render_lists_problem_trees(): + body = render([], [], [LOCAL_ROW], [], 14, "") + assert "| openadapt-example | feat/wip | 7 | 2 | 3 |" in body + + +def test_render_says_all_clear_only_when_it_ran_locally_and_found_none(): + body = render([], [], [], [], 14, "") + assert "current and clean" in body + + +def test_render_marks_duplicate_clones(): + duplicate = dict(LOCAL_ROW, name="openadapt-hosted", duplicate_of="openadapt-cloud") + body = render([], [], [LOCAL_ROW, duplicate], [], 14, "") + assert "same remote as `openadapt-cloud`" in body + + +def test_render_reports_unreadable_private_repositories(): + body = render([ROW], ["OpenAdaptAI/private-one"], None, [], 14, "") + assert "OpenAdaptAI/private-one" in body + assert "OA_SWEEP_TOKEN" in body From c1906a0e8b9cbfd54b2e5a57f91ff172d62079a7 Mon Sep 17 00:00:00 2001 From: abrichr Date: Fri, 21 Aug 2026 17:45:25 -0400 Subject: [PATCH 2/2] ci(self-test): install the dev extra so pytest exists on the runner Copies the default-branch-sweep self-test setup; a bare python -m pytest has no pytest module on the hosted image. --- .github/workflows/workspace-staleness-sweep.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/workspace-staleness-sweep.yml b/.github/workflows/workspace-staleness-sweep.yml index 9e25a6b..038f7b0 100644 --- a/.github/workflows/workspace-staleness-sweep.yml +++ b/.github/workflows/workspace-staleness-sweep.yml @@ -82,7 +82,11 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - run: python -m pytest tests/test_sweep_workspace_staleness.py -q + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: true + - run: uv sync --locked --extra dev + - run: uv run pytest tests/test_sweep_workspace_staleness.py -q sweep: name: Sweep for stranded work