releng: point failed compose cleanup at Forgejo
All checks were successful
/ test (push) Successful in 2s
All checks were successful
/ test (push) Successful in 2s
compose-tracker-issues lives on forge.fedoraproject.org now, so this script lists and closes open tickets there instead of Pagure. Signed-off-by: Samyak Jain <samyak.jn11@gmail.com>
This commit is contained in:
parent
82898e7343
commit
4e01068563
1 changed files with 353 additions and 60 deletions
|
|
@ -1,75 +1,368 @@
|
|||
#!/usr/bin/python3
|
||||
#
|
||||
# failed_composes_cleanup.py - A utility to clean all the older issues in the releng/failed-compose tracker.
|
||||
# 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) 2024 Red Hat Inc,
|
||||
# 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
|
||||
|
||||
API_TOKEN = 'YOUR_API_TOKEN'
|
||||
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 get_open_issues(repo):
|
||||
base_url = 'https://pagure.io/api/0/'
|
||||
endpoint = f'{repo}/issues'
|
||||
url = base_url + endpoint
|
||||
|
||||
# Include API token in headers for authentication
|
||||
headers = {'Authorization': f'token {API_TOKEN}'}
|
||||
|
||||
open_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:
|
||||
# Make the GET request to retrieve open issues for the current page
|
||||
params = {'page': page}
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
|
||||
# Check if the request was successful
|
||||
if response.status_code == 200:
|
||||
page_issues = response.json()['issues']
|
||||
open_issues.extend(issue['id'] for issue in page_issues if issue['status'] == 'Open')
|
||||
# Check if there are more pages
|
||||
if not page_issues:
|
||||
break
|
||||
else:
|
||||
page += 1
|
||||
else:
|
||||
print(f"Failed to retrieve open issues for {repo}.")
|
||||
print(f"Response: {response.text}")
|
||||
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 []
|
||||
|
||||
return open_issues
|
||||
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 []
|
||||
|
||||
def close_pagure_issue(repo, issue_id, close_status=None):
|
||||
base_url = 'https://pagure.io/api/0/'
|
||||
endpoint = f'{repo}/issue/{issue_id}/status'
|
||||
url = base_url + endpoint
|
||||
if len(batch) == 0:
|
||||
break
|
||||
|
||||
# Include API token in headers for authentication
|
||||
headers = {'Authorization': f'token {API_TOKEN}'}
|
||||
|
||||
# Set the new status of the issue
|
||||
payload = {'status': 'Closed'}
|
||||
|
||||
# Make the POST request to change the status of the issue
|
||||
response = requests.post(url, headers=headers, data=payload)
|
||||
|
||||
# Check if the request was successful
|
||||
if response.status_code == 200:
|
||||
print(f"Issue #{issue_id} closed successfully.")
|
||||
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(f"Failed to close issue #{issue_id}.")
|
||||
print(f"Response: {response.text}")
|
||||
|
||||
repo = 'releng/failed-composes'
|
||||
# Get list of open issues
|
||||
open_issues = get_open_issues(repo)
|
||||
print("Open issues:", open_issues)
|
||||
|
||||
print("Open Issues:", open_issues)
|
||||
#
|
||||
# Close each open issue
|
||||
for issue_id in open_issues:
|
||||
close_pagure_issue(repo, issue_id)
|
||||
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())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue