add pagure_add_migration_comment.py
This commit is contained in:
commit
35977a8989
5 changed files with 549 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Python virtual environment
|
||||
.venv/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
57
README.md
Normal file
57
README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Forge Helpers
|
||||
|
||||
This repository contains various helpers used for migrating Fedora QA repositories from [Pagure](https://pagure.io/group/fedora-qa) into [Fedora Forge](https://forge.fedoraproject.org/quality) (based on [Forgejo](https://forgejo.org/)), and mass-changing the target tickets if there is some subsequent action needed.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Create and activate virtualenv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Pagure API Token
|
||||
|
||||
Several scripts require a Pagure API token for authentication. To create one:
|
||||
|
||||
1. Log in to [Pagure](https://pagure.io/)
|
||||
2. Go to your [API settings](https://pagure.io/settings#nav-api-tab)
|
||||
3. Click "Create new API key"
|
||||
4. Select the required ACLs (e.g., "Comment on a ticket" for adding migration comments)
|
||||
5. Save the token to a file (e.g., `~/.pagure-token`) for use with `--token-file`
|
||||
|
||||
## Scripts
|
||||
|
||||
### pagure_add_migration_comment.py
|
||||
|
||||
Adds a migration notice comment to Pagure tickets, linking to their new Forge location.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
# Dry run on all issues (preview without making changes)
|
||||
python pagure_add_migration_comment.py https://pagure.io/fedora-qa/some-repo \
|
||||
https://forge.fedoraproject.org/quality/some-repo \
|
||||
--token-file ~/.pagure-token --dry-run
|
||||
|
||||
# Process specific issues on staging Pagure
|
||||
python pagure_add_migration_comment.py https://stg.pagure.io/fedora-qa/some-repo \
|
||||
https://forge.fedoraproject.org/quality/some-repo \
|
||||
--token-file ~/.pagure-token --issues 1 2 3
|
||||
|
||||
# Process all issues with custom comment
|
||||
python pagure_add_migration_comment.py https://pagure.io/fedora-qa/some-repo \
|
||||
https://forge.fedoraproject.org/quality/some-repo \
|
||||
--token-file ~/.pagure-token \
|
||||
--comment "Moved to: {forge_url}"
|
||||
```
|
||||
|
||||
Run with `--help` for all available options.
|
||||
|
||||
## Note
|
||||
|
||||
Most of the code in this repository is AI-generated using Claude (Anthropic) via Cursor IDE.
|
||||
|
||||
68
pagure-migration-comment.plan.md
Normal file
68
pagure-migration-comment.plan.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Pagure Migration Comment Helper
|
||||
|
||||
## Goal
|
||||
|
||||
Create a Python script that adds a comment to old Pagure tickets notifying users that the ticket has been migrated to Fedora Forge, with a hyperlink to the new location.
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Project Setup
|
||||
|
||||
- Create a Python virtualenv in `.venv/` directory
|
||||
- Create `requirements.txt` with `requests` library for API calls
|
||||
- Install dependencies into the virtualenv
|
||||
- Add `.venv/` to `.gitignore`
|
||||
- Create main script `pagure_add_migration_comment.py`
|
||||
|
||||
### 2. Script Features
|
||||
|
||||
The script will:
|
||||
|
||||
- Accept command-line arguments for:
|
||||
- Full Pagure project URL (e.g., `https://pagure.io/fedora-qa/some-repo` or `https://stg.pagure.io/fedora-qa/some-repo`)
|
||||
- Forge project URL base (to construct target links)
|
||||
- Pagure API token (via `--token` on command line or `--token-file` to read from file)
|
||||
- Optional: specific issue number(s) or "all" to process all open issues
|
||||
- Optional: dry-run mode to preview without making changes
|
||||
- Optional: custom comment template
|
||||
- Optional: delay between API calls (default: 1 second)
|
||||
- Query Pagure API to get issue(s)
|
||||
- Construct the corresponding Forge URL (same issue number, different base URL)
|
||||
- Add a comment to each Pagure issue via API
|
||||
|
||||
### 3. Pagure API Integration
|
||||
|
||||
- Use Pagure API endpoint: `POST /api/0/<repo>/issue/<issue_id>/comment`
|
||||
- Authentication via API token in headers
|
||||
- Comment text will be configurable but default to something like:
|
||||
> "This issue has been migrated to Fedora Forge: <link>"
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- Handle rate limiting (configurable delay between requests)
|
||||
- Log successes and failures
|
||||
- Support resuming if interrupted (skip already-commented issues)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Activate virtualenv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Dry run on all issues (token from file)
|
||||
python pagure_add_migration_comment.py https://pagure.io/fedora-qa/some-repo \
|
||||
https://forge.fedoraproject.org/quality/some-repo \
|
||||
--token-file ~/.pagure-token --dry-run
|
||||
|
||||
# Process specific issues on staging Pagure
|
||||
python pagure_add_migration_comment.py https://stg.pagure.io/fedora-qa/some-repo \
|
||||
https://forge.fedoraproject.org/quality/some-repo \
|
||||
--token YOUR_TOKEN --issues 1 2 3
|
||||
|
||||
# Process all issues with custom comment
|
||||
python pagure_add_migration_comment.py https://pagure.io/fedora-qa/some-repo \
|
||||
https://forge.fedoraproject.org/quality/some-repo \
|
||||
--token-file ~/.pagure-token \
|
||||
--comment "Moved to: {forge_url}"
|
||||
```
|
||||
|
||||
411
pagure_add_migration_comment.py
Executable file
411
pagure_add_migration_comment.py
Executable file
|
|
@ -0,0 +1,411 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add migration notice comments to Pagure tickets, linking to their new Forge locations.
|
||||
|
||||
This script adds a comment to old Pagure tickets notifying users that the ticket
|
||||
has been migrated to Fedora Forge, with a hyperlink to the new location.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
DEFAULT_COMMENT_TEMPLATE = (
|
||||
"This issue has been migrated to Fedora Forge:\n{forge_url}\n\n"
|
||||
"Please continue any further discussion there."
|
||||
)
|
||||
MIGRATION_MARKER = "This issue has been migrated to Fedora Forge:"
|
||||
|
||||
# Ensure the marker is present in the default template to detect already-migrated issues
|
||||
assert MIGRATION_MARKER in DEFAULT_COMMENT_TEMPLATE, (
|
||||
"MIGRATION_MARKER must be contained in DEFAULT_COMMENT_TEMPLATE"
|
||||
)
|
||||
|
||||
|
||||
def parse_pagure_url(pagure_url: str) -> tuple[str, str]:
|
||||
"""
|
||||
Parse a Pagure project URL into API base URL and project path.
|
||||
|
||||
Args:
|
||||
pagure_url: Full URL to the Pagure project
|
||||
(e.g., 'https://pagure.io/fedora-qa/some-repo' or
|
||||
'https://stg.pagure.io/fedora-qa/some-repo')
|
||||
|
||||
Returns:
|
||||
Tuple of (api_base, project_path)
|
||||
e.g., ('https://pagure.io/api/0', 'fedora-qa/some-repo')
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL is invalid
|
||||
"""
|
||||
parsed = urlparse(pagure_url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
raise ValueError(f"Invalid Pagure URL: {pagure_url}")
|
||||
|
||||
api_base = f"{parsed.scheme}://{parsed.netloc}/api/0"
|
||||
# Remove leading slash from path
|
||||
project_path = parsed.path.lstrip("/")
|
||||
|
||||
if not project_path:
|
||||
raise ValueError(f"No project path found in URL: {pagure_url}")
|
||||
|
||||
return api_base, project_path
|
||||
|
||||
|
||||
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 get_pagure_issues(
|
||||
api_base: str, project: str, api_token: str, status: str = "all", delay: float = 1.0
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Fetch issues from a Pagure project.
|
||||
|
||||
Args:
|
||||
api_base: Pagure API base URL (e.g., 'https://pagure.io/api/0')
|
||||
project: Pagure project path (e.g., 'fedora-qa/some-repo')
|
||||
api_token: Pagure API token for authentication
|
||||
status: Issue status filter ('all', 'Open', 'Closed')
|
||||
delay: Delay between API calls in seconds (for pagination)
|
||||
|
||||
Returns:
|
||||
List of issue dictionaries
|
||||
"""
|
||||
url = f"{api_base}/{project}/issues"
|
||||
headers = {"Authorization": f"token {api_token}"}
|
||||
params = {"status": status, "per_page": 100}
|
||||
|
||||
all_issues = []
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
params["page"] = page
|
||||
response = requests.get(url, headers=headers, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
issues = data.get("issues", [])
|
||||
if not issues:
|
||||
break
|
||||
|
||||
all_issues.extend(issues)
|
||||
page += 1
|
||||
|
||||
# Check if we've fetched all pages
|
||||
if len(issues) < params["per_page"]:
|
||||
break
|
||||
|
||||
# Delay between page fetches to avoid rate limiting
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
return all_issues
|
||||
|
||||
|
||||
def get_issue_comments(
|
||||
api_base: str, project: str, issue_id: int, api_token: str
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Fetch comments for a specific issue.
|
||||
|
||||
Args:
|
||||
api_base: Pagure API base URL
|
||||
project: Pagure project path
|
||||
issue_id: Issue number
|
||||
api_token: Pagure API token
|
||||
|
||||
Returns:
|
||||
List of comment dictionaries
|
||||
"""
|
||||
url = f"{api_base}/{project}/issue/{issue_id}"
|
||||
headers = {"Authorization": f"token {api_token}"}
|
||||
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return data.get("comments", [])
|
||||
|
||||
|
||||
def has_migration_comment(comments: list[dict]) -> bool:
|
||||
"""
|
||||
Check if any comment already contains the migration notice.
|
||||
|
||||
Args:
|
||||
comments: List of comment dictionaries
|
||||
|
||||
Returns:
|
||||
True if migration comment exists, False otherwise
|
||||
"""
|
||||
for comment in comments:
|
||||
if MIGRATION_MARKER in comment.get("comment", ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def add_comment_to_issue(
|
||||
api_base: str, project: str, issue_id: int, comment: str, api_token: str
|
||||
) -> bool:
|
||||
"""
|
||||
Add a comment to a Pagure issue.
|
||||
|
||||
Args:
|
||||
api_base: Pagure API base URL
|
||||
project: Pagure project path
|
||||
issue_id: Issue number
|
||||
comment: Comment text to add
|
||||
api_token: Pagure API token
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
url = f"{api_base}/{project}/issue/{issue_id}/comment"
|
||||
headers = {"Authorization": f"token {api_token}"}
|
||||
data = {"comment": comment}
|
||||
|
||||
response = requests.post(url, headers=headers, data=data, timeout=30)
|
||||
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
print(f" Error adding comment: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
|
||||
def construct_forge_url(forge_base: str, issue_id: int) -> str:
|
||||
"""
|
||||
Construct the Forge issue URL from the base URL and issue ID.
|
||||
|
||||
Args:
|
||||
forge_base: Base URL for the Forge project (e.g., 'https://forge.fedoraproject.org/quality/some-repo')
|
||||
issue_id: Issue number
|
||||
|
||||
Returns:
|
||||
Full Forge issue URL
|
||||
"""
|
||||
# Remove trailing slash if present
|
||||
forge_base = forge_base.rstrip("/")
|
||||
return f"{forge_base}/issues/{issue_id}"
|
||||
|
||||
|
||||
def process_issues(
|
||||
api_base: str,
|
||||
project: str,
|
||||
forge_base: str,
|
||||
api_token: str,
|
||||
issue_numbers: list[int] | None,
|
||||
comment_template: str,
|
||||
dry_run: bool,
|
||||
delay: float,
|
||||
) -> tuple[int, int, int]:
|
||||
"""
|
||||
Process issues and add migration comments.
|
||||
|
||||
Args:
|
||||
api_base: Pagure API base URL
|
||||
project: Pagure project path
|
||||
forge_base: Base URL for the Forge project
|
||||
api_token: Pagure API token
|
||||
issue_numbers: Specific issue numbers to process, or None for all
|
||||
comment_template: Template for the migration comment
|
||||
dry_run: If True, don't actually add comments
|
||||
delay: Delay between API calls in seconds
|
||||
|
||||
Returns:
|
||||
Tuple of (processed, skipped, failed) counts
|
||||
"""
|
||||
processed = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
if issue_numbers:
|
||||
# Process specific issues
|
||||
issues = [{"id": num} for num in issue_numbers]
|
||||
else:
|
||||
# Fetch all issues
|
||||
print(f"Fetching issues from {project}...")
|
||||
issues = get_pagure_issues(api_base, project, api_token, delay=delay)
|
||||
print(f"Found {len(issues)} issues")
|
||||
|
||||
for issue in issues:
|
||||
issue_id = issue["id"]
|
||||
forge_url = construct_forge_url(forge_base, issue_id)
|
||||
comment = comment_template.format(forge_url=forge_url, issue_id=issue_id)
|
||||
|
||||
print(f"\nProcessing issue #{issue_id}...")
|
||||
|
||||
# Check for existing migration comment
|
||||
try:
|
||||
comments = get_issue_comments(api_base, project, issue_id, api_token)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f" Error fetching issue: {e}")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
if has_migration_comment(comments):
|
||||
print(" Skipping: migration comment already exists")
|
||||
skipped += 1
|
||||
# Delay after API call to avoid rate limiting
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] Would add comment linking to: {forge_url}")
|
||||
processed += 1
|
||||
# Delay after API call to avoid rate limiting
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
else:
|
||||
print(f" Adding migration comment linking to: {forge_url}")
|
||||
if add_comment_to_issue(api_base, project, issue_id, comment, api_token):
|
||||
print(" Success!")
|
||||
processed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Delay after API call to avoid rate limiting
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
return processed, skipped, failed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add migration notice comments to Pagure tickets",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Dry run on all issues (token from file)
|
||||
%(prog)s https://pagure.io/fedora-qa/some-repo https://forge.fedoraproject.org/quality/some-repo --token-file ~/.pagure-token --dry-run
|
||||
|
||||
# Process specific issues on staging Pagure
|
||||
%(prog)s https://stg.pagure.io/fedora-qa/some-repo https://forge.fedoraproject.org/quality/some-repo --token YOUR_TOKEN --issues 1 2 3
|
||||
|
||||
# Process all issues with custom comment
|
||||
%(prog)s https://pagure.io/fedora-qa/some-repo https://forge.fedoraproject.org/quality/some-repo --token-file ~/.pagure-token --comment "Moved to: {forge_url}"
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"pagure_url",
|
||||
help="Full URL to the Pagure project (e.g., 'https://pagure.io/fedora-qa/some-repo' or 'https://stg.pagure.io/fedora-qa/some-repo')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"forge_base",
|
||||
help="Base URL for 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="Pagure API token for authentication",
|
||||
)
|
||||
token_group.add_argument(
|
||||
"--token-file",
|
||||
type=Path,
|
||||
help="Path to file containing the Pagure API token",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--issues",
|
||||
type=int,
|
||||
nargs="+",
|
||||
help="Specific issue numbers to process (default: all issues)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--comment",
|
||||
default=DEFAULT_COMMENT_TEMPLATE,
|
||||
help="Custom comment template. Use {forge_url} and {issue_id} as placeholders",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Preview changes without actually adding comments",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delay",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Delay between API calls in seconds (default: 1.0)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Parse the Pagure URL
|
||||
try:
|
||||
api_base, project = parse_pagure_url(args.pagure_url)
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Get the API token
|
||||
if args.token:
|
||||
api_token = args.token
|
||||
else:
|
||||
try:
|
||||
api_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)
|
||||
|
||||
print(f"Pagure URL: {args.pagure_url}")
|
||||
print(f"Forge base URL: {args.forge_base}")
|
||||
if args.dry_run:
|
||||
print("Mode: DRY RUN (no changes will be made)")
|
||||
print()
|
||||
|
||||
try:
|
||||
processed, skipped, failed = process_issues(
|
||||
api_base=api_base,
|
||||
project=project,
|
||||
forge_base=args.forge_base,
|
||||
api_token=api_token,
|
||||
issue_numbers=args.issues,
|
||||
comment_template=args.comment,
|
||||
dry_run=args.dry_run,
|
||||
delay=args.delay,
|
||||
)
|
||||
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" Processed: {processed}")
|
||||
print(f" Skipped (already migrated): {skipped}")
|
||||
print(f" Failed: {failed}")
|
||||
|
||||
if failed > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
requests>=2.28.0
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue