diff --git a/scripts/sweep_workspace_staleness.py b/scripts/sweep_workspace_staleness.py index ed49cc7..6b80047 100644 --- a/scripts/sweep_workspace_staleness.py +++ b/scripts/sweep_workspace_staleness.py @@ -71,6 +71,11 @@ # Branch-name prefixes that are machinery, not someone's stranded work. IGNORED_BRANCH_PREFIXES = ("gh-readonly-queue/", "dependabot/", "l10n_") +# GitHub rejects an issue body over 65536 characters; keep the table readable +# long before that and guard the total as a last resort. +MAX_REMOTE_ROWS = 40 +MAX_BODY_CHARS = 60000 + # Remote classification outcomes worth asserting in tests. DEFAULT = "default" OPEN_PR = "open-pr" @@ -350,15 +355,31 @@ def render( "", ] if remote_rows: + ordered = sorted(remote_rows, key=lambda item: (-item["age_days"], item["repo"])) + shown = ordered[:MAX_REMOTE_ROWS] + lines.append( + f"{len(ordered)} stranded branch(es) past the cutoff" + + (f" (oldest {MAX_REMOTE_ROWS} shown)." if len(ordered) > MAX_REMOTE_ROWS else ".") + ) + lines.append("") 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"]) - ): + for row in shown: lines.append( f"| {row['repo']} | [{row['branch']}]({row['url']}) " f"| {row['last_commit']} | {row['age_days']} |" ) + hidden = len(ordered) - len(shown) + if hidden: + per_repo: dict[str, int] = {} + for row in ordered[MAX_REMOTE_ROWS:]: + per_repo[row["repo"]] = per_repo.get(row["repo"], 0) + 1 + summary = ", ".join(f"{name} ({count})" for name, count in sorted(per_repo.items())) + lines.append("") + lines.append( + f"+{hidden} older stranded branch(es) not listed: {summary}. " + "The full list is in the run log." + ) lines.append("") lines.append( "A stranded branch is not the default branch, has no open pull " @@ -400,7 +421,17 @@ def render( if run_url: lines.append("") lines.append(f"Produced by run {run_url}.") - return "\n".join(lines) + body = "\n".join(lines) + # GitHub rejects issue bodies over 65536 characters; the first scheduled + # run died exactly there (2026-08-22, GraphQL "Body is too long"). The + # table cap above bounds the usual cause; this guard bounds every cause. + if len(body) > MAX_BODY_CHARS: + note = ( + f"\n\n---\n[truncated at {MAX_BODY_CHARS} of {len(body)} characters " + "to fit the GitHub issue limit; full output is in the run log.]" + ) + body = body[: MAX_BODY_CHARS - len(note)] + note + return body def main(argv: list[str] | None = None) -> int: diff --git a/tests/test_sweep_workspace_staleness.py b/tests/test_sweep_workspace_staleness.py index 53b5cb3..d9aa737 100644 --- a/tests/test_sweep_workspace_staleness.py +++ b/tests/test_sweep_workspace_staleness.py @@ -251,3 +251,37 @@ 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 + + +def _rows(count: int) -> list[dict]: + return [ + dict(ROW, branch=f"feat/old-{index}", age_days=100 - index) + for index in range(count) + ] + + +def test_render_caps_remote_table_and_counts_the_rest(): + from sweep_workspace_staleness import MAX_REMOTE_ROWS + + rows = _rows(MAX_REMOTE_ROWS + 7) + body = render(rows, [], None, [], 14, "") + assert f"{MAX_REMOTE_ROWS + 7} stranded branch(es) past the cutoff" in body + assert f"oldest {MAX_REMOTE_ROWS} shown" in body + assert "+7 older stranded branch(es)" in body + assert "+7 older stranded branch(es)" in body + # Exactly the cap is listed; nothing hidden leaks into the body. + assert body.count("feat/old-") == MAX_REMOTE_ROWS + assert "feat/old-40" not in body + + +def test_render_body_never_exceeds_github_limit(): + from sweep_workspace_staleness import MAX_BODY_CHARS + + wide = [ + dict(ROW, branch="feat/" + "x" * 400 + str(index), url="https://e.example") + for index in range(200) + ] + body = render(wide, [], None, [], 14, "https://run.example") + assert len(body) <= 65536 + if len(body) > MAX_BODY_CHARS: + assert "[truncated at" in body