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