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>
This commit is contained in:
Adam Williamson 2026-02-09 18:33:24 -08:00 • committed by Kamil Páral
commit 79855da7be
3 changed files with 342 additions and 0 deletions

View file

@ -97,6 +97,53 @@ python forge_migrate_story_points.py https://pagure.io/fedora-qa/some-repo \
Run with `--help` for all available options.
### forge_convert_labels.py
Intended to convert migrated issues from auto-converted, repo-specific labels to forge-wide and/or
organization-wide 'standard' labels.
**Usage:**
```bash
# Dry run on all issues (preview without making changes)
python forge_convert_labels.py https://forge.fedoraproject.org/quality/some-repo \
--token-file ~/.forge-token --dry-run
# Real run with custom replacement mapping
python forge_convert_labels.py https://forge.fedoraproject.org/quality/some-repo \
--token-file ~/.forge-token --replacements-file /path/to/mymapping.json
```
**Configuration:**
As orgs and repos may use different 'standard' labels, you may need to configure the mapping.
The default mapping, labels-default.json, is appropriate for the Quality team's migration. The
format of the file is a dict of dicts:
```json
{
"priority/critical": {
"color": "ff00ff",
"id": 2058
},
"priority/high": {
"color": "ff00ff",
"id": 1136
},
...
}
```
Each dict is an instruction to replace any label of the specified name and color with the label
that has the given id. So if an issue has a label named "priority/critical" with the color
ff00ff, it will be replaced with the label whose id is 2058. You can adjust the default file or
write your own from scratch to achieve the exact replacements that are appropriate for your
situation. You can find label names and colors in the Forge web UI. In the repository, go to
Issues and then click Labels. Assuming you have admin access to the repository, labels with ''Edit''
and ''Delete'' buttons are specific to that repository; you can see each label's name and color by
clicking ''Edit''. You can find label IDs via the API. To find the org-wide labels for a given org,
go to https://forge.fedoraproject.org/api/v1/orgs/(orgname)/labels.
Run with `--help` for all available options.
## Note
Most of the code in this repository is AI-generated using Claude (Anthropic) via Cursor IDE.

261
forge_convert_labels.py Executable file
View file

@ -0,0 +1,261 @@
#!/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()

34
labels-default.json Normal file
View file

@ -0,0 +1,34 @@
{
"priority/critical": {
"color": "ff00ff",
"id": 2058
},
"priority/high": {
"color": "ff00ff",
"id": 1136
},
"priority/medium": {
"color": "ff00ff",
"id": 1137
},
"priority/normal": {
"color": "ff00ff",
"id": 1137
},
"priority/low": {
"color": "ff00ff",
"id": 1138
},
"priority/wishlist": {
"color": "ff00ff",
"id": 1138
},
"bug": {
"color": "ff0000",
"id": 1120
},
"task": {
"color": "f0c000",
"id": 1119
}
}