Files
BlooMooExperiments/bulk_draft_issues.py

187 lines
6.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Masowe tworzenie Draft issues w GitHub Projects v2 (GraphQL).
- Czyta CSV z kolumnami: title, body
- Sam wykrywa czy owner to org czy user
- Tworzy drafty via createProjectV2DraftIssue
- Proste retry i batchowanie
Użycie:
export GITHUB_TOKEN="ghp_..."
python3 bulk_draft_issues.py \
--owner my-org-or-user \
--project-number 12 \
--csv drafts.csv \
[--dry-run] [--prefix "[BACKLOG] "] [--start-index 1]
Wymagania: Python 3.8+, pip install requests
"""
import csv
import os
import sys
import time
import argparse
import requests
from typing import Optional, Tuple
GITHUB_API = "https://api.github.com/graphql"
QUERY_USER_PROJECT_ID = """
query($owner:String!, $number:Int!) {
user(login: $owner) {
projectV2(number: $number) { id title number }
}
}
"""
QUERY_ORG_PROJECT_ID = """
query($owner:String!, $number:Int!) {
organization(login: $owner) {
projectV2(number: $number) { id title number }
}
}
"""
MUTATION_CREATE_DRAFT = """
mutation($projectId: ID!, $title: String!, $body: String) {
addProjectV2DraftIssue(input: {
projectId: $projectId,
title: $title,
body: $body
}) {
projectItem { id }
}
}
"""
def gh_graphql(token: str, query: str, variables: dict, max_retries: int = 3) -> dict:
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json"
}
for attempt in range(1, max_retries + 1):
resp = requests.post(GITHUB_API, json={"query": query, "variables": variables}, headers=headers, timeout=30)
if resp.status_code == 200:
data = resp.json()
if "errors" in data:
# GraphQL-level errors (e.g., permissions, bad input)
raise RuntimeError(f"GraphQL errors: {data['errors']}")
return data["data"]
# HTTP-level issues (rate limits, etc.)
if resp.status_code in (502, 503, 504, 403, 429):
wait = min(2 ** attempt, 30)
time.sleep(wait)
continue
resp.raise_for_status()
raise RuntimeError("Failed to call GitHub GraphQL after retries.")
def resolve_project_id(token: str, owner: str, number: int) -> Tuple[str, str]:
# Spróbuj jako USER
try:
d_user = gh_graphql(token, QUERY_USER_PROJECT_ID, {"owner": owner, "number": number})
if d_user.get("user") and d_user["user"].get("projectV2"):
proj = d_user["user"]["projectV2"]
return proj["id"], "user"
except Exception:
pass
# Spróbuj jako ORG
try:
d_org = gh_graphql(token, QUERY_ORG_PROJECT_ID, {"owner": owner, "number": number})
if d_org.get("organization") and d_org["organization"].get("projectV2"):
proj = d_org["organization"]["projectV2"]
return proj["id"], "organization"
except Exception:
pass
raise RuntimeError(
f"Nie znalazłem Project v2 nr {number} dla ownera '{owner}'. "
f"Upewnij się, że to **Projects (v2)** i numer się zgadza."
)
def create_draft_issue(token: str, project_id: str, title: str, body: Optional[str] = None) -> str:
variables = {
"projectId": project_id,
"title": title.strip(),
"body": (body.strip() if body and body.strip() else None),
}
data = gh_graphql(token, MUTATION_CREATE_DRAFT, variables)
return data["addProjectV2DraftIssue"]["projectItem"]["id"]
def read_csv(path: str):
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for i, row in enumerate(reader, start=1):
title = (row.get("title") or "").strip()
body = (row.get("body") or "").strip()
if not title:
print(f"[WARN] Wiersz {i}: brak 'title' pomijam.", file=sys.stderr)
continue
yield {"title": title, "body": body}
def main():
parser = argparse.ArgumentParser(description="Bulk Draft Issues → GitHub Project v2")
parser.add_argument("--owner", required=True, help="Login usera lub organizacji posiadającej Project v2")
parser.add_argument("--project-number", required=True, type=int, help="Numer projektu (z URL)")
parser.add_argument("--csv", required=True, help="Ścieżka do CSV z kolumnami: title, body")
parser.add_argument("--prefix", default="", help="Opcjonalny prefiks dopisywany do tytułu, np. '[BACKLOG] '")
parser.add_argument("--dry-run", action="store_true", help="Nic nie tworzy tylko wypisuje, co by zrobił")
parser.add_argument("--start-index", type=int, default=None, help="Jeśli podasz, doda numerację 'NN. ' przed tytułem, zaczynając od tej liczby")
args = parser.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
print("ERROR: Brak zmiennej środowiskowej GITHUB_TOKEN.", file=sys.stderr)
sys.exit(1)
try:
project_id, scope = resolve_project_id(token, args.owner, args.project_number)
print(f"[OK] Project ID resolved ({scope}): {project_id}")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
items = list(read_csv(args.csv))
if not items:
print("ERROR: CSV nie zawiera poprawnych rekordów (kolumna 'title' wymagana).", file=sys.stderr)
sys.exit(1)
created = 0
errors = 0
idx = args.start_index if args.start_index is not None else None
for i, item in enumerate(items, start=1):
title = f"{args.prefix}{item['title']}"
if idx is not None:
title = f"{idx}. {title}"
idx += 1
body = item.get("body") or None
if args.dry_run:
print(f"[DRY-RUN] Would create draft: title='{title}' body_len={len(body) if body else 0}")
created += 1
continue
try:
draft_id = create_draft_issue(token, project_id, title, body)
print(f"[OK] Draft created: {draft_id}'{title}'")
created += 1
# lekka pauza, żeby być milszym dla API
time.sleep(0.2)
except Exception as e:
print(f"[ERR] '{title}': {e}", file=sys.stderr)
errors += 1
# króciutki backoff po błędzie
time.sleep(1)
print(f"\n== Podsumowanie ==")
print(f"Utworzono: {created}")
print(f"Błędy: {errors}")
if __name__ == "__main__":
main()