|
#!/usr/bin/env -S uv run --script |
|
# /// script |
|
# requires-python = ">=3.10" |
|
# /// |
|
""" |
|
md2gist - Upload markdown files to per-branch GitHub gists. |
|
|
|
SPDX-License-Identifier: MIT |
|
Copyright (c) 2026 Simon Massey |
|
|
|
Requires: uv, git, gh CLI |
|
|
|
Registry Mapping: |
|
Maintains a registry gist that maps org/repo/branch -> gist_url. |
|
Each branch gets its own gist for feature branch reports, notes, analysis. |
|
|
|
Usage: |
|
md2gist file.md [-q|--quiet] [--create-gist true|false] [--save-location local|home] |
|
|
|
Example: |
|
md2gist analysis.md # interactive, prompts for registry |
|
md2gist analysis.md -q # auto-create registry, save to .md2gist |
|
""" |
|
|
|
import argparse |
|
import json |
|
import subprocess |
|
import sys |
|
from datetime import datetime |
|
from pathlib import Path |
|
from typing import Any |
|
from urllib.parse import urlparse |
|
|
|
|
|
def run_cmd(cmd: list[str]) -> tuple[str, str, int]: |
|
""" |
|
Execute a shell command. |
|
|
|
Returns a tuple of (stdout, stderr, return_code). |
|
""" |
|
result = subprocess.run(cmd, capture_output=True, text=True) |
|
return result.stdout.strip(), result.stderr.strip(), result.returncode |
|
|
|
|
|
def get_current_branch() -> str: |
|
""" |
|
Get current git branch name. |
|
|
|
Raises RuntimeError if not in a git repository. |
|
""" |
|
stdout, _, code = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]) |
|
if code != 0: |
|
raise RuntimeError("Not in a git repository") |
|
return stdout |
|
|
|
|
|
def get_repo_info() -> tuple[str, str]: |
|
""" |
|
Get current git repository owner and name. |
|
|
|
Returns (owner, repo_name). Raises RuntimeError if not in git repo or cannot parse remote. |
|
""" |
|
# Try to get remote URL - first try 'origin', then any available remote |
|
remote_url, _, code = run_cmd(["git", "config", "--get", "remote.origin.url"]) |
|
if code != 0: |
|
# If 'origin' doesn't exist, get the first available remote |
|
remote_url, _, code = run_cmd(["git", "config", "--get-regexp", "remote\\..*\\.url"]) |
|
if code != 0: |
|
raise RuntimeError("Cannot get git remote URL") |
|
# Extract first remote URL from output (format: "remote.name.url <url>") |
|
remote_url = remote_url.split('\n')[0].split(None, 1)[1] if remote_url else "" |
|
if not remote_url: |
|
raise RuntimeError("Cannot get git remote URL") |
|
|
|
# Parse owner/repo from URL or SSH |
|
# SSH: git@github.com:owner/repo.git |
|
# HTTPS: https://github.com/owner/repo.git |
|
if "@" in remote_url: # SSH format |
|
parts = remote_url.split(":")[-1] |
|
else: # HTTPS format |
|
parts = remote_url.split("/")[-2:] |
|
parts = "/".join(parts) |
|
|
|
parts = parts.rstrip(".git") |
|
if "/" not in parts: |
|
raise RuntimeError(f"Cannot parse owner/repo from remote URL: {remote_url}") |
|
|
|
owner, repo = parts.split("/") |
|
return owner, repo |
|
|
|
|
|
def find_config_file() -> Path | None: |
|
""" |
|
Look for .md2gist config file in current dir, then home dir. |
|
|
|
Returns Path to config file or None if not found. |
|
""" |
|
local_config = Path(".md2gist") |
|
if local_config.exists(): |
|
return local_config |
|
|
|
home_config = Path.home() / ".md2gist" |
|
if home_config.exists(): |
|
return home_config |
|
|
|
return None |
|
|
|
|
|
def read_registry_gist(registry_url: str) -> dict[str, str]: |
|
""" |
|
Read registry gist content and parse mappings. |
|
|
|
Expected format: |
|
# DO NOT EDIT - Auto-generated by md2gist tool |
|
# Format: org/repo/branch -> gist_url |
|
|
|
- org/repo/branch -> https://gist.github.com/user/id |
|
|
|
Returns dict mapping "org/repo/branch" -> "gist_url" |
|
""" |
|
gist_id = registry_url.split("/")[-1] |
|
stdout, _, code = run_cmd(["gh", "gist", "view", gist_id]) |
|
if code != 0: |
|
return {} |
|
|
|
mappings = {} |
|
for line in stdout.split("\n"): |
|
line = line.strip() |
|
if line.startswith("-") and "->" in line: |
|
parts = line.lstrip("- ").split(" -> ") |
|
if len(parts) == 2: |
|
key = parts[0].strip() |
|
url = parts[1].strip() |
|
mappings[key] = url |
|
|
|
return mappings |
|
|
|
|
|
def find_gist_for_context(registry_url: str, owner: str, repo: str, branch: str) -> str | None: |
|
""" |
|
Look up gist URL for org/repo/branch in registry. |
|
|
|
Returns gist URL or None if not found. |
|
""" |
|
mappings = read_registry_gist(registry_url) |
|
key = f"{owner}/{repo}/{branch}" |
|
return mappings.get(key) |
|
|
|
|
|
def create_registry_gist() -> str | None: |
|
""" |
|
Create a new empty registry gist. |
|
|
|
Returns gist URL or None on error. |
|
""" |
|
description = "md2gist registry - DO NOT EDIT - Auto-generated tool mappings" |
|
content = """# DO NOT EDIT - Auto-generated by md2gist tool |
|
# Format: org/repo/branch -> gist_url |
|
|
|
""" |
|
|
|
result = subprocess.run( |
|
[ |
|
"gh", |
|
"gist", |
|
"create", |
|
"--desc", |
|
description, |
|
"--filename", |
|
"md2gist.md", |
|
"-", |
|
], |
|
input=content, |
|
capture_output=True, |
|
text=True, |
|
) |
|
|
|
if result.returncode != 0: |
|
print(f"Error creating registry gist: {result.stderr}", file=sys.stderr) |
|
return None |
|
|
|
gist_url = result.stdout.strip().split("\n")[0] |
|
return gist_url if gist_url else None |
|
|
|
|
|
def prompt_for_registry(create_gist: bool = True, save_location: str = "local", quiet: bool = False) -> str | None: |
|
""" |
|
Prompt user to create registry gist and choose save location. |
|
|
|
Args: |
|
create_gist: Whether to create a new registry gist (default True) |
|
save_location: "local" for .md2gist (default) or "home" for ~/.md2gist |
|
quiet: If True, skip prompts and use defaults |
|
|
|
Returns registry URL or None if user cancels. |
|
""" |
|
if not create_gist: |
|
print("Cancelled.") |
|
return None |
|
|
|
if not quiet: |
|
print("\nNo md2gist registry found.") |
|
response = input("Create new registry gist? (Y/n): ").strip().lower() |
|
if response == "n": |
|
print("Cancelled.") |
|
return None |
|
|
|
print("Creating registry gist...") |
|
registry_url = create_registry_gist() |
|
|
|
if not registry_url: |
|
print("Failed to create registry gist.", file=sys.stderr) |
|
return None |
|
|
|
print(f"Registry created: {registry_url}") |
|
|
|
if not quiet: |
|
print() |
|
print("Where should we save the registry URL?") |
|
print(" 1. .md2gist (repo-local, checked into git)") |
|
print(" 2. ~/.md2gist (home directory, global to all repos)") |
|
choice = input("Choose (1 or 2): ").strip() |
|
save_location = "local" if choice == "1" else "home" if choice == "2" else "" |
|
|
|
if save_location == "local": |
|
save_path = Path(".md2gist") |
|
elif save_location == "home": |
|
save_path = Path.home() / ".md2gist" |
|
else: |
|
if not quiet: |
|
print("Invalid choice.") |
|
return registry_url |
|
|
|
try: |
|
save_path.write_text(registry_url) |
|
if not quiet: |
|
print(f"Saved to {save_path}") |
|
except Exception as e: |
|
print(f"Warning: Could not save to {save_path}: {e}", file=sys.stderr) |
|
|
|
return registry_url |
|
|
|
|
|
def get_gist_from_api(gist_id: str) -> dict[str, Any] | None: |
|
""" |
|
Fetch gist details from GitHub API. |
|
|
|
Returns parsed JSON response or None on error. |
|
""" |
|
stdout, _, code = run_cmd(["gh", "api", f"gists/{gist_id}"]) |
|
if code != 0: |
|
return None |
|
try: |
|
result: dict[str, Any] = json.loads(stdout) |
|
return result |
|
except json.JSONDecodeError as e: |
|
print(f"Error parsing gist response: {e}", file=sys.stderr) |
|
return None |
|
|
|
|
|
def create_gist(filename: str, content: str, owner: str, repo: str, branch: str) -> dict[str, Any] | None: |
|
""" |
|
Create a new gist with given filename and content. |
|
|
|
Gist is named "owner/repo/branch Analysis files". |
|
Returns gist API response dict or None on error. |
|
""" |
|
description = f"{owner}/{repo}/{branch} Analysis files" |
|
|
|
result = subprocess.run( |
|
[ |
|
"gh", |
|
"gist", |
|
"create", |
|
"--desc", |
|
description, |
|
"--filename", |
|
filename, |
|
"-", |
|
], |
|
input=content, |
|
capture_output=True, |
|
text=True, |
|
) |
|
|
|
if result.returncode != 0: |
|
print(f"Error creating gist: {result.stderr}", file=sys.stderr) |
|
return None |
|
|
|
gist_url = result.stdout.strip().split("\n")[0] |
|
if gist_url: |
|
gist_id = gist_url.split("/")[-1] |
|
return get_gist_from_api(gist_id) |
|
return None |
|
|
|
|
|
def update_registry(registry_url: str, owner: str, repo: str, branch: str, gist_url: str) -> bool: |
|
""" |
|
Add or update a mapping in the registry gist. |
|
|
|
Returns True on success, False on error. |
|
""" |
|
registry_id = registry_url.split("/")[-1] |
|
mappings = read_registry_gist(registry_url) |
|
|
|
# Get current content |
|
stdout, _, code = run_cmd(["gh", "gist", "view", registry_id]) |
|
if code != 0: |
|
return False |
|
|
|
# Add new mapping |
|
key = f"{owner}/{repo}/{branch}" |
|
mappings[key] = gist_url |
|
|
|
# Build new content |
|
new_content = """# DO NOT EDIT - Auto-generated by md2gist tool |
|
# Format: org/repo/branch -> gist_url |
|
|
|
""" |
|
for k, v in sorted(mappings.items()): |
|
new_content += f"- {k} -> {v}\n" |
|
|
|
# Update registry gist |
|
payload = json.dumps({"files": {"md2gist.md": {"content": new_content}}}) |
|
|
|
result = subprocess.run( |
|
["gh", "api", f"gists/{registry_id}", "-X", "PATCH", "--input", "-"], |
|
input=payload, |
|
capture_output=True, |
|
text=True, |
|
) |
|
|
|
return result.returncode == 0 |
|
|
|
|
|
def update_gist_file(gist_id: str, filename: str, content: str) -> bool: |
|
""" |
|
Update or add a file in an existing gist using GitHub API. |
|
|
|
Uses PATCH /gists/{id} to add or replace file content. |
|
""" |
|
payload = json.dumps({"files": {filename: {"content": content}}}) |
|
|
|
result = subprocess.run( |
|
["gh", "api", f"gists/{gist_id}", "-X", "PATCH", "--input", "-"], |
|
input=payload, |
|
capture_output=True, |
|
text=True, |
|
) |
|
|
|
if result.returncode != 0: |
|
print(f"Error updating gist file: {result.stderr}", file=sys.stderr) |
|
return False |
|
return True |
|
|
|
|
|
def format_timestamp(iso_string: str) -> str: |
|
""" |
|
Convert ISO 8601 timestamp to readable format "YYYY-MM-DD HH:MM:SS". |
|
|
|
Returns original string if parsing fails. |
|
""" |
|
try: |
|
dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00")) |
|
return dt.strftime("%Y-%m-%d %H:%M:%S") |
|
except ValueError: |
|
return iso_string |
|
|
|
|
|
def _make_gist_file_anchor(filename: str) -> str: |
|
""" |
|
Generate GitHub gist anchor for a file. |
|
|
|
GitHub's anchor format: |
|
- Spaces → hyphens |
|
- Dots (except .md extension) → hyphens |
|
- Underscores → stay as underscores |
|
- Uppercase → lowercase |
|
- .md extension → -md suffix |
|
Format: file-{name-converted}-md |
|
|
|
Examples: |
|
"My File.md" → "file-my-file-md" |
|
"RIO_Bulk_Upload_Dependency_Analysis.md" → "file-rio_bulk_upload_dependency_analysis-md" |
|
"Excel_Products_Databasev5.1-10112025.analysis.20251212-173230.md" |
|
→ "file-excel_products_databasev5-1-10112025-analysis-20251212-173230-md" |
|
""" |
|
# Remove .md extension if present |
|
if filename.endswith(".md"): |
|
name_without_ext = filename[:-3] |
|
else: |
|
name_without_ext = filename |
|
|
|
# Convert to lowercase, spaces to hyphens, dots to hyphens, keep underscores |
|
anchor = ( |
|
name_without_ext.replace(" ", "-") |
|
.replace(".", "-") |
|
.lower() |
|
) |
|
|
|
# Add -md suffix (was the extension) |
|
return f"file-{anchor}-md" |
|
|
|
|
|
def display_gist_info(gist: dict[str, Any], output_format: str = "md") -> None: |
|
""" |
|
Display gist metadata and file listing in tree format. |
|
|
|
Output includes gist URL, creation/modification timestamps, and direct links |
|
to each file for easy navigation without scrolling. |
|
|
|
Args: |
|
gist: Gist API response dictionary. |
|
output_format: "md" for markdown links (default) or "html" for HTML anchor links. |
|
""" |
|
gist_id = gist.get("id", "unknown") |
|
owner_login: Any = gist.get("owner", {}) |
|
if isinstance(owner_login, dict): |
|
username = owner_login.get("login", "unknown") |
|
else: |
|
username = "unknown" |
|
gist_url = f"https://gist.github.com/{username}/{gist_id}" |
|
created = format_timestamp(str(gist.get("created_at", "N/A"))) |
|
updated = format_timestamp(str(gist.get("updated_at", "N/A"))) |
|
|
|
print(f"\n📄 Gist: {gist_url}") |
|
print(f" Created: {created}") |
|
print(f" Modified: {updated}") |
|
print() |
|
|
|
files: Any = gist.get("files", {}) |
|
if not files: |
|
print(" (empty)") |
|
return |
|
|
|
file_list = list(files.keys()) |
|
for i, filename in enumerate(file_list): |
|
is_last = i == len(file_list) - 1 |
|
prefix = "└── " if is_last else "├── " |
|
anchor = _make_gist_file_anchor(filename) |
|
file_url = f"{gist_url}#{anchor}" |
|
|
|
if output_format == "md": |
|
# Markdown link format |
|
print(f" {prefix}[{filename}]({file_url})") |
|
else: |
|
# HTML link format (default) |
|
print(f" {prefix}<a href=\"{file_url}\">{filename}</a>") |
|
|
|
|
|
def show_help() -> None: |
|
"""Display help message.""" |
|
print("Usage: md2gist [OPTIONS] <markdown-file> [<file2> ...]") |
|
print() |
|
print("Upload markdown files to GitHub gists using a registry.") |
|
print() |
|
print("Options:") |
|
print(" -f, --format {html,md} Output format for file links (default: md)") |
|
print(" md: Markdown links (best for terminals)") |
|
print(" html: HTML anchor tags") |
|
print(" -q, --quiet Non-interactive mode (default: prompt user)") |
|
print(" Creates registry if missing, saves to .md2gist") |
|
print(" --create-gist {true|false} Create new registry gist if not found (default: true)") |
|
print(" --save-location {local|home} Save registry URL to .md2gist (local) or") |
|
print(" ~/.md2gist (home) (default: local)") |
|
print(" -h, --help Show this help message") |
|
print() |
|
print("Registry:") |
|
print(" - Looks for ./.md2gist config (repo-local) or ~/.md2gist (home-global)") |
|
print(" - Config file contains URL to a registry gist") |
|
print(" - Registry maps org/repo/branch -> gist_url") |
|
print(" - If no config found, prompts to create registry gist (skip with -q)") |
|
print() |
|
print("Behavior:") |
|
print(" - Uses org/repo/branch to lookup gist in registry") |
|
print(" - If gist not found: creates new gist, updates registry") |
|
print(" - If gist found & file in gist: updates the file") |
|
print(" - If gist found & file not in gist: adds the file") |
|
print(" - Multiple files processed sequentially") |
|
print() |
|
print("Examples:") |
|
print(" md2gist analysis.md") |
|
print(" md2gist -q report.md # non-interactive") |
|
print(" md2gist --format html report.md") |
|
print(" md2gist file1.md file2.md file3.md") |
|
|
|
|
|
def process_single_file( |
|
md_file_path: Path, |
|
owner: str, |
|
repo: str, |
|
branch: str, |
|
registry_url: str, |
|
gist: dict[str, Any] | None, |
|
) -> dict[str, Any] | None: |
|
""" |
|
Process a single markdown file - create gist or add/update file. |
|
|
|
Returns updated gist dict or None on error. |
|
""" |
|
filename = md_file_path.name |
|
content = md_file_path.read_text() |
|
|
|
if not gist: |
|
print("✓ Creating new gist...") |
|
gist = create_gist(filename, content, owner, repo, branch) |
|
if not gist: |
|
print("✗ Failed to create gist", file=sys.stderr) |
|
return None |
|
|
|
# Update registry with new gist |
|
gist_id: Any = gist["id"] |
|
owner_login: Any = gist.get("owner", {}) |
|
if isinstance(owner_login, dict): |
|
username = owner_login.get("login", "unknown") |
|
else: |
|
username = "unknown" |
|
gist_url = f"https://gist.github.com/{username}/{gist_id}" |
|
|
|
if not update_registry(registry_url, owner, repo, branch, gist_url): |
|
print("Warning: Could not update registry", file=sys.stderr) |
|
|
|
print("✓ Gist created") |
|
else: |
|
files: Any = gist.get("files", {}) |
|
|
|
if filename in files: |
|
print(f" ✓ Updating '{filename}'...") |
|
gist_id = gist["id"] |
|
if not update_gist_file(str(gist_id), filename, content): |
|
print(f" ✗ Failed to update '{filename}'", file=sys.stderr) |
|
return gist |
|
gist = get_gist_from_api(str(gist_id)) |
|
else: |
|
print(f" ✓ Adding '{filename}'...") |
|
gist_id = gist["id"] |
|
if not update_gist_file(str(gist_id), filename, content): |
|
print(f" ✗ Failed to add '{filename}'", file=sys.stderr) |
|
return gist |
|
gist = get_gist_from_api(str(gist_id)) |
|
|
|
return gist |
|
|
|
|
|
def main() -> None: |
|
"""Main entry point.""" |
|
parser = argparse.ArgumentParser( |
|
prog="md2gist", |
|
description="Upload markdown files to a GitHub gist registry.", |
|
add_help=False, |
|
) |
|
parser.add_argument( |
|
"files", |
|
nargs="*", |
|
help="Markdown files to upload", |
|
) |
|
parser.add_argument( |
|
"-f", |
|
"--format", |
|
choices=["html", "md"], |
|
default="md", |
|
help="Output format for file links (default: md)", |
|
) |
|
parser.add_argument( |
|
"-q", |
|
"--quiet", |
|
action="store_true", |
|
help="Non-interactive mode: create registry gist and save to .md2gist", |
|
) |
|
parser.add_argument( |
|
"--create-gist", |
|
type=lambda x: x.lower() in ("true", "1", "yes", "y"), |
|
default=True, |
|
help="Whether to create a new registry gist if not found (default: true)", |
|
) |
|
parser.add_argument( |
|
"--save-location", |
|
choices=["local", "home"], |
|
default="local", |
|
help="Where to save registry URL: local (.md2gist) or home (~/.md2gist, default: local)", |
|
) |
|
parser.add_argument( |
|
"-h", |
|
"--help", |
|
action="store_true", |
|
help="Show help message", |
|
) |
|
|
|
args = parser.parse_args() |
|
|
|
# Handle help |
|
if args.help or not args.files: |
|
show_help() |
|
sys.exit(0 if args.help else 1) |
|
|
|
# Validate files |
|
md_files: list[Path] = [] |
|
for f in args.files: |
|
md_file_path = Path(f) |
|
|
|
if not md_file_path.exists(): |
|
print(f"Error: File not found: {md_file_path}", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
if md_file_path.suffix != ".md": |
|
print( |
|
f"Error: File must be markdown (.md): {md_file_path}", |
|
file=sys.stderr, |
|
) |
|
sys.exit(1) |
|
|
|
md_files.append(md_file_path) |
|
|
|
# Sort files for consistent ordering |
|
md_files.sort(key=lambda p: p.name) |
|
|
|
# Get git info |
|
try: |
|
branch = get_current_branch() |
|
owner, repo = get_repo_info() |
|
except RuntimeError as e: |
|
print(f"Error: {e}", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
print(f"Repo: {owner}/{repo}") |
|
print(f"Branch: {branch}") |
|
print(f"Files: {len(md_files)}") |
|
for f in md_files: |
|
print(f" - {f.name}") |
|
print() |
|
|
|
# Find registry config |
|
config_path = find_config_file() |
|
if config_path: |
|
registry_url = config_path.read_text().strip() |
|
if not args.quiet: |
|
print(f"✓ Found config at {config_path}") |
|
print(f"✓ Using registry: {registry_url}") |
|
else: |
|
registry_url = prompt_for_registry( |
|
create_gist=args.create_gist, |
|
save_location=args.save_location, |
|
quiet=args.quiet |
|
) |
|
if not registry_url: |
|
sys.exit(1) |
|
if not args.quiet: |
|
print(f"✓ Using registry: {registry_url}") |
|
|
|
print() |
|
|
|
# Find gist for this context in registry |
|
gist_url = find_gist_for_context(registry_url, owner, repo, branch) |
|
|
|
if gist_url: |
|
print(f"✓ Found existing gist for {owner}/{repo}/{branch}") |
|
# Get gist details from API |
|
gist_id = gist_url.split("/")[-1] |
|
gist = get_gist_from_api(gist_id) |
|
else: |
|
print(f"✓ No gist found for {owner}/{repo}/{branch}") |
|
gist = None |
|
|
|
# Process each file |
|
for md_file in md_files: |
|
gist = process_single_file(md_file, owner, repo, branch, registry_url, gist) |
|
if not gist: |
|
print("✗ Failed to process files", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
print() |
|
print(f"✓ All {len(md_files)} files processed") |
|
|
|
if gist: |
|
display_gist_info(gist, output_format=args.format) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |