From 88eee7ab96d0751393e3672e9f1f975ac7873ea1 Mon Sep 17 00:00:00 2001 From: patryk025 Date: Thu, 30 Apr 2026 21:36:09 +0000 Subject: [PATCH] Added script used for bulk insert drafts into Github Project --- bulk_draft_issues.py | 186 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 bulk_draft_issues.py diff --git a/bulk_draft_issues.py b/bulk_draft_issues.py new file mode 100644 index 0000000..7e328c0 --- /dev/null +++ b/bulk_draft_issues.py @@ -0,0 +1,186 @@ +#!/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()