#!/usr/bin/python3 # # failed_composes_cleanup.py - Close all open issues in the releng compose failure tracker. # # Tracker: https://forge.fedoraproject.org/releng/compose-tracker-issues/ # Pagure releng/failed-composes was retired; Forgejo exposes a Gitea-compatible API: # https://forge.fedoraproject.org/api/v1/swagger # # Create a personal access token on Forgejo with permission to modify issues in the repo. # # How to run (from this directory, or pass the full path to the script): # # export FORGEJO_TOKEN='your-forgejo-personal-access-token' # python3 failed_composes_cleanup.py --dry-run # # Lists open issue numbers and prints what would be closed; does not PATCH. # # Works without a token only if the repo allows anonymous issue listing. # # python3 failed_composes_cleanup.py # # Closes every open issue (needs FORGEJO_TOKEN or --token). # # python3 failed_composes_cleanup.py --token "$FORGEJO_TOKEN" # # Same as above if you prefer not to rely on the environment variable name alone. # # python3 failed_composes_cleanup.py --limit 25 # # Close (or dry-run) at most 25 issues, in API list order (newest pages first). # # Optional comment on each issue (posted immediately before closing), e.g. bulk cleanup: # # python3 failed_composes_cleanup.py --comment 'Cleaning up after new Fedora release.' # # python3 failed_composes_cleanup.py --help # # Shows --base-url, --owner, --repo, and other options. # # If you see dropped connections (RemoteDisconnected), try a token even for # --dry-run, increase --timeout, or raise --page-delay between list pages. # # Optional environment variables (same meaning as the matching CLI flags): # FORGEJO_URL default https://forge.fedoraproject.org # FORGEJO_OWNER default releng # FORGEJO_REPO default compose-tracker-issues # FORGEJO_ISSUE_COMMENT optional default text for --comment if the flag is omitted # # Authors: # Samyak Jain # Copyright (C) 2026 Red Hat Inc, # SPDX-License-Identifier: GPL-2.0+ import argparse import os import sys import time import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry DEFAULT_BASE = "https://forge.fedoraproject.org" DEFAULT_OWNER = "releng" DEFAULT_REPO = "compose-tracker-issues" # Forgejo/Gitea caps per-request issue list size (often 50). Do not stop pagination # when len(batch) < requested limit—that treats a full capped page as the last page. ISSUES_PER_PAGE = 50 DEFAULT_TIMEOUT = 120.0 DEFAULT_PAGE_DELAY = 0.35 USER_AGENT = ( "failed_composes_cleanup " "(Fedora releng; +https://forge.fedoraproject.org/releng/compose-tracker-issues)" ) def _build_session(token: str | None) -> requests.Session: s = requests.Session() s.headers["User-Agent"] = USER_AGENT s.headers["Accept"] = "application/json" if token: s.headers["Authorization"] = f"token {token}" retry = Retry( total=8, connect=8, read=8, backoff_factor=0.8, status_forcelist=(429, 500, 502, 503, 504), allowed_methods=("GET", "PATCH", "POST"), raise_on_status=False, ) adapter = HTTPAdapter(max_retries=retry) s.mount("https://", adapter) s.mount("http://", adapter) return s def get_open_issue_numbers( session: requests.Session, base_url: str, owner: str, repo: str, *, timeout: float, page_delay: float, max_collect: int | None = None, ) -> list[int]: """Return issue numbers for open issues (newest-first API order). If max_collect is set, stop after that many numbers are collected (fewer API pages). """ open_numbers: list[int] = [] page = 1 api = f"{base_url.rstrip('/')}/api/v1/repos/{owner}/{repo}/issues" while True: if page > 1 and page_delay > 0: time.sleep(page_delay) try: r = session.get( api, params={ "state": "open", "type": "issues", "page": page, "limit": ISSUES_PER_PAGE, }, timeout=timeout, ) except requests.RequestException as exc: print( f"Failed to list open issues for {owner}/{repo} (page {page}): {exc}", file=sys.stderr, ) return [] if r.status_code != 200: print( f"Failed to list open issues for {owner}/{repo}: " f"HTTP {r.status_code}", file=sys.stderr, ) print(r.text, file=sys.stderr) return [] batch = r.json() if not isinstance(batch, list): print("Unexpected API response (not a list).", file=sys.stderr) print(r.text[:2000], file=sys.stderr) return [] if len(batch) == 0: break for issue in batch: if issue.get("state") == "open" and "number" in issue: open_numbers.append(int(issue["number"])) if max_collect is not None and len(open_numbers) >= max_collect: return open_numbers[:max_collect] page += 1 return open_numbers def post_issue_comment( session: requests.Session, base_url: str, owner: str, repo: str, issue_number: int, body: str, dry_run: bool, *, timeout: float, ) -> bool: """POST a normal issue comment (before closing).""" api = ( f"{base_url.rstrip('/')}/api/v1/repos/{owner}/{repo}/issues/" f"{issue_number}/comments" ) if dry_run: preview = body.replace("\n", " ")[:120] if len(body) > 120: preview += "…" print(f"[dry-run] would comment on #{issue_number}: {preview}") return True try: r = session.post(api, json={"body": body}, timeout=timeout) except requests.RequestException as exc: print(f"Failed to comment on issue #{issue_number}: {exc}", file=sys.stderr) return False if r.status_code in (200, 201): return True print( f"Failed to comment on issue #{issue_number}: HTTP {r.status_code}", file=sys.stderr, ) print(r.text, file=sys.stderr) return False def close_issue( session: requests.Session, base_url: str, owner: str, repo: str, issue_number: int, dry_run: bool, *, timeout: float, ) -> bool: api = ( f"{base_url.rstrip('/')}/api/v1/repos/{owner}/{repo}/issues/{issue_number}" ) if dry_run: print(f"[dry-run] would close issue #{issue_number}") return True try: r = session.patch(api, json={"state": "closed"}, timeout=timeout) except requests.RequestException as exc: print(f"Failed to close issue #{issue_number}: {exc}", file=sys.stderr) return False # Forgejo may return 200 or 201 on successful PATCH (issue body in response). if r.status_code in (200, 201): print(f"Issue #{issue_number} closed successfully.") return True print(f"Failed to close issue #{issue_number}: HTTP {r.status_code}", file=sys.stderr) print(r.text, file=sys.stderr) return False def main() -> int: p = argparse.ArgumentParser( description=( "Close every open issue in the releng compose-tracker-issues " "Forgejo repository (failed compose tickets)." ), ) p.add_argument( "--base-url", default=os.environ.get("FORGEJO_URL", DEFAULT_BASE), help=f"Forgejo root URL (default: {DEFAULT_BASE} or FORGEJO_URL)", ) p.add_argument( "--owner", default=os.environ.get("FORGEJO_OWNER", DEFAULT_OWNER), help=f"Repository owner (default: {DEFAULT_OWNER} or FORGEJO_OWNER)", ) p.add_argument( "--repo", default=os.environ.get("FORGEJO_REPO", DEFAULT_REPO), help=f"Repository name (default: {DEFAULT_REPO} or FORGEJO_REPO)", ) p.add_argument( "--token", default=os.environ.get("FORGEJO_TOKEN"), help="Forgejo API token (default: FORGEJO_TOKEN env)", ) p.add_argument( "--dry-run", action="store_true", help="List issues that would be closed without calling the API to close them.", ) p.add_argument( "--timeout", type=float, default=DEFAULT_TIMEOUT, metavar="SEC", help=f"Per-request timeout in seconds (default: {DEFAULT_TIMEOUT:g}).", ) p.add_argument( "--page-delay", type=float, default=DEFAULT_PAGE_DELAY, metavar="SEC", help=( "Pause between issue-list API pages (default: %(default)s). " "Use 0 to disable; small delays help avoid dropped connections on busy hosts." ), ) p.add_argument( "--limit", type=int, default=None, metavar="N", help=( "Close or dry-run at most N open issues (API list order). " "Omit to process every open issue. Listing stops early once N are found." ), ) p.add_argument( "--comment", metavar="TEXT", default=None, help=( "If set, post this markdown/plain text as a comment on each issue " "immediately before closing. Same as env FORGEJO_ISSUE_COMMENT when " "this flag is omitted." ), ) args = p.parse_args() issue_comment = (args.comment or os.environ.get("FORGEJO_ISSUE_COMMENT") or "").strip() if not issue_comment: issue_comment = None if args.limit is not None and args.limit < 1: print("--limit must be a positive integer.", file=sys.stderr) return 1 if not args.token and not args.dry_run: print( "No API token: set FORGEJO_TOKEN or pass --token (required to close issues).", file=sys.stderr, ) return 1 # Dry-run listing may work without a token on public repos; use a token if set. session = _build_session(args.token) open_issues = get_open_issue_numbers( session, args.base_url, args.owner, args.repo, timeout=args.timeout, page_delay=args.page_delay, max_collect=args.limit, ) if args.limit is not None: print( f"Issues to process ({len(open_issues)}; --limit {args.limit}):", open_issues, ) else: print("Open issues:", open_issues) ok = True for num in open_issues: if issue_comment: if not post_issue_comment( session, args.base_url, args.owner, args.repo, num, issue_comment, args.dry_run, timeout=args.timeout, ): ok = False continue if not close_issue( session, args.base_url, args.owner, args.repo, num, args.dry_run, timeout=args.timeout, ): ok = False return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())