forge-helpers/forge_convert_labels.py
Adam Williamson 79855da7be Add forge_convert_labels.py to convert to templated labels
As part of the Pagure migration process, per-repo labels were
created to match Pagure's labels, priorities and statuses. But
in Forgejo we also have similar quality org-wide labels templated
from instance-wide labels, which is confusing and redundant.

This script runs on a Forgejo repo and removes the per-repo
priority labels, replacing them with the templated priority
labels. It also replaces 'bug' with 'Work Item/Bug' and
'task' with 'Work Item/Task', since they seem pretty good
equivalents.

We use color to distinguish between the per-repo and templated
priority labels as they actually have the exact same names. The
colors for the 'bug' and 'task' labels seem pretty consistent
from repo to repo, except one outlier where the 'task' color
was different. We make the replacements mapping an external file
that can be swapped out, so it can easily be adapted to different
organizations and repositories.

Fixes #1

Signed-off-by: Adam Williamson <awilliam@redhat.com>
2026-04-29 12:52:29 +02:00

261 lines
7.7 KiB
Python
Executable file

#!/usr/bin/python3
"""
Switch issues from using per-repo priority (and other) labels created by the Pagure
import process to using the instance-wide, 'templated' priority labels
for consistency.
Note: this script was initally based on the others in the repository, which were
mostly AI-generated using Claude (Anthropic) via Cursor IDE. All changes were done
manually.
"""
import argparse
import json
from pathlib import Path
import sys
import time
from urllib.parse import urlparse
import requests
def read_token_from_file(token_file: Path) -> str:
"""
Read API token from a file.
Args:
token_file: Path to the file containing the token
Returns:
The token string (stripped of whitespace)
Raises:
FileNotFoundError: If the file doesn't exist
ValueError: If the file is empty
"""
content = token_file.read_text().strip()
if not content:
raise ValueError(f"Token file is empty: {token_file}")
return content
def read_replacements_from_file(replacements_file: str) -> dict:
"""
Read label replacements dict from a JSON file.
Args:
replacements_file: Path to the file containing the dict
Returns:
The dict
Raises:
FileNotFoundError: If the file doesn't exist
json.decoder.JSONDecodeError: If the file is not valid JSON
"""
with open(replacements_file, "r", encoding="utf-8") as rfh:
return json.load(rfh)
def parse_url(url: str) -> tuple[str, str]:
"""
Parse a Forge project URL into API base URL and owner/repo path.
Args:
url: Full URL to the Forge project
(e.g., 'https://forge.fedoraproject.org/quality/some-repo')
Returns:
Tuple of (api_base, owner_repo)
e.g., ('https://forge.fedoraproject.org/api/v1', 'quality/some-repo')
Raises:
ValueError: If the URL is invalid
"""
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
raise ValueError(f"Invalid Forge URL: {url}")
api_base = f"{parsed.scheme}://{parsed.netloc}/api/v1"
# Remove leading slash from path
owner_repo = parsed.path.lstrip("/")
if not owner_repo or "/" not in owner_repo:
raise ValueError(f"Invalid Forge project path (expected owner/repo): {url}")
return api_base, owner_repo
def process_issues(
api_base: str,
owner_repo: str,
token: str,
dry_run: bool,
delay: float,
replacements: dict,
) -> tuple[int, int, int]:
"""
Process issues and swap migrated labels to templated ones.
Args:
api_base: Forge API base URL
owner_repo: Forge owner/repo path
token: Forge API token
dry_run: If True, don't actually add labels
delay: Delay between issues in seconds
Returns:
Failed count
"""
failed = 0
i = 0
while True:
# this is sloppy, it breaks if any issue/PR has been deleted,
# but it's easier than dealing with pagination...
i += 1
url = f"{api_base}/repos/{owner_repo}/issues/{i}/labels"
headers = {
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
response = requests.get(url, headers=headers, timeout=30)
if response.ok:
orig_labels = response.json()
elif response.status_code == 404:
# ran out of issues
break
else:
print(f"Unexpected response for issue {i}: {response.status_code}")
failed += 1
continue
orig_labels = requests.get(url, headers=headers, timeout=30).json()
new_labels = []
changed = 0
for label in orig_labels:
lname = label["name"].lower()
if lname in replacements and replacements[lname]["color"] == label["color"]:
new_labels.append(replacements[lname]["id"])
changed = 1
if dry_run:
print(f"Issue {i}: Would replace {label['name']} {label['id']} with {replacements[lname]['id']}")
else:
new_labels.append(label["id"])
if changed and not dry_run:
data = {"labels": new_labels}
print(f"Updating labels on issue {i}...")
response = requests.put(url, headers=headers, json=data, timeout=30)
if not response.ok:
print(f"Unexpected response for issue {i}: {response.status_code}")
failed += 1
time.sleep(delay)
return failed
def main():
parser = argparse.ArgumentParser(
description="Port priority labels from per-repo to per-org",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Dry run on all issues (token from file)
%(prog)s https://forge.fedoraproject.org/quality/some-repo --token-file ~/.forge-token --dry-run
# Process specific issues
%(prog)s https://forge.fedoraproject.org/quality/some-repo --token YOUR_TOKEN --issues 1 2 3
Configuration:
The default mapping is in labels-default.json. For each issue in the repo, any label with
a name and color that matches an entry in the mapping will be replaced with the label that
has the id specified in the same mapping
""",
)
parser.add_argument(
"url",
help="Full URL to the Forge project (e.g., 'https://forge.fedoraproject.org/quality/some-repo')",
)
token_group = parser.add_mutually_exclusive_group(required=True)
token_group.add_argument(
"--token",
help="Forge API token for authentication",
)
token_group.add_argument(
"--token-file",
type=Path,
help="Path to file containing the Forge API token",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without actually adding labels",
)
parser.add_argument(
"--delay",
type=float,
default=0.5,
help="Delay between API calls in seconds (default: 0.5)",
)
parser.add_argument(
"--replacements-file",
type=str,
default="labels-default.json",
help="File containing map of labels to replace",
)
args = parser.parse_args()
# Parse the Forge URL
try:
api_base, owner_repo = parse_url(args.url)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
# Get the Forge API token
if args.token:
token = args.token
else:
try:
token = read_token_from_file(args.token_file)
except FileNotFoundError:
print(f"Error: Token file not found: {args.token_file}")
sys.exit(1)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
try:
replacements = read_replacements_from_file(args.replacements_file)
except FileNotFoundError:
sys.exit(f"Error: Replacements file not found: {args.replacements_file}")
except json.decoder.JSONDecodeError:
sys.exit(f"Error: Replacements file not valid JSON: {args.replacements_file}")
print(f"Forge URL: {args.url}")
if args.dry_run:
print("Mode: DRY RUN (no changes will be made)")
print()
try:
failed = process_issues(
api_base=api_base,
owner_repo=owner_repo,
token=token,
dry_run=args.dry_run,
delay=args.delay,
replacements=replacements,
)
except requests.exceptions.HTTPError as e:
print(f"\nAPI Error: {e}")
sys.exit(1)
except KeyboardInterrupt:
print("\n\nInterrupted by user")
sys.exit(130)
print(f"\n{'=' * 40}")
print("Summary:")
print(f" Failed: {failed}")
if failed > 0:
sys.exit(1)
if __name__ == "__main__":
main()