tooling/release-process/post-release/failed_composes_cleanup.py

368 lines
11 KiB
Python
Raw Permalink Normal View History

#!/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 <samyak.jn11@gmail.com>
# 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())