#!/usr/bin/env python3 import subprocess import os import re import argparse # --- Configuration --- INPUT_FILE = "issues-to-create.md" DEFAULT_PROJECT_TITLE = None DEFAULT_MILESTONE = "Milestone 4" # --- def parse_issues(content): """Parse issue blocks from markdown content. Each issue block starts with a '# Title' line, followed by an optional 'Labels:' metadata line, then the body. Milestone is set globally, not per-issue. """ issue_blocks = re.split(r'\n(?=#\s)', content) issues = [] for block in issue_blocks: if not block.strip(): continue lines = block.strip().split('\n') # Title: strip leading '#' characters and whitespace title = re.sub(r'^#+\s*', '', lines[0]).strip() labels_line = "" body_start_index = len(lines) # default: no body # Only 'Labels:' is parsed from the markdown; milestone is global for i, line in enumerate(lines[1:], start=1): stripped = line.strip() if stripped.lower().startswith('labels:'): labels_line = stripped.split(':', 1)[1].strip() elif stripped == "": continue # skip blank separator lines in the header else: body_start_index = i break body = "\n".join(lines[body_start_index:]).strip() labels = [label.strip() for label in labels_line.split(',') if label.strip()] if not title: print("⚠️ Skipping block with no title.") continue issues.append({ "title": title, "body": body, "labels": labels, }) return issues def main(): parser = argparse.ArgumentParser( description="Bulk create GitHub issues from a markdown file.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Input file format (issues-to-create.md): ----------------------------------------- # Issue Title One Labels: bug, enhancement This is the body of the first issue. It can span multiple lines. # Issue Title Two Labels: documentation Body of the second issue. ----------------------------------------- All issues share the same project and milestone, configured at the top of this script or passed via --project and --milestone flags. """ ) parser.add_argument( "--file", "-f", default=INPUT_FILE, help=f"Path to the markdown input file (default: {INPUT_FILE})" ) parser.add_argument( "--project", "-p", default=DEFAULT_PROJECT_TITLE, help=f"GitHub Project title for all issues (default: {DEFAULT_PROJECT_TITLE})" ) parser.add_argument( "--milestone", "-m", default=DEFAULT_MILESTONE, help=f"Milestone to assign to all issues (default: {DEFAULT_MILESTONE})" ) parser.add_argument( "--no-project", action="store_true", help="Do not add issues to any project." ) parser.add_argument( "--no-milestone", action="store_true", help="Do not assign a milestone to any issue." ) parser.add_argument( "--repo", "-r", default=None, help="Target GitHub repo in OWNER/REPO format (uses gh default if not set)." ) parser.add_argument( "--dry-run", action="store_true", help="Parse the file and print issues without creating them." ) args = parser.parse_args() input_file = args.file project_title = args.project if not args.no_project else None milestone = args.milestone if not args.no_milestone else None print("🚀 Bulk GitHub Issue Creator") print("=" * 40) print(f" Input file: {input_file}") print(f" Project: {project_title or '(none)'}") print(f" Milestone: {milestone or '(none)'}") if args.repo: print(f" Repo: {args.repo}") if args.dry_run: print(" Mode: DRY RUN (no issues will be created)") print("=" * 40) # --- Preflight checks --- if subprocess.run(["which", "gh"], capture_output=True).returncode != 0: print("❌ ERROR: GitHub CLI ('gh') is not installed or not in PATH.") print(" Install it from: https://cli.github.com/") exit(1) if not os.path.exists(input_file): print(f"❌ ERROR: Input file '{input_file}' not found.") exit(1) print("✅ Preflight checks passed.\n") # --- Parse --- print(f"📄 Parsing '{input_file}'...") with open(input_file, 'r') as f: content = f.read() issues = parse_issues(content) if not issues: print("⚠️ No issues found in the input file. Check the format.") exit(0) print(f" Found {len(issues)} issue(s) to create.\n") # --- Create --- success_count = 0 fail_count = 0 for idx, issue in enumerate(issues, start=1): print(f"[{idx}/{len(issues)}] {issue['title']}") if issue['labels']: print(f" Labels: {', '.join(issue['labels'])}") print(f" Milestone: {milestone or '(none)'}") print(f" Project: {project_title or '(none)'}") if args.dry_run: print(" (dry-run — skipping creation)\n") continue command = ["gh", "issue", "create"] if args.repo: command.extend(["--repo", args.repo]) command.extend(["--title", issue["title"]]) command.extend(["--body", issue["body"] or " "]) # gh requires non-empty body if project_title: command.extend(["--project", project_title]) if milestone: command.extend(["--milestone", milestone]) for label in issue["labels"]: command.extend(["--label", label]) try: result = subprocess.run(command, check=True, text=True, capture_output=True) print(f" ✅ Created: {result.stdout.strip()}") success_count += 1 except subprocess.CalledProcessError as e: print(f" ❌ Failed: {e.stderr.strip()}") fail_count += 1 print() # --- Summary --- print("=" * 40) if args.dry_run: print(f"🔍 Dry run complete. {len(issues)} issue(s) parsed, none created.") else: print(f"🎉 Done! {success_count} created, {fail_count} failed.") if __name__ == "__main__": main()