From edb5a79d4fa5fd39fcb603845f971b95793fabc8 Mon Sep 17 00:00:00 2001 From: Adam Simpson Date: Thu, 19 Feb 2026 14:04:39 -0500 Subject: [PATCH] Add --publish-to-github flag for GitHub Pages deployment Add new flags to local, json, and web commands: - --publish-to-github: Enable publishing to GitHub Pages repo - --publish-to-github-repo: Target repository (owner/repo) - --publish-to-github-branch: Target branch (default: gh-pages) Files are uploaded to: {username}/{date-slug}/ path in the repo, making them accessible via GitHub Pages at: https://{owner}.github.io/{repo}/{username}/{date-slug}/ Features: - Prompts interactively for repo/branch if not specified - Updates existing files with correct SHA - Good error messages for common issues (not authenticated, repo not found, etc.) Co-Authored-By: Claude Opus 4.5 --- src/claude_code_transcripts/__init__.py | 354 +++++++++++++++- tests/test_publish.py | 515 ++++++++++++++++++++++++ 2 files changed, 867 insertions(+), 2 deletions(-) create mode 100644 tests/test_publish.py diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index e4854a3..959def8 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1286,6 +1286,194 @@ def create_gist(output_dir, public=False): ) +def get_github_username(): + """Get authenticated GitHub username via gh CLI. + + Returns the username of the currently authenticated user. + Raises click.ClickException if gh CLI is not found or not authenticated. + """ + try: + result = subprocess.run( + ["gh", "api", "user", "--jq", ".login"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + error_msg = e.stderr.strip() if e.stderr else str(e) + if "Not logged in" in error_msg or "not logged in" in error_msg.lower(): + raise click.ClickException( + "Not authenticated with GitHub. Run 'gh auth login' first." + ) + raise click.ClickException(f"Failed to get GitHub username: {error_msg}") + except FileNotFoundError: + raise click.ClickException( + "gh CLI not found. Install it from https://cli.github.com/ and run 'gh auth login'." + ) + + +def generate_session_slug(title, timestamp): + """Generate a URL-safe slug from session title and timestamp. + + Args: + title: Session title (e.g., "Fix auth bug") + timestamp: ISO timestamp (e.g., "2025-01-15T10:30:00.000Z") + + Returns: + A slug like "2025-01-15-fix-auth-bug" + """ + # Extract date from timestamp + if timestamp: + try: + date_part = timestamp[:10] # "2025-01-15" + except (TypeError, IndexError): + date_part = datetime.now().strftime("%Y-%m-%d") + else: + date_part = datetime.now().strftime("%Y-%m-%d") + + # Convert title to lowercase and replace non-alphanumeric with hyphens + slug_title = re.sub(r"[^a-z0-9]+", "-", title.lower()) + # Remove leading/trailing hyphens + slug_title = slug_title.strip("-") + # Collapse multiple hyphens + slug_title = re.sub(r"-+", "-", slug_title) + + # Truncate to reasonable length + max_title_len = 45 # Leave room for date prefix + if len(slug_title) > max_title_len: + slug_title = slug_title[:max_title_len].rstrip("-") + + return f"{date_part}-{slug_title}" + + +def publish_to_github(output_dir, repo, branch, session_title, session_timestamp): + """Publish HTML files to a GitHub repository for GitHub Pages. + + Args: + output_dir: Directory containing HTML files to publish. + repo: Target repository (owner/repo format). + branch: Target branch (e.g., "gh-pages"). + session_title: Title of the session for the folder name. + session_timestamp: Timestamp of the session for the folder name. + + Returns: + The URL where the files will be accessible via GitHub Pages. + + Raises: + click.ClickException: If publishing fails. + """ + import base64 + + output_dir = Path(output_dir) + + # Get current user + username = get_github_username() + + # Generate session slug + slug = generate_session_slug(session_title, session_timestamp) + + # Build the path: username/slug/ + base_path = f"{username}/{slug}" + + # Parse owner and repo name + if "/" not in repo: + raise click.ClickException(f"Invalid repo format: {repo}. Use 'owner/repo'.") + owner, repo_name = repo.split("/", 1) + + # Upload each HTML file + html_files = list(output_dir.glob("*.html")) + if not html_files: + raise click.ClickException("No HTML files found to publish.") + + click.echo(f"Publishing to {repo}...") + + for html_file in sorted(html_files): + file_path = f"{base_path}/{html_file.name}" + content = html_file.read_text(encoding="utf-8") + content_base64 = base64.b64encode(content.encode("utf-8")).decode("ascii") + + # Check if file already exists (to get SHA for update) + # Must specify the branch with ref= parameter + sha = None + try: + result = subprocess.run( + [ + "gh", + "api", + f"/repos/{owner}/{repo_name}/contents/{file_path}?ref={branch}", + "--jq", + ".sha", + "-H", + "Accept: application/vnd.github+json", + ], + capture_output=True, + text=True, + check=True, + ) + sha = result.stdout.strip() + except subprocess.CalledProcessError: + # File doesn't exist yet, that's fine + pass + + # Build the request payload as JSON and pass via --input + # This avoids command-line length limits for large files + payload = { + "message": f"Add {html_file.name} for session {slug}", + "content": content_base64, + "branch": branch, + } + if sha: + payload["sha"] = sha + + # Write payload to temp file to avoid command-line length limits + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: + json.dump(payload, tmp) + tmp_path = tmp.name + + try: + cmd = [ + "gh", + "api", + "-X", + "PUT", + f"/repos/{owner}/{repo_name}/contents/{file_path}", + "--input", + tmp_path, + ] + + subprocess.run( + cmd, + capture_output=True, + text=True, + check=True, + ) + click.echo(f" Uploaded {html_file.name}") + except subprocess.CalledProcessError as e: + error_msg = e.stderr.strip() if e.stderr else str(e) + if "Could not resolve to a Repository" in error_msg: + raise click.ClickException( + f"Repository {repo} not found or you don't have access." + ) + if "No commit found for the ref" in error_msg: + raise click.ClickException( + f"Branch '{branch}' does not exist in {repo}." + ) + raise click.ClickException( + f"Failed to upload {html_file.name}: {error_msg}" + ) + finally: + # Clean up temp file + Path(tmp_path).unlink(missing_ok=True) + + # Generate the GitHub Pages URL + # Standard GitHub Pages: https://{owner}.github.io/{repo}/{path}/ + # GHE Pages: varies by installation + pages_url = f"https://{owner}.github.io/{repo_name}/{base_path}/" + + return pages_url + + def generate_pagination_html(current_page, total_pages): return _macros.pagination(current_page, total_pages) @@ -1516,7 +1704,35 @@ def cli(): default=10, help="Maximum number of sessions to show (default: 10)", ) -def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit): +@click.option( + "--publish-to-github", + "publish_github", + is_flag=True, + help="Publish HTML to a GitHub Pages repository.", +) +@click.option( + "--publish-to-github-repo", + "publish_github_repo", + help="Target repository for GitHub Pages publishing (owner/repo).", +) +@click.option( + "--publish-to-github-branch", + "publish_github_branch", + default="gh-pages", + help="Target branch for GitHub Pages publishing (default: gh-pages).", +) +def local_cmd( + output, + output_auto, + repo, + gist, + include_json, + open_browser, + limit, + publish_github, + publish_github_repo, + publish_github_branch, +): """Select and convert a local Claude Code session to HTML.""" projects_folder = Path.home() / ".claude" / "projects" @@ -1589,6 +1805,35 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") + if publish_github: + # Prompt for repo if not provided + if not publish_github_repo: + publish_github_repo = questionary.text("GitHub repo (owner/repo):").ask() + if not publish_github_repo: + click.echo("No repository specified, skipping publish.") + else: + # Also ask for branch since they're in interactive mode + branch_answer = questionary.text( + "Branch:", default=publish_github_branch + ).ask() + if branch_answer: + publish_github_branch = branch_answer + + if publish_github_repo: + # Get session title and timestamp for the slug + session_summary = get_session_summary(session_file) + session_mtime = datetime.fromtimestamp(session_file.stat().st_mtime) + session_timestamp = session_mtime.isoformat() + + pages_url = publish_to_github( + output_dir=output, + repo=publish_github_repo, + branch=publish_github_branch, + session_title=session_summary, + session_timestamp=session_timestamp, + ) + click.echo(f"Published: {pages_url}") + if open_browser or auto_open: index_url = (output / "index.html").resolve().as_uri() webbrowser.open(index_url) @@ -1668,7 +1913,35 @@ def fetch_url_to_tempfile(url): is_flag=True, help="Open the generated index.html in your default browser (default if no -o specified).", ) -def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_browser): +@click.option( + "--publish-to-github", + "publish_github", + is_flag=True, + help="Publish HTML to a GitHub Pages repository.", +) +@click.option( + "--publish-to-github-repo", + "publish_github_repo", + help="Target repository for GitHub Pages publishing (owner/repo).", +) +@click.option( + "--publish-to-github-branch", + "publish_github_branch", + default="gh-pages", + help="Target branch for GitHub Pages publishing (default: gh-pages).", +) +def json_cmd( + json_file, + output, + output_auto, + repo, + gist, + include_json, + open_browser, + publish_github, + publish_github_repo, + publish_github_branch, +): """Convert a Claude Code session JSON/JSONL file or URL to HTML.""" # Handle URL input if is_url(json_file): @@ -1720,6 +1993,35 @@ def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_brow click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") + if publish_github: + # Prompt for repo if not provided + if not publish_github_repo: + publish_github_repo = questionary.text("GitHub repo (owner/repo):").ask() + if not publish_github_repo: + click.echo("No repository specified, skipping publish.") + else: + # Also ask for branch since they're in interactive mode + branch_answer = questionary.text( + "Branch:", default=publish_github_branch + ).ask() + if branch_answer: + publish_github_branch = branch_answer + + if publish_github_repo: + # Get session title and timestamp for the slug + session_summary = get_session_summary(json_file_path) + session_mtime = datetime.fromtimestamp(json_file_path.stat().st_mtime) + session_timestamp = session_mtime.isoformat() + + pages_url = publish_to_github( + output_dir=output, + repo=publish_github_repo, + branch=publish_github_branch, + session_title=session_summary, + session_timestamp=session_timestamp, + ) + click.echo(f"Published: {pages_url}") + if open_browser or auto_open: index_url = (output / "index.html").resolve().as_uri() webbrowser.open(index_url) @@ -1983,6 +2285,23 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): is_flag=True, help="Open the generated index.html in your default browser (default if no -o specified).", ) +@click.option( + "--publish-to-github", + "publish_github", + is_flag=True, + help="Publish HTML to a GitHub Pages repository.", +) +@click.option( + "--publish-to-github-repo", + "publish_github_repo", + help="Target repository for GitHub Pages publishing (owner/repo).", +) +@click.option( + "--publish-to-github-branch", + "publish_github_branch", + default="gh-pages", + help="Target branch for GitHub Pages publishing (default: gh-pages).", +) def web_cmd( session_id, output, @@ -1993,6 +2312,9 @@ def web_cmd( gist, include_json, open_browser, + publish_github, + publish_github_repo, + publish_github_branch, ): """Select and convert a web session from the Claude API to HTML. @@ -2091,6 +2413,34 @@ def web_cmd( click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") + if publish_github: + # Prompt for repo if not provided + if not publish_github_repo: + publish_github_repo = questionary.text("GitHub repo (owner/repo):").ask() + if not publish_github_repo: + click.echo("No repository specified, skipping publish.") + else: + # Also ask for branch since they're in interactive mode + branch_answer = questionary.text( + "Branch:", default=publish_github_branch + ).ask() + if branch_answer: + publish_github_branch = branch_answer + + if publish_github_repo: + # Get session title and timestamp for the slug + session_title = session_data.get("title", "Untitled") + session_timestamp = session_data.get("created_at", "") + + pages_url = publish_to_github( + output_dir=output, + repo=publish_github_repo, + branch=publish_github_branch, + session_title=session_title, + session_timestamp=session_timestamp, + ) + click.echo(f"Published: {pages_url}") + if open_browser or auto_open: index_url = (output / "index.html").resolve().as_uri() webbrowser.open(index_url) diff --git a/tests/test_publish.py b/tests/test_publish.py new file mode 100644 index 0000000..c125190 --- /dev/null +++ b/tests/test_publish.py @@ -0,0 +1,515 @@ +"""Tests for GitHub Pages publishing functionality.""" + +import base64 +import json +import subprocess +from pathlib import Path +from unittest.mock import MagicMock + +import click +import pytest +from click.testing import CliRunner + +from claude_code_transcripts import ( + get_github_username, + publish_to_github, + generate_session_slug, + cli, +) + + +class TestGetGithubUsername: + """Tests for get_github_username function.""" + + def test_returns_username_from_gh_api(self, monkeypatch): + """Test successful username retrieval from gh CLI.""" + mock_result = subprocess.CompletedProcess( + args=["gh", "api", "user", "--jq", ".login"], + returncode=0, + stdout="testuser\n", + stderr="", + ) + + def mock_run(*args, **kwargs): + return mock_result + + monkeypatch.setattr(subprocess, "run", mock_run) + + username = get_github_username() + assert username == "testuser" + + def test_strips_whitespace(self, monkeypatch): + """Test that username is stripped of whitespace.""" + mock_result = subprocess.CompletedProcess( + args=["gh", "api", "user", "--jq", ".login"], + returncode=0, + stdout=" myuser \n", + stderr="", + ) + + def mock_run(*args, **kwargs): + return mock_result + + monkeypatch.setattr(subprocess, "run", mock_run) + + username = get_github_username() + assert username == "myuser" + + def test_raises_on_gh_not_found(self, monkeypatch): + """Test error when gh CLI is not installed.""" + + def mock_run(*args, **kwargs): + raise FileNotFoundError() + + monkeypatch.setattr(subprocess, "run", mock_run) + + with pytest.raises(click.ClickException) as exc_info: + get_github_username() + + assert "gh CLI not found" in str(exc_info.value) + assert "https://cli.github.com/" in str(exc_info.value) + + def test_raises_on_not_authenticated(self, monkeypatch): + """Test error when gh is not authenticated.""" + + def mock_run(*args, **kwargs): + raise subprocess.CalledProcessError( + returncode=1, + cmd=["gh", "api", "user"], + stderr="gh: Not logged in to any GitHub hosts.", + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + with pytest.raises(click.ClickException) as exc_info: + get_github_username() + + assert "gh auth login" in str(exc_info.value) + + +class TestGenerateSessionSlug: + """Tests for generate_session_slug function.""" + + def test_generates_slug_from_title(self): + """Test slug generation from session title.""" + slug = generate_session_slug("Fix auth bug", "2025-01-15T10:30:00.000Z") + assert slug == "2025-01-15-fix-auth-bug" + + def test_handles_long_titles(self): + """Test that long titles are truncated.""" + long_title = ( + "This is a very long title that should be truncated to keep URLs manageable" + ) + slug = generate_session_slug(long_title, "2025-01-15T10:30:00.000Z") + assert len(slug) <= 60 + assert slug.startswith("2025-01-15-") + + def test_handles_special_characters(self): + """Test that special characters are handled.""" + slug = generate_session_slug( + "Fix: auth bug (URGENT!!)", "2025-01-15T10:30:00.000Z" + ) + # Should convert to lowercase, replace special chars with hyphens + assert ":" not in slug + assert "(" not in slug + assert "!" not in slug + assert slug.startswith("2025-01-15-") + + def test_handles_missing_timestamp(self): + """Test slug generation with missing timestamp uses current date.""" + slug = generate_session_slug("Test session", None) + # Should still produce a valid slug with a date prefix + assert "-test-session" in slug + + def test_collapses_multiple_hyphens(self): + """Test that multiple consecutive hyphens are collapsed.""" + slug = generate_session_slug( + "Fix --- multiple spaces", "2025-01-15T10:00:00Z" + ) + assert "---" not in slug + assert " " not in slug + + +class TestPublishToGithub: + """Tests for publish_to_github function.""" + + def test_publishes_html_files(self, tmp_path, monkeypatch): + """Test successful publishing of HTML files.""" + # Create test HTML files + output_dir = tmp_path / "output" + output_dir.mkdir() + (output_dir / "index.html").write_text("Index") + (output_dir / "page-001.html").write_text("Page 1") + + # Track API calls + api_calls = [] + + def mock_run(cmd, *args, **kwargs): + api_calls.append(cmd) + if cmd[0] == "gh" and cmd[1] == "api" and cmd[2] == "user": + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="testuser\n", stderr="" + ) + if cmd[0] == "gh" and cmd[1] == "api" and "-X" in cmd: + # Simulate file upload - check if file exists (GET returns 404 for new files) + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout='{"sha": "abc123"}', stderr="" + ) + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="", stderr="" + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + url = publish_to_github( + output_dir=output_dir, + repo="myorg/transcripts", + branch="gh-pages", + session_title="Fix auth bug", + session_timestamp="2025-01-15T10:30:00.000Z", + ) + + # Should return a GitHub Pages URL + assert "transcripts" in url # repo name + assert "myorg" in url # owner + assert "testuser" in url + assert "2025-01-15" in url + + def test_raises_on_repo_not_found(self, tmp_path, monkeypatch): + """Test error when repository is not found.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + (output_dir / "index.html").write_text("Test") + + def mock_run(cmd, *args, **kwargs): + if cmd[0] == "gh" and cmd[1] == "api" and cmd[2] == "user": + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="testuser\n", stderr="" + ) + if cmd[0] == "gh" and cmd[1] == "api": + raise subprocess.CalledProcessError( + returncode=1, + cmd=cmd, + stderr="gh: Could not resolve to a Repository", + ) + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="", stderr="" + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + with pytest.raises(click.ClickException) as exc_info: + publish_to_github( + output_dir=output_dir, + repo="nonexistent/repo", + branch="gh-pages", + session_title="Test", + session_timestamp="2025-01-15T10:30:00.000Z", + ) + + assert "not found" in str(exc_info.value).lower() or "Repository" in str( + exc_info.value + ) + + def test_raises_on_branch_not_found(self, tmp_path, monkeypatch): + """Test error when branch does not exist.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + (output_dir / "index.html").write_text("Test") + + def mock_run(cmd, *args, **kwargs): + if cmd[0] == "gh" and cmd[1] == "api" and cmd[2] == "user": + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="testuser\n", stderr="" + ) + if cmd[0] == "gh" and cmd[1] == "api": + raise subprocess.CalledProcessError( + returncode=1, + cmd=cmd, + stderr="gh: No commit found for the ref nonexistent-branch", + ) + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="", stderr="" + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + with pytest.raises(click.ClickException) as exc_info: + publish_to_github( + output_dir=output_dir, + repo="myorg/transcripts", + branch="nonexistent-branch", + session_title="Test", + session_timestamp="2025-01-15T10:30:00.000Z", + ) + + assert ( + "branch" in str(exc_info.value).lower() + or "does not exist" in str(exc_info.value).lower() + ) + + def test_updates_existing_files(self, tmp_path, monkeypatch): + """Test that existing files are updated with correct SHA.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + (output_dir / "index.html").write_text("Updated") + + api_calls = [] + + def mock_run(cmd, *args, **kwargs): + api_calls.append(cmd) + if cmd[0] == "gh" and cmd[1] == "api" and cmd[2] == "user": + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="testuser\n", stderr="" + ) + if cmd[0] == "gh" and cmd[1] == "api": + # Check if this is a GET request (no -X flag or -X not followed by PUT) + if "-X" not in cmd: + # Return existing file with SHA + return subprocess.CompletedProcess( + args=cmd, + returncode=0, + stdout='{"sha": "existing-sha-123"}', + stderr="", + ) + # PUT request + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout='{"sha": "new-sha-456"}', stderr="" + ) + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="", stderr="" + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + publish_to_github( + output_dir=output_dir, + repo="myorg/transcripts", + branch="gh-pages", + session_title="Test", + session_timestamp="2025-01-15T10:30:00.000Z", + ) + + # Check that a PUT request was made with SHA + put_calls = [c for c in api_calls if "-X" in c and "PUT" in c] + assert len(put_calls) > 0 + + +class TestPublishToGithubCLI: + """Tests for CLI integration of --publish-to-github flag.""" + + def test_json_command_has_publish_options(self): + """Test that json command has publish-to-github options.""" + runner = CliRunner() + result = runner.invoke(cli, ["json", "--help"]) + + assert result.exit_code == 0 + assert "--publish-to-github" in result.output + assert "--publish-to-github-repo" in result.output + assert "--publish-to-github-branch" in result.output + + def test_local_command_has_publish_options(self): + """Test that local command has publish-to-github options.""" + runner = CliRunner() + result = runner.invoke(cli, ["local", "--help"]) + + assert result.exit_code == 0 + assert "--publish-to-github" in result.output + assert "--publish-to-github-repo" in result.output + assert "--publish-to-github-branch" in result.output + + def test_web_command_has_publish_options(self): + """Test that web command has publish-to-github options.""" + runner = CliRunner() + result = runner.invoke(cli, ["web", "--help"]) + + assert result.exit_code == 0 + assert "--publish-to-github" in result.output + assert "--publish-to-github-repo" in result.output + assert "--publish-to-github-branch" in result.output + + def test_all_command_does_not_have_publish_options(self): + """Test that all command does NOT have publish-to-github options.""" + runner = CliRunner() + result = runner.invoke(cli, ["all", "--help"]) + + assert result.exit_code == 0 + assert "--publish-to-github" not in result.output + + def test_json_publish_to_github_success(self, tmp_path, monkeypatch): + """Test successful publishing via json command.""" + fixture_path = Path(__file__).parent / "sample_session.json" + + api_calls = [] + + def mock_run(cmd, *args, **kwargs): + api_calls.append(cmd) + if cmd[0] == "gh" and cmd[1] == "api" and cmd[2] == "user": + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="testuser\n", stderr="" + ) + if cmd[0] == "gh" and cmd[1] == "api": + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout='{"sha": "abc123"}', stderr="" + ) + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="", stderr="" + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "json", + str(fixture_path), + "-o", + str(tmp_path / "output"), + "--publish-to-github", + "--publish-to-github-repo", + "myorg/transcripts", + ], + ) + + assert result.exit_code == 0 + assert "Publishing to" in result.output or "Published" in result.output + + def test_publish_prompts_for_repo_when_missing(self, tmp_path, monkeypatch): + """Test that interactive prompt is shown when repo is not specified.""" + fixture_path = Path(__file__).parent / "sample_session.json" + + # Mock questionary to return a repo + mock_questionary = MagicMock() + mock_questionary.text.return_value.ask.return_value = "myorg/transcripts" + monkeypatch.setattr("claude_code_transcripts.questionary", mock_questionary) + + def mock_run(cmd, *args, **kwargs): + if cmd[0] == "gh" and cmd[1] == "api" and cmd[2] == "user": + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="testuser\n", stderr="" + ) + if cmd[0] == "gh" and cmd[1] == "api": + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout='{"sha": "abc123"}', stderr="" + ) + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="", stderr="" + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "json", + str(fixture_path), + "-o", + str(tmp_path / "output"), + "--publish-to-github", + ], + ) + + # Should have prompted for repo + mock_questionary.text.assert_called() + assert ( + "myorg/transcripts" in str(mock_questionary.text.call_args_list) + or result.exit_code == 0 + ) + + def test_publish_uses_default_branch(self, tmp_path, monkeypatch): + """Test that gh-pages is used as default branch.""" + fixture_path = Path(__file__).parent / "sample_session.json" + + # Track JSON payloads written to temp files + json_payloads = [] + + def mock_run(cmd, *args, **kwargs): + if cmd[0] == "gh" and cmd[1] == "api" and cmd[2] == "user": + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="testuser\n", stderr="" + ) + if cmd[0] == "gh" and cmd[1] == "api": + # If this is a PUT with --input, capture the JSON payload + if "-X" in cmd and "PUT" in cmd and "--input" in cmd: + input_idx = cmd.index("--input") + input_file = cmd[input_idx + 1] + with open(input_file) as f: + json_payloads.append(json.load(f)) + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout='{"sha": "abc123"}', stderr="" + ) + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="", stderr="" + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "json", + str(fixture_path), + "-o", + str(tmp_path / "output"), + "--publish-to-github", + "--publish-to-github-repo", + "myorg/transcripts", + ], + ) + + assert result.exit_code == 0 + # Check that gh-pages was used in the JSON payloads + assert len(json_payloads) > 0 + assert all(p.get("branch") == "gh-pages" for p in json_payloads) + + def test_publish_with_custom_branch(self, tmp_path, monkeypatch): + """Test publishing with custom branch.""" + fixture_path = Path(__file__).parent / "sample_session.json" + + # Track JSON payloads written to temp files + json_payloads = [] + + def mock_run(cmd, *args, **kwargs): + if cmd[0] == "gh" and cmd[1] == "api" and cmd[2] == "user": + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="testuser\n", stderr="" + ) + if cmd[0] == "gh" and cmd[1] == "api": + # If this is a PUT with --input, capture the JSON payload + if "-X" in cmd and "PUT" in cmd and "--input" in cmd: + input_idx = cmd.index("--input") + input_file = cmd[input_idx + 1] + with open(input_file) as f: + json_payloads.append(json.load(f)) + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout='{"sha": "abc123"}', stderr="" + ) + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout="", stderr="" + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "json", + str(fixture_path), + "-o", + str(tmp_path / "output"), + "--publish-to-github", + "--publish-to-github-repo", + "myorg/transcripts", + "--publish-to-github-branch", + "html", + ], + ) + + assert result.exit_code == 0 + # Check that custom branch was used in the JSON payloads + assert len(json_payloads) > 0 + assert all(p.get("branch") == "html" for p in json_payloads)