Added script used for bulk insert drafts into Github Project

This commit is contained in:
2026-04-30 21:36:09 +00:00
parent 137d5987ba
commit 88eee7ab96

186
bulk_draft_issues.py Normal file
View File

@@ -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()