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:
parent
fb9cf8f80b
commit
79855da7be
3 changed files with 342 additions and 0 deletions
47
README.md
47
README.md
|
|
@ -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
261
forge_convert_labels.py
Executable 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
34
labels-default.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue