2024-05-14 13:33:19 +05:30
|
|
|
#!/usr/bin/python3
|
|
|
|
|
#
|
2026-05-12 20:40:52 +05:30
|
|
|
# failed_composes_cleanup.py - Close all open issues in the releng compose failure tracker.
|
2024-05-14 13:33:19 +05:30
|
|
|
#
|
2026-05-12 20:40:52 +05:30
|
|
|
# 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
|
2024-05-14 13:33:19 +05:30
|
|
|
#
|
|
|
|
|
# Authors:
|
|
|
|
|
# Samyak Jain <samyak.jn11@gmail.com>
|
2026-05-12 20:40:52 +05:30
|
|
|
# Copyright (C) 2026 Red Hat Inc,
|
2024-05-14 13:33:19 +05:30
|
|
|
# SPDX-License-Identifier: GPL-2.0+
|
|
|
|
|
|
2026-05-12 20:40:52 +05:30
|
|
|
import argparse
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
|
2024-05-14 13:33:19 +05:30
|
|
|
import requests
|
2026-05-12 20:40:52 +05:30
|
|
|
from requests.adapters import HTTPAdapter
|
|
|
|
|
from urllib3.util.retry import Retry
|
2024-05-14 13:33:19 +05:30
|
|
|
|
2026-05-12 20:40:52 +05:30
|
|
|
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)"
|
|
|
|
|
)
|
2024-05-14 13:33:19 +05:30
|
|
|
|
2026-05-12 20:40:52 +05:30
|
|
|
|
|
|
|
|
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] = []
|
2024-05-14 13:33:19 +05:30
|
|
|
page = 1
|
2026-05-12 20:40:52 +05:30
|
|
|
api = f"{base_url.rstrip('/')}/api/v1/repos/{owner}/{repo}/issues"
|
|
|
|
|
|
2024-05-14 13:33:19 +05:30
|
|
|
while True:
|
2026-05-12 20:40:52 +05:30
|
|
|
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)
|
2024-05-14 13:33:19 +05:30
|
|
|
return []
|
|
|
|
|
|
2026-05-12 20:40:52 +05:30
|
|
|
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 []
|
2024-05-14 13:33:19 +05:30
|
|
|
|
2026-05-12 20:40:52 +05:30
|
|
|
if len(batch) == 0:
|
|
|
|
|
break
|
2024-05-14 13:33:19 +05:30
|
|
|
|
2026-05-12 20:40:52 +05:30
|
|
|
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,
|
|
|
|
|
)
|
2024-05-14 13:33:19 +05:30
|
|
|
else:
|
2026-05-12 20:40:52 +05:30
|
|
|
print("Open issues:", open_issues)
|
2024-05-14 13:33:19 +05:30
|
|
|
|
2026-05-12 20:40:52 +05:30
|
|
|
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())
|