#!/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()