Added script used for bulk insert drafts into Github Project
This commit is contained in:
186
bulk_draft_issues.py
Normal file
186
bulk_draft_issues.py
Normal 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()
|
||||||
Reference in New Issue
Block a user