From 7ffd89787416aca6bcaa48c36e42d2f865946404 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 00:30:47 +0000 Subject: [PATCH 01/60] automated ci fixes --- .helper_bash_functions | 35 +++ bin/ci_tool/__init__.py | 0 bin/ci_tool/__main__.py | 11 + .../__pycache__/__init__.cpython-38.pyc | Bin 0 -> 137 bytes .../__pycache__/__main__.cpython-38.pyc | Bin 0 -> 406 bytes bin/ci_tool/__pycache__/ci_fix.cpython-38.pyc | Bin 0 -> 4114 bytes .../__pycache__/ci_reproduce.cpython-38.pyc | Bin 0 -> 3205 bytes .../__pycache__/claude_setup.cpython-38.pyc | Bin 0 -> 5289 bytes bin/ci_tool/__pycache__/cli.cpython-38.pyc | Bin 0 -> 2449 bytes .../__pycache__/containers.cpython-38.pyc | Bin 0 -> 4582 bytes .../__pycache__/preflight.cpython-38.pyc | Bin 0 -> 6903 bytes bin/ci_tool/ci_fix.py | 147 +++++++++++++ bin/ci_tool/ci_reproduce.py | 122 +++++++++++ bin/ci_tool/claude_setup.py | 151 +++++++++++++ bin/ci_tool/cli.py | 82 +++++++ bin/ci_tool/containers.py | 132 ++++++++++++ bin/ci_tool/preflight.py | 203 ++++++++++++++++++ bin/ci_tool/requirements.txt | 2 + 18 files changed, 885 insertions(+) create mode 100644 bin/ci_tool/__init__.py create mode 100644 bin/ci_tool/__main__.py create mode 100644 bin/ci_tool/__pycache__/__init__.cpython-38.pyc create mode 100644 bin/ci_tool/__pycache__/__main__.cpython-38.pyc create mode 100644 bin/ci_tool/__pycache__/ci_fix.cpython-38.pyc create mode 100644 bin/ci_tool/__pycache__/ci_reproduce.cpython-38.pyc create mode 100644 bin/ci_tool/__pycache__/claude_setup.cpython-38.pyc create mode 100644 bin/ci_tool/__pycache__/cli.cpython-38.pyc create mode 100644 bin/ci_tool/__pycache__/containers.cpython-38.pyc create mode 100644 bin/ci_tool/__pycache__/preflight.cpython-38.pyc create mode 100644 bin/ci_tool/ci_fix.py create mode 100644 bin/ci_tool/ci_reproduce.py create mode 100644 bin/ci_tool/claude_setup.py create mode 100644 bin/ci_tool/cli.py create mode 100644 bin/ci_tool/containers.py create mode 100644 bin/ci_tool/preflight.py create mode 100644 bin/ci_tool/requirements.txt diff --git a/.helper_bash_functions b/.helper_bash_functions index a7f7235..04088be 100644 --- a/.helper_bash_functions +++ b/.helper_bash_functions @@ -183,6 +183,41 @@ remove_ci_container() { echo -e "${Green}Container '${container_name}' removed${Color_Off}" } +# CI tool (Python CLI) - interactive CI reproduction + Claude-powered fix +CI_TOOL_CACHE_DIR="${HOME}/.ci_tool" +CI_TOOL_RAW_URL="https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/refs/heads/${THIS_SCRIPT_BRANCH}/bin/ci_tool" +CI_TOOL_FILES="__init__.py __main__.py cli.py preflight.py containers.py ci_reproduce.py claude_setup.py ci_fix.py requirements.txt" + +install_ci_tool() { + echo -e "${Yellow}Installing ci_tool from er_build_tools (branch: ${THIS_SCRIPT_BRANCH})...${Color_Off}" + mkdir -p "${CI_TOOL_CACHE_DIR}/ci_tool" + local failed="false" + for file in ${CI_TOOL_FILES}; do + echo " Fetching ${file}..." + if ! curl -fSL "${CI_TOOL_RAW_URL}/${file}" -o "${CI_TOOL_CACHE_DIR}/ci_tool/${file}"; then + echo -e "${Red} Failed to fetch ${file}${Color_Off}" + failed="true" + fi + done + if [ "${failed}" = "true" ]; then + echo -e "${Red}Error: Failed to install ci_tool. Check network and branch '${THIS_SCRIPT_BRANCH}'.${Color_Off}" + return 1 + fi + echo -e "${Yellow}Installing Python dependencies...${Color_Off}" + pip3 install -r "${CI_TOOL_CACHE_DIR}/ci_tool/requirements.txt" + echo -e "${Green}ci_tool installed to ${CI_TOOL_CACHE_DIR}${Color_Off}" +} + +update_ci_tool() { install_ci_tool; } + +ci_tool() { + if [ ! -d "${CI_TOOL_CACHE_DIR}/ci_tool" ]; then + echo -e "${Yellow}ci_tool not found locally. Installing...${Color_Off}" + install_ci_tool || return 1 + fi + python3 "${CI_TOOL_CACHE_DIR}/ci_tool" "$@" +} +ci_fix() { ci_tool fix "$@"; } er_python_linters_here() { local ret=0 diff --git a/bin/ci_tool/__init__.py b/bin/ci_tool/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bin/ci_tool/__main__.py b/bin/ci_tool/__main__.py new file mode 100644 index 0000000..3de47be --- /dev/null +++ b/bin/ci_tool/__main__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""Entry point for: python3 -m ci_tool or python3 /path/to/ci_tool""" +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from ci_tool.cli import main # noqa: E402 + +if __name__ == "__main__": + main() diff --git a/bin/ci_tool/__pycache__/__init__.cpython-38.pyc b/bin/ci_tool/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..287b173d208ee4a2c2d2c98cc912b1048fafd80a GIT binary patch literal 137 zcmWIL<>g`kf)wVdnIQTxh(HF6K#l_t7qb9~6oz01O-8?!3`HPe1o2BxKRLgsB(*|6 zwJ1KRG&3h9z9c_Cr&vEJGfzJ`6U@<%kI&4@EQycTE2zB1VUwGmQks)$2Qud~5HkP( DDjgnZ literal 0 HcmV?d00001 diff --git a/bin/ci_tool/__pycache__/__main__.cpython-38.pyc b/bin/ci_tool/__pycache__/__main__.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a32d8973f844808f01c4a1064c5263dd591ccfc4 GIT binary patch literal 406 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 literal 0 HcmV?d00001 diff --git a/bin/ci_tool/__pycache__/ci_reproduce.cpython-38.pyc b/bin/ci_tool/__pycache__/ci_reproduce.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f71d8923cc6b4adbf0eb222018539141a02d7692 GIT binary patch literal 3205 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 K=*@ZdEbWDg!a$f22^$3H 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 literal 0 HcmV?d00001 diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py new file mode 100644 index 0000000..46ce5c9 --- /dev/null +++ b/bin/ci_tool/ci_fix.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Fix CI test failures using Claude Code inside a container.""" +from __future__ import annotations + +import sys + +from InquirerPy import inquirer +from rich.console import Console +from rich.panel import Panel + +from ci_tool.claude_setup import setup_claude_in_container +from ci_tool.containers import ( + DEFAULT_CONTAINER_NAME, + container_exists, + container_is_running, + docker_exec, + docker_exec_interactive, + remove_container, + start_container, +) +from ci_tool.ci_reproduce import reproduce_ci, extract_repo_url_from_args +from ci_tool.preflight import run_all_preflight_checks, PreflightError + +console = Console() + +DEFAULT_PROMPT = ( + "You are inside a CI reproduction container at /ros_ws. " + "Source the ROS workspace: " + "`source /opt/ros/noetic/setup.bash && source /ros_ws/install/setup.bash`.\n\n" + "The CI tests have already been run. Your job:\n" + "1. Examine the test output in /ros_ws/test_output.log to identify failures\n" + "2. Find and fix the root cause in the source code under /ros_ws/src/\n" + "3. Rebuild the affected packages\n" + "4. Re-run the failing tests to verify your fix\n" + "5. Iterate until all tests pass\n\n" + "When done, print EXACTLY this format:\n\n" + "--- SUMMARY ---\n" + "Problem: \n" + "Fix: \n" + "Assumptions: \n\n" + "--- COMMIT MESSAGE ---\n" + "\n" + "--- END ---" +) + + +def parse_fix_args(args): + """Parse fix-specific arguments, separating them from reproduce args.""" + parsed = { + "prompt": DEFAULT_PROMPT, + "container_name": DEFAULT_CONTAINER_NAME, + "reproduce_args": [], + } + + i = 0 + while i < len(args): + if args[i] == "--prompt" and i + 1 < len(args): + parsed["prompt"] = args[i + 1] + i += 2 + elif args[i] in ("--container-name", "-n") and i + 1 < len(args): + parsed["container_name"] = args[i + 1] + i += 2 + else: + parsed["reproduce_args"].append(args[i]) + i += 1 + + return parsed + + +def fix_ci(args): + """Main fix workflow: preflight -> ensure container -> install Claude -> run -> drop to shell.""" + parsed = parse_fix_args(args) + container_name = parsed["container_name"] + prompt = parsed["prompt"] + + console.print(Panel("[bold cyan]CI Fix with Claude[/bold cyan]", expand=False)) + + # Step 0: Preflight checks + repo_url = extract_repo_url_from_args(parsed["reproduce_args"]) + try: + run_all_preflight_checks(repo_url=repo_url) + except PreflightError as error: + console.print(f"\n[bold red]Preflight failed:[/bold red] {error}") + sys.exit(1) + + # Step 1: Ensure container exists + needs_reproduce = False + + if container_exists(container_name): + if container_is_running(container_name): + action = inquirer.select( + message=f"Container '{container_name}' is running. What to do?", + choices=[ + {"name": "Use existing container (skip CI reproduction)", "value": "reuse"}, + {"name": "Remove and recreate from scratch", "value": "recreate"}, + {"name": "Cancel", "value": "cancel"}, + ], + ).execute() + else: + action = inquirer.select( + message=f"Container '{container_name}' exists but is stopped.", + choices=[ + {"name": "Start and reuse it", "value": "reuse"}, + {"name": "Remove and recreate from scratch", "value": "recreate"}, + {"name": "Cancel", "value": "cancel"}, + ], + ).execute() + + if action == "cancel": + return + if action == "recreate": + remove_container(container_name) + needs_reproduce = True + elif action == "reuse": + if not container_is_running(container_name): + start_container(container_name) + else: + needs_reproduce = True + + if needs_reproduce: + if not parsed["reproduce_args"]: + console.print("[red]No container exists and no reproduce args provided.[/red]") + console.print( + "Pass repo args, e.g.: ci_tool fix -r https://github.com/... " + "-b main --only-needed-deps" + ) + sys.exit(1) + reproduce_ci(parsed["reproduce_args"], skip_preflight=True) + + # Step 2: Install Claude in container + setup_claude_in_container(container_name) + + # Step 3: Launch Claude + console.print("\n[bold cyan]Launching Claude Code...[/bold cyan]") + console.print("[dim]Claude will attempt to fix CI failures autonomously[/dim]\n") + + escaped_prompt = prompt.replace("'", "'\\''") + claude_command = f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions -p '{escaped_prompt}'" + docker_exec(container_name, claude_command, check=False) + + # Step 4: Drop into interactive shell + console.print("\n[bold green]Claude has finished.[/bold green]") + console.print("[cyan]Dropping you into the container shell.[/cyan]") + console.print("[dim]You can run 'git diff', 'git add', 'git commit' etc.[/dim]") + console.print("[dim]The repo is at /ros_ws/src/[/dim]\n") + + docker_exec_interactive(container_name) diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py new file mode 100644 index 0000000..406ab81 --- /dev/null +++ b/bin/ci_tool/ci_reproduce.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Reproduce CI locally by creating a Docker container.""" +from __future__ import annotations + +import os +import subprocess +import sys + +from InquirerPy import inquirer +from rich.console import Console +from rich.panel import Panel + +from ci_tool.containers import ( + DEFAULT_CONTAINER_NAME, + container_exists, + container_is_running, + remove_container, +) +from ci_tool.preflight import validate_docker_available, validate_gh_token, PreflightError + +console = Console() + +DEFAULT_SCRIPTS_BRANCH = "ERD-1633_reproduce_ci_locally" + + +def get_gh_token(): + """Get GitHub token from environment.""" + token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") or "" + if not token: + raise RuntimeError( + "No GitHub token found. Set GH_TOKEN or ER_SETUP_TOKEN environment variable." + ) + return token + + +def extract_repo_url_from_args(args): + """Extract --repo/-r value from args list, or return None.""" + for i, arg in enumerate(args): + if arg in ("--repo", "-r") and i + 1 < len(args): + return args[i + 1] + return None + + +def reproduce_ci(args, skip_preflight=False): + """Create a CI reproduction container.""" + if not skip_preflight: + try: + validate_docker_available() + repo_url = extract_repo_url_from_args(args) + validate_gh_token(repo_url=repo_url) + except PreflightError as error: + console.print(f"\n[bold red]Preflight failed:[/bold red] {error}") + sys.exit(1) + + container_name = DEFAULT_CONTAINER_NAME + + if container_exists(container_name): + action = inquirer.select( + message=f"Container '{container_name}' already exists. What to do?", + choices=[ + {"name": "Remove and recreate", "value": "recreate"}, + {"name": "Keep existing (skip creation)", "value": "keep"}, + {"name": "Cancel", "value": "cancel"}, + ], + ).execute() + + if action == "cancel": + return + if action == "recreate": + remove_container(container_name) + if action == "keep": + if not container_is_running(container_name): + subprocess.run(["docker", "start", container_name], check=True) + console.print(f"[green]Using existing container '{container_name}'[/green]") + return + + if not args: + console.print(Panel( + "[bold]Reproduce CI requires arguments.[/bold]\n\n" + "Example:\n" + " ci_tool reproduce -r https://github.com/extend-robotics/er_interface " + "-b main --only-needed-deps", + title="Usage", + )) + sys.exit(1) + + token = get_gh_token() + scripts_branch = DEFAULT_SCRIPTS_BRANCH + + filtered_args = [] + i = 0 + while i < len(args): + if args[i] in ("--scripts-branch", "--scripts_branch"): + scripts_branch = args[i + 1] + i += 2 + else: + filtered_args.append(args[i]) + i += 1 + + wrapper_url = ( + f"https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/" + f"refs/heads/{scripts_branch}/bin/reproduce_ci.sh" + ) + + console.print(f"[cyan]Fetching CI scripts from branch: {scripts_branch}[/cyan]") + + full_args = [ + "--gh-token", token, + "--scripts-branch", scripts_branch, + ] + filtered_args + + fetch_result = subprocess.run( + ["curl", "-fSL", wrapper_url], + capture_output=True, text=True, check=True, + ) + + subprocess.run( + ["bash", "-c", fetch_result.stdout + '\n"$@"', "--"] + full_args, + check=True, + ) + + console.print(f"\n[green]Container '{container_name}' is ready[/green]") diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py new file mode 100644 index 0000000..2f8d5ff --- /dev/null +++ b/bin/ci_tool/claude_setup.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Install Claude Code in a container and copy auth/config from host.""" +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path + +from rich.console import Console + +from ci_tool.containers import docker_exec, docker_cp_to_container + +console = Console() + +CLAUDE_HOME = Path.home() / ".claude" +GIT_USER_NAME = "Tom Queen" +GIT_USER_EMAIL = "tom.queen@extendrobotics.com" + + +def install_node_in_container(container_name): + """Install Node.js 20 LTS in the container.""" + console.print("[cyan]Installing Node.js 20 in container...[/cyan]") + docker_exec(container_name, ( + "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - " + "&& apt-get install -y nodejs" + )) + + +def install_claude_in_container(container_name): + """Install Claude Code via npm in the container.""" + console.print("[cyan]Installing Claude Code in container...[/cyan]") + docker_exec(container_name, "npm install -g @anthropic-ai/claude-code") + + +def install_fzf_in_container(container_name): + """Install fzf in the container.""" + console.print("[cyan]Installing fzf in container...[/cyan]") + docker_exec(container_name, "apt-get update && apt-get install -y fzf", check=False) + + +def copy_claude_credentials(container_name): + """Copy Claude credentials into the container.""" + credentials_path = CLAUDE_HOME / ".credentials.json" + if not credentials_path.exists(): + raise RuntimeError(f"Claude credentials not found at {credentials_path}") + + console.print("[cyan]Copying Claude credentials...[/cyan]") + docker_exec(container_name, "mkdir -p /root/.claude") + docker_cp_to_container( + str(credentials_path), container_name, "/root/.claude/.credentials.json" + ) + + +def copy_claude_config(container_name): + """Copy CLAUDE.md and modified settings.json into the container.""" + claude_md_path = CLAUDE_HOME / "CLAUDE.md" + settings_path = CLAUDE_HOME / "settings.json" + + if claude_md_path.exists(): + console.print("[cyan]Copying CLAUDE.md...[/cyan]") + docker_cp_to_container(str(claude_md_path), container_name, "/root/.claude/CLAUDE.md") + + if settings_path.exists(): + console.print("[cyan]Copying settings.json (modified for dangerous mode)...[/cyan]") + with open(settings_path, encoding="utf-8") as settings_file: + settings = json.load(settings_file) + + settings.setdefault("permissions", {})["defaultMode"] = "dangerouslySkipPermissions" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: + json.dump(settings, tmp, indent=2) + tmp_path = tmp.name + + try: + docker_cp_to_container(tmp_path, container_name, "/root/.claude/settings.json") + finally: + os.unlink(tmp_path) + + +def copy_claude_memory(container_name): + """Copy Claude project memory files into the container.""" + projects_dir = CLAUDE_HOME / "projects" + if not projects_dir.exists(): + return + + console.print("[cyan]Copying Claude memory files...[/cyan]") + for project_dir in projects_dir.iterdir(): + memory_dir = project_dir / "memory" + if not memory_dir.exists(): + continue + memory_files = [f for f in memory_dir.iterdir() if f.is_file()] + if not memory_files: + continue + + container_memory_path = f"/root/.claude/projects/{project_dir.name}/memory" + docker_exec(container_name, f"mkdir -p {container_memory_path}") + for memory_file in memory_files: + docker_cp_to_container( + str(memory_file), + container_name, + f"{container_memory_path}/{memory_file.name}", + ) + + +def copy_helper_bash_functions(container_name): + """Copy ~/.helper_bash_functions and source it in bashrc.""" + helper_path = Path.home() / ".helper_bash_functions" + if not helper_path.exists(): + console.print("[yellow]~/.helper_bash_functions not found, skipping[/yellow]") + return + + console.print("[cyan]Copying helper bash functions...[/cyan]") + docker_cp_to_container(str(helper_path), container_name, "/root/.helper_bash_functions") + docker_exec( + container_name, + "grep -q 'source ~/.helper_bash_functions' /root/.bashrc " + "|| echo 'source ~/.helper_bash_functions' >> /root/.bashrc", + ) + + +def configure_git_in_container(container_name): + """Set up git identity and token-based auth in the container.""" + gh_token = os.environ.get("GH_TOKEN", "") + + console.print("[cyan]Configuring git in container...[/cyan]") + docker_exec(container_name, f'git config --global user.name "{GIT_USER_NAME}"') + docker_exec(container_name, f'git config --global user.email "{GIT_USER_EMAIL}"') + + if gh_token: + docker_exec( + container_name, + f'git config --global url."https://{gh_token}@github.com/"' + f'.insteadOf "https://github.com/"', + ) + + +def setup_claude_in_container(container_name): + """Full setup: install Claude Code and copy all config into container.""" + console.print("\n[bold cyan]Setting up Claude in container...[/bold cyan]") + + install_node_in_container(container_name) + install_claude_in_container(container_name) + install_fzf_in_container(container_name) + copy_claude_credentials(container_name) + copy_claude_config(container_name) + copy_claude_memory(container_name) + copy_helper_bash_functions(container_name) + configure_git_in_container(container_name) + + console.print("[bold green]Claude Code is installed and configured in the container[/bold green]") diff --git a/bin/ci_tool/cli.py b/bin/ci_tool/cli.py new file mode 100644 index 0000000..a2d77bc --- /dev/null +++ b/bin/ci_tool/cli.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Interactive CLI menu for CI tool.""" +from __future__ import annotations + +import sys + +from InquirerPy import inquirer +from rich.console import Console +from rich.panel import Panel + +console = Console() + +MENU_CHOICES = [ + {"name": "Reproduce CI (create container)", "value": "reproduce"}, + {"name": "Fix CI with Claude", "value": "fix"}, + {"name": "Shell into container", "value": "shell"}, + {"name": "Re-run tests in container", "value": "retest"}, + {"name": "Clean up containers", "value": "clean"}, + {"name": "Exit", "value": "exit"}, +] + + +def main(): + """Entry point - show menu or dispatch subcommand.""" + if len(sys.argv) > 1: + dispatch_subcommand(sys.argv[1], sys.argv[2:]) + return + + console.print(Panel("[bold cyan]CI Tool[/bold cyan]", expand=False)) + + action = inquirer.select( + message="What would you like to do?", + choices=MENU_CHOICES, + default="fix", + ).execute() + + if action == "exit": + return + + dispatch_subcommand(action, []) + + +def dispatch_subcommand(command, args): + """Route to the appropriate subcommand handler.""" + handlers = { + "reproduce": _handle_reproduce, + "fix": _handle_fix, + "shell": _handle_shell, + "retest": _handle_retest, + "clean": _handle_clean, + } + handler = handlers.get(command) + if handler is None: + console.print(f"[red]Unknown command: {command}[/red]") + console.print(f"Available: {', '.join(handlers.keys())}") + sys.exit(1) + handler(args) + + +def _handle_reproduce(args): + from ci_tool.ci_reproduce import reproduce_ci + reproduce_ci(args) + + +def _handle_fix(args): + from ci_tool.ci_fix import fix_ci + fix_ci(args) + + +def _handle_shell(args): + from ci_tool.containers import shell_into_container + shell_into_container(args) + + +def _handle_retest(args): + from ci_tool.containers import retest_in_container + retest_in_container(args) + + +def _handle_clean(args): + from ci_tool.containers import clean_containers + clean_containers(args) diff --git a/bin/ci_tool/containers.py b/bin/ci_tool/containers.py new file mode 100644 index 0000000..1c3cb5f --- /dev/null +++ b/bin/ci_tool/containers.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Docker container lifecycle management.""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys + +from rich.console import Console + +console = Console() + +DEFAULT_CONTAINER_NAME = "er_ci_reproduced_testing_env" + + +def require_docker(): + """Fail fast if docker is not available.""" + if not shutil.which("docker"): + console.print("[red]Error: 'docker' command not found. Is Docker installed?[/red]") + sys.exit(1) + + +def run_command(command, capture_output=False, check=True): + """Run a shell command, raising on failure.""" + console.print(f"[dim]$ {' '.join(command)}[/dim]") + return subprocess.run(command, capture_output=capture_output, check=check, text=True) + + +def container_exists(container_name=DEFAULT_CONTAINER_NAME): + """Check if a container exists (running or stopped).""" + result = subprocess.run( + ["docker", "ps", "-a", "--filter", f"name=^{container_name}$", "--format", "{{.Names}}"], + capture_output=True, text=True, check=False, + ) + return container_name in result.stdout.strip() + + +def container_is_running(container_name=DEFAULT_CONTAINER_NAME): + """Check if a container is currently running.""" + result = subprocess.run( + ["docker", "ps", "--filter", f"name=^{container_name}$", "--format", "{{.Names}}"], + capture_output=True, text=True, check=False, + ) + return container_name in result.stdout.strip() + + +def start_container(container_name=DEFAULT_CONTAINER_NAME): + """Start a stopped container.""" + run_command(["docker", "start", container_name]) + + +def remove_container(container_name=DEFAULT_CONTAINER_NAME): + """Force remove a container.""" + run_command(["docker", "rm", "-f", container_name]) + console.print(f"[green]Container '{container_name}' removed[/green]") + + +def docker_exec(container_name, command, interactive=False, check=True): + """Run a command inside a container.""" + docker_command = ["docker", "exec"] + if interactive: + docker_command.extend(["-it"]) + docker_command.extend([container_name, "bash", "-c", command]) + return run_command(docker_command, check=check) + + +def docker_exec_interactive(container_name=DEFAULT_CONTAINER_NAME): + """Drop user into an interactive shell inside the container.""" + console.print(f"\n[bold cyan]Entering container '{container_name}'...[/bold cyan]") + console.print("[dim]Type 'exit' to leave the container[/dim]\n") + os.execvp("docker", ["docker", "exec", "-it", container_name, "bash"]) + + +def docker_cp_to_container(host_path, container_name, container_path): + """Copy a file/directory from host into container.""" + run_command(["docker", "cp", host_path, f"{container_name}:{container_path}"]) + + +def shell_into_container(args): + """Shell subcommand handler.""" + require_docker() + container_name = args[0] if args else DEFAULT_CONTAINER_NAME + if not container_exists(container_name): + console.print(f"[red]Container '{container_name}' does not exist[/red]") + sys.exit(1) + if not container_is_running(container_name): + console.print(f"[yellow]Container '{container_name}' is stopped, starting...[/yellow]") + start_container(container_name) + docker_exec_interactive(container_name) + + +def retest_in_container(args): + """Re-run tests subcommand handler.""" + require_docker() + container_name = args[0] if args else DEFAULT_CONTAINER_NAME + if not container_is_running(container_name): + console.print(f"[red]Container '{container_name}' is not running[/red]") + sys.exit(1) + console.print(f"[cyan]Re-running tests in '{container_name}'...[/cyan]") + docker_exec(container_name, "bash /tmp/ci_repull_and_retest.sh") + + +def clean_containers(_args): + """Clean up CI containers.""" + require_docker() + from InquirerPy import inquirer + + result = subprocess.run( + ["docker", "ps", "-a", "--filter", "name=er_ci_", "--format", "{{.Names}}\t{{.Status}}"], + capture_output=True, text=True, check=False, + ) + if not result.stdout.strip(): + console.print("[green]No CI containers found[/green]") + return + + console.print("[bold]CI containers:[/bold]") + containers = [] + for line in result.stdout.strip().split("\n"): + parts = line.split("\t") + name = parts[0] + status = parts[1] if len(parts) > 1 else "unknown" + containers.append({"name": f"{name} ({status})", "value": name}) + console.print(f" {name}: {status}") + + selected = inquirer.checkbox( + message="Select containers to remove:", + choices=containers, + ).execute() + + for name in selected: + remove_container(name) diff --git a/bin/ci_tool/preflight.py b/bin/ci_tool/preflight.py new file mode 100644 index 0000000..ffb9689 --- /dev/null +++ b/bin/ci_tool/preflight.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Preflight validation - fail fast on auth issues before any docker operations.""" +from __future__ import annotations + +import json +import os +import subprocess +import time +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from rich.console import Console +from rich.panel import Panel + +console = Console() + +CLAUDE_HOME = Path.home() / ".claude" + + +class PreflightError(RuntimeError): + """Raised when a preflight check fails.""" + + +def _check_pass(message): + console.print(f" [green]\u2713[/green] {message}") + + +def _check_fail(message): + console.print(f" [red]\u2717[/red] {message}") + + +def _check_warn(message): + console.print(f" [yellow]![/yellow] {message}") + + +def _github_api_get(endpoint, gh_token): + """Make an authenticated GET request to the GitHub API.""" + url = f"https://api.github.com{endpoint}" + request = Request(url, headers={ + "Authorization": f"token {gh_token}", + "Accept": "application/vnd.github.v3+json", + }) + with urlopen(request, timeout=10) as response: + return json.loads(response.read().decode()) + + +def validate_gh_token(repo_url=None): + """Validate GitHub token exists, is valid, and has repo access.""" + console.print("\n[bold]Checking GitHub token...[/bold]") + + gh_token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") or "" + if not gh_token: + _check_fail("GH_TOKEN or ER_SETUP_TOKEN not set") + raise PreflightError( + "No GitHub token found. Set GH_TOKEN or ER_SETUP_TOKEN environment variable." + ) + _check_pass("Token environment variable found") + + try: + user_data = _github_api_get("/user", gh_token) + username = user_data.get("login", "unknown") + _check_pass(f"Token is valid (authenticated as: {username})") + except HTTPError as error: + _check_fail(f"Token validation failed (HTTP {error.code})") + if error.code == 401: + raise PreflightError("GitHub token is invalid or expired.") from error + raise PreflightError(f"GitHub API error: HTTP {error.code}") from error + except URLError as error: + _check_fail(f"Cannot reach GitHub API: {error.reason}") + raise PreflightError(f"Cannot reach GitHub API: {error.reason}") from error + + if repo_url: + repo_url_clean = repo_url.rstrip("/").removesuffix(".git") + repo_path = repo_url_clean.split("github.com/")[-1] + try: + _github_api_get(f"/repos/{repo_path}", gh_token) + _check_pass(f"Token has access to {repo_path}") + except HTTPError as error: + _check_fail(f"Cannot access {repo_path} (HTTP {error.code})") + if error.code == 404: + raise PreflightError( + f"Token does not have access to {repo_path}. " + "Check the token has 'repo' scope and org access." + ) from error + raise PreflightError( + f"GitHub API error for {repo_path}: HTTP {error.code}" + ) from error + + return gh_token + + +def validate_claude_credentials(): + """Validate Claude credentials file exists and is structurally valid.""" + console.print("\n[bold]Checking Claude credentials...[/bold]") + + credentials_path = CLAUDE_HOME / ".credentials.json" + if not credentials_path.exists(): + _check_fail(f"Credentials file not found: {credentials_path}") + raise PreflightError( + f"Claude credentials not found at {credentials_path}. " + "Run 'claude' to authenticate first." + ) + _check_pass("Credentials file exists") + + try: + with open(credentials_path, encoding="utf-8") as credentials_file: + credentials = json.load(credentials_file) + except json.JSONDecodeError as error: + _check_fail("Credentials file is not valid JSON") + raise PreflightError(f"Invalid JSON in {credentials_path}") from error + _check_pass("Credentials file is valid JSON") + + oauth_data = credentials.get("claudeAiOauth", {}) + access_token = oauth_data.get("accessToken", "") + if not access_token: + _check_fail("No accessToken found in credentials") + raise PreflightError( + "Claude credentials missing accessToken. Re-run 'claude' to authenticate." + ) + _check_pass("Access token present") + + expires_at_ms = oauth_data.get("expiresAt", 0) + now_ms = int(time.time() * 1000) + if expires_at_ms and expires_at_ms < now_ms: + refresh_token = oauth_data.get("refreshToken", "") + if refresh_token: + _check_warn("Access token expired, but refresh token exists (Claude may auto-refresh)") + else: + _check_fail("Access token expired and no refresh token") + raise PreflightError( + "Claude access token has expired. Re-run 'claude' to re-authenticate." + ) + else: + remaining_hours = (expires_at_ms - now_ms) / (1000 * 60 * 60) + _check_pass(f"Access token valid ({remaining_hours:.0f}h remaining)") + + +def validate_claude_auth_works(): + """Run a minimal Claude prompt to verify auth actually works.""" + console.print("\n[bold]Testing Claude authentication...[/bold]") + + try: + result = subprocess.run( + ["claude", "-p", "say ok", "--max-turns", "1"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + _check_pass("Claude auth verified (test prompt succeeded)") + else: + stderr_preview = result.stderr.strip()[:200] if result.stderr else "no stderr" + _check_fail(f"Claude test prompt failed (exit {result.returncode}): {stderr_preview}") + raise PreflightError( + f"Claude auth test failed. Exit code: {result.returncode}. " + f"stderr: {stderr_preview}" + ) + except FileNotFoundError as error: + _check_fail("'claude' command not found on host") + raise PreflightError( + "'claude' is not installed on the host. Install with: npm install -g @anthropic-ai/claude-code" + ) from error + except subprocess.TimeoutExpired: + _check_warn("Claude test prompt timed out (30s) - proceeding anyway") + + +def validate_docker_available(): + """Check that docker is available and running.""" + console.print("\n[bold]Checking Docker...[/bold]") + + try: + result = subprocess.run( + ["docker", "info"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if result.returncode == 0: + _check_pass("Docker is available and running") + else: + _check_fail("Docker is not running or not accessible") + raise PreflightError( + "Docker is not running. Start Docker and try again." + ) + except FileNotFoundError as error: + _check_fail("'docker' command not found") + raise PreflightError("Docker is not installed.") from error + + +def run_all_preflight_checks(repo_url=None): + """Run all preflight checks. Raises PreflightError on first failure.""" + console.print(Panel("[bold]Preflight Checks[/bold]", expand=False)) + + validate_docker_available() + gh_token = validate_gh_token(repo_url=repo_url) + validate_claude_credentials() + validate_claude_auth_works() + + console.print("\n[bold green]All preflight checks passed![/bold green]\n") + return gh_token diff --git a/bin/ci_tool/requirements.txt b/bin/ci_tool/requirements.txt new file mode 100644 index 0000000..cde09fd --- /dev/null +++ b/bin/ci_tool/requirements.txt @@ -0,0 +1,2 @@ +rich +InquirerPy From ac3db7860956c1549663fc829e22422e2cecccf9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 00:50:04 +0000 Subject: [PATCH 02/60] testing --- .helper_bash_functions | 39 ++++++- bin/ci_tool/__pycache__/ci_fix.cpython-38.pyc | Bin 4114 -> 6046 bytes .../__pycache__/claude_setup.cpython-38.pyc | Bin 5289 -> 6195 bytes bin/ci_tool/ci_fix.py | 62 ++++++++++- bin/ci_tool/claude_setup.py | 25 ++++- bin/merge_helper_vars.py | 105 ++++++++++++++++++ 6 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 bin/merge_helper_vars.py diff --git a/.helper_bash_functions b/.helper_bash_functions index 04088be..5eaccb5 100644 --- a/.helper_bash_functions +++ b/.helper_bash_functions @@ -116,11 +116,35 @@ ps_aux() { ps aux | cgrep $1 | grep -v grep ; } kill_any_process() { ps_aux_command $1; conf="$(confirm "kill these processes? [Y/n]")"; if [[ $conf == "y" ]]; then echo "killing..."; sudo kill -9 $(ps_aux $1 | awk {'print $2}'); sleep 1; echo "remaining: "; ps_aux_command $1 else echo "not killing"; fi ; } docker_exec () { if [[ $(docker container ls -q | wc -l) -eq 1 ]]; then docker exec -it $(docker container ls -q) bash; else echo "wrong number of containers running"; fi; } awk_line_length() { if [[ -z $2 ]]; then MAX_LINE_LENGTH=200; else MAX_LINE_LENGTH=$2; fi; cat $1 | awk 'length($0) < '"$MAX_LINE_LENGTH"''; } -update_helper_bash_functions() { if [ ! -f ~/.helper_bash_functions ]; then - echo -e "${Red}ERROR: Tried to replace this file but couldn't find it, something has gone wrong!${Color_Off}\n" - return - fi - wget -O ~/.helper_bash_functions ${THIS_SCRIPT_URL} +MERGE_VARS_URL="https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/refs/heads/${THIS_SCRIPT_BRANCH}/bin/merge_helper_vars.py" + +update_helper_bash_functions() { + if [ ! -f ~/.helper_bash_functions ]; then + echo -e "${Red}ERROR: Tried to replace this file but couldn't find it, something has gone wrong!${Color_Off}\n" + return 1 + fi + + local tmp_new + tmp_new=$(mktemp) + if ! wget -q -O "${tmp_new}" "${THIS_SCRIPT_URL}"; then + echo -e "${Red}Error: Failed to download updated helper_bash_functions${Color_Off}" + rm -f "${tmp_new}" + return 1 + fi + + local merge_script + merge_script=$(curl -fSL "${MERGE_VARS_URL}") || { + echo -e "${Red}Error: Failed to fetch merge_helper_vars.py${Color_Off}" + rm -f "${tmp_new}" + return 1 + } + + python3 <(echo "${merge_script}") ~/.helper_bash_functions "${tmp_new}" + cp "${tmp_new}" ~/.helper_bash_functions + rm -f "${tmp_new}" + + echo "" + echo -e "${Green}helper_bash_functions updated. Run 'source ~/.helper_bash_functions' to reload.${Color_Off}" } # python linters @@ -204,7 +228,10 @@ install_ci_tool() { return 1 fi echo -e "${Yellow}Installing Python dependencies...${Color_Off}" - pip3 install -r "${CI_TOOL_CACHE_DIR}/ci_tool/requirements.txt" + if ! pip3 install --user -r "${CI_TOOL_CACHE_DIR}/ci_tool/requirements.txt"; then + echo -e "${Red}Error: Failed to install Python dependencies${Color_Off}" + return 1 + fi echo -e "${Green}ci_tool installed to ${CI_TOOL_CACHE_DIR}${Color_Off}" } diff --git a/bin/ci_tool/__pycache__/ci_fix.cpython-38.pyc b/bin/ci_tool/__pycache__/ci_fix.cpython-38.pyc index 72c0d987126aca9c5a706e3ad75a8c0c3baa743e..0f77ee1925d65bd3f6f4a1c8d1738fa9c66a6b68 100644 GIT binary patch delta 2673 zcmai0O>7&-6`q-0F3FWh%KEKrSsFWzW5yy?`A4?Z0D&Ugu>es-1SU-#t zY=8~INSW~#Dh_?oe$_PGCq}YJK5xy@*L``bu}bGW>4!l?=NG7G1k{hHyY2dQceT!` zD`)BZp6|ed(-8Hhy%KSHuePQww|%}t-&adMYtmAw9@a`#Kyr4EhT{L3-J`M<(khqU z8kK8Y9Sz{9Y}?CgTyV{SGC%SJ-*N+Qm#%WT!+8K!o?EAGz!XICNK(P0MqNf_`fk_| zbUj?1wWrH;2^>)yE0d^$s_$x?=WfXc9MMwCU9f$jOm99*M#Vu9qK(K8YI-N|dH(i- zeX&gE!_6%hcF_CpgtSC`#sleBce}^QHCNItH+p*TYC}>L8R`c>d{RZHwe5SHZgUa2 z_N6ji2)2Q$Uvp(=V-bdudTs;49ae$(?sMTd$AfKOguy193k%gM1TFA*G)bku35+M{ zrXOkQjBmj#_*O33m%&T_zUJ*m!%+j$3xNa*in@+$eK#33-yEN$J8Qm5jgDS-f2OT> z)^?M1^Kr|7Dx60MM{k1?qXWe7AwIy29O5RHB*qUgGd7TTSsKiY(L?lzJix!fF=nYz z1a@G}tWne?F*z^~QM3eh>6jeZ5vbS9-d}Fqm%{a=<{UIOfvTrV?`I3wX;p-q)cs)< zuPoh$GNSOg1>V(pL}3Op;cxI+Jx!Be_YA%iirN$uS2zW`tLmSElt)w37cO49Jagsh zwVzHuwQc>V?G%*V)U~TuW-e>gKk?L_)!Fbn0^!-C^KKA8K6%$0RbJma0PJSzcNols z5_==6bYT(NP(i@yMdkG@EvDL*h@|kh+No%(?#s4O=Rw3rnP#Bi*t06`{Gnv;}_SufNkQ6{74 z5MxcNO9Vqb=8|ty*Xd%>Y+G9puPxcmdto45Kj6X%T&T|D5QyYeYvP0$1WiyS20-j( zN+r)PK?20_WZfDYI;B=lgD4szt@IHP$seo}EpbvkYB3I4I}gc=xKs7-JFcijcVR9% z3j!Gg>+ip;v#?SA}E!}YLEP=AzwyL4nbRpc=Z`_<) zxxL(-huFOc=VYD}{&~S03)WxN-nS(6L zDD53|8L@0L^{dof^ezg>T?lE@l6DLi;m26CUXwja6;EXEdXB;LL&SO>8LU^m%ReLP zO|D0HldG|2uz`JYkUvDtba3Gr%phQfejF30S^oxsgL_#P@|$k4KK*z`78DJP*l^Pd z#=tD6=WllCeT<`(q7Qo zP}ec=w|FLbGy6+?D)}-yZC5OQAO2P_aXR@&b`}pN=k4TdB_lZe-GKLR#e(bF9I)K=}jI*ZjD!xzuRBqO7h2^zk>X2@3)y@-Cj2(To@z+ bO#xq!XR>{`k5~rzHyMK8Xy#bvL?-ti9|*?s delta 1263 zcmah|&u<$=6rPz~uh(09F?QDO zc&wCYw?=9Yy;y1)aY}<6i34!s59oiu0VLGJ-YUVN2lxjNh?(7BwOp{JZ{EE3eQ#zy zKYzXY<)VEfm$Q)kyrX~BUMoMgYxw)eZ|s*K_W<3lfekZqod@kYYUF?T<6T|+Z93v0 zml=I4H5_r=LV%xWAqEnXfN&C^`?!PHHvtMr6@k_vY%?IBSJ1S!scI@yMFED?1+_EI zqmbOmAF0wEgFXC@>ftM@Rr3`YhD;Srv_yW0aeTm0ODykhiEG(Qmm6AS^jUAG&m+6p z^SRgdDRX^qhekG|vI4^+o5*m7BU%I@0 z@y3T+?#AY|D;rxZEz^9Ne;ME(UG zaie3gFE9#I812=S8q6TIj}8)TdSXIGb-9Vw5LjXAUg{S52>Ij|0zEW%CLs6mBg{tc zaZ82lM}F+HEF25SK7#Cl201kxPYC3}iPr)Y)!NdaG$xbWAqvy}*_SkBshRzMO^}9S zhscAoU|mN+dIV0qJ;iNx4UC`?8h%aE>^Q%kT<;Mctn-VM7_!NO#35Pbu>-Hg zz9}!NaWr*-=MyGW;P{wGZ=IwUA1U(OR8~__piK%e5A{P5ny?U<(vkTCEzIy@K!S|? zffmP_Y@w7Vrpni+(aC9aDvnA=50me0EbV=KMULg7oR!7h9_wt2XZfo}JZ=TC40lQnT&yUaz~-?0Q3>?tt=P zpBXXX>Fl|yFA>`A^gqdL)ptg*oPQhi8Qt!-Tc2{4S4Bs`n+hrl78IJLy+a&7AKDJPcVzr1^6uPz4Y-XB-O_Pw!R(4t3 zL-kOkhcbAQf;|?z$W||cUOfulyepm+yeZCXT1!D_C(OrW{@3?ElKpJ`V%xpe)`Y;; z_r!B^sduNXAH3fgd)wNN2A&A{J``FMvV*U`HHX1I{Sm$iI_tT}1_1f`LG+#2n_`Au znnR33oI{jDcYQ1VM~nt?y4^Aiy6Xomn_}00mFYtJCU{6cwO_sefJCYSYN~=40*dX1 zHbo$iSXHP3enN4^CQ-SuhzTsYP&WxQ5w?j*%HxnYMQlq2)yD{`C1Q+2)kaV^;M~GI zz0=VH^3>?~4B~V;IeY#~k7@fvwX9X9w93_2bz@bt%ze>oW(nl!)8qt5(+|n6OgQ4@ z*p((SI9mK1VS7#xfIm%_!hJO&yaXt+2N+?Ir4VhT(6k#uAr#&YG4e4+K3=&p9|S&R z)*#8_^E5P-VED)+24wHh^_&fs9ttv(EAp~dR_&ETd(+mBT$nc09Qr1ltf^mtAds?) zxoAc~ctf#EmIW`*3}1rPDjd;Do&}{fe{lgCgt-2sqOhc8R!O5&kTqvraaFCT>eyF; zJ0_J>PczI7EERFtV_{|8)nOGP1f8{EmN zhU= z#VEMhm9>nK@z&-&Y`dAHgn;^rgh2!ch~NYfY#>5(^F+>nOl%PO$y0gM7)3Yl;b~?P z#j1F62!AtU*yJ1hPIf^htThZN>@|$D8B#drGB-0ZGNf<`)h znUb1Ul37xzkeHXEP?DdWnx~tTSe%+Nxj;ai@z&%Hfy;~>lVb!OIW(DyxPk7uHF=+) zr3A<|3`~4XT#Q1DLQE`-OhCx>ugG?Dq-Yu=qu}HNV&05hlX=9I7^Nrcix)$DX9e<( bHHhE?5&R$mq*QkDJ#lqLjmc~hT8sh!?CMK- diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 46ce5c9..6f5ab16 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -43,12 +43,54 @@ "--- END ---" ) +CI_RUN_COMPARE_PROMPT = ( + "You are inside a CI reproduction container at /ros_ws. " + "Source the ROS workspace: " + "`source /opt/ros/noetic/setup.bash && source /ros_ws/install/setup.bash`.\n\n" + "A GitHub Actions CI run is available at: {ci_run_url}\n" + "Use `gh run view {run_id} --log-failed` or `gh run view {run_id} --log` " + "to fetch the CI logs.\n\n" + "There is a discrepancy between local and CI test results. Your job:\n" + "1. Run the tests locally and capture the results\n" + "2. Fetch the CI run logs using the gh CLI\n" + "3. Compare the two - identify tests that pass locally but fail in CI, or vice versa\n" + "4. Investigate the root cause of any discrepancy (environment differences, " + "timing, missing deps, etc.)\n" + "5. Fix the issue and verify locally\n\n" + "When done, print EXACTLY this format:\n\n" + "--- SUMMARY ---\n" + "Problem: \n" + "Fix: \n" + "Assumptions: \n\n" + "--- COMMIT MESSAGE ---\n" + "\n" + "--- END ---" +) + + +def extract_run_id_from_url(ci_run_url): + """Extract the numeric run ID from a GitHub Actions URL. + + Handles URLs like: + https://github.com/org/repo/actions/runs/12345678901 + https://github.com/org/repo/actions/runs/12345678901/job/98765 + """ + # Split on /runs/ and take the next path segment + parts = ci_run_url.rstrip("/").split("/runs/") + if len(parts) < 2: + raise ValueError(f"Cannot extract run ID from URL: {ci_run_url}") + run_id = parts[1].split("/")[0] + if not run_id.isdigit(): + raise ValueError(f"Run ID is not numeric: {run_id}") + return run_id + def parse_fix_args(args): """Parse fix-specific arguments, separating them from reproduce args.""" parsed = { - "prompt": DEFAULT_PROMPT, + "prompt": None, "container_name": DEFAULT_CONTAINER_NAME, + "ci_run_url": None, "reproduce_args": [], } @@ -57,6 +99,9 @@ def parse_fix_args(args): if args[i] == "--prompt" and i + 1 < len(args): parsed["prompt"] = args[i + 1] i += 2 + elif args[i] == "--ci-run" and i + 1 < len(args): + parsed["ci_run_url"] = args[i + 1] + i += 2 elif args[i] in ("--container-name", "-n") and i + 1 < len(args): parsed["container_name"] = args[i + 1] i += 2 @@ -67,11 +112,24 @@ def parse_fix_args(args): return parsed +def build_prompt(parsed): + """Build the Claude prompt based on parsed args.""" + if parsed["prompt"] is not None: + return parsed["prompt"] + + ci_run_url = parsed["ci_run_url"] + if ci_run_url: + run_id = extract_run_id_from_url(ci_run_url) + return CI_RUN_COMPARE_PROMPT.format(ci_run_url=ci_run_url, run_id=run_id) + + return DEFAULT_PROMPT + + def fix_ci(args): """Main fix workflow: preflight -> ensure container -> install Claude -> run -> drop to shell.""" parsed = parse_fix_args(args) container_name = parsed["container_name"] - prompt = parsed["prompt"] + prompt = build_prompt(parsed) console.print(Panel("[bold cyan]CI Fix with Claude[/bold cyan]", expand=False)) diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index 2f8d5ff..f9d8ea9 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -120,7 +120,7 @@ def copy_helper_bash_functions(container_name): def configure_git_in_container(container_name): - """Set up git identity and token-based auth in the container.""" + """Set up git identity, token-based auth, and gh CLI auth in the container.""" gh_token = os.environ.get("GH_TOKEN", "") console.print("[cyan]Configuring git in container...[/cyan]") @@ -133,6 +133,29 @@ def configure_git_in_container(container_name): f'git config --global url."https://{gh_token}@github.com/"' f'.insteadOf "https://github.com/"', ) + install_and_auth_gh_cli(container_name, gh_token) + + +def install_and_auth_gh_cli(container_name, gh_token): + """Install gh CLI and authenticate with the provided token.""" + console.print("[cyan]Installing gh CLI in container...[/cyan]") + docker_exec(container_name, ( + "type gh >/dev/null 2>&1 || (" + "curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg " + "| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg " + "&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg " + '&& echo "deb [arch=$(dpkg --print-architecture) ' + "signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] " + 'https://cli.github.com/packages stable main" ' + "| tee /etc/apt/sources.list.d/github-cli-stable.list > /dev/null " + "&& apt-get update && apt-get install -y gh)" + ), check=False) + console.print("[cyan]Authenticating gh CLI...[/cyan]") + docker_exec( + container_name, + f'echo "{gh_token}" | gh auth login --with-token', + check=False, + ) def setup_claude_in_container(container_name): diff --git a/bin/merge_helper_vars.py b/bin/merge_helper_vars.py new file mode 100644 index 0000000..8f318cd --- /dev/null +++ b/bin/merge_helper_vars.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Merge user-customised variables when updating .helper_bash_functions. + +Reads old and new versions of the file, extracts top-level variable assignments, +and applies these rules: + - Variable only in old file (user-added): preserve it + - Same value in both: keep new (no-op) + - Different value: ask the user which to keep + +Writes the merged result to the new file path. + +Usage: python3 merge_helper_vars.py +""" +import re +import sys + +SKIP_VARS = {"Red", "Green", "Yellow", "Color_Off"} +VAR_PATTERN = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)') +REFERENCES_OTHER_VAR = re.compile(r'\$\{') +FUNCTION_OR_SECTION = re.compile(r'^[a-zA-Z_]+\(\)|^# [A-Z]') + + +def extract_top_level_vars(filepath): + """Extract VAR=value lines from the top of the file, before functions start.""" + variables = {} + with open(filepath, encoding="utf-8") as file_handle: + for line in file_handle: + stripped = line.rstrip('\n') + if FUNCTION_OR_SECTION.match(stripped): + break + match = VAR_PATTERN.match(stripped) + if not match: + continue + var_name = match.group(1) + if var_name in SKIP_VARS: + continue + if REFERENCES_OTHER_VAR.search(match.group(2)): + continue + variables[var_name] = match.group(2) + return variables + + +def ask_user(var_name, old_value, new_value): + """Ask user which value to keep. Returns the chosen value.""" + print(f"\n\033[0;33m{var_name} has changed:\033[0m") + print(f" Current: {old_value}") + print(f" Updated: {new_value}") + response = input(" Keep current value? [Y/n] ").strip().lower() + if response in ("n", "no"): + print(" \033[0;32mUsing updated value\033[0m") + return new_value + print(" \033[0;32mKeeping current value\033[0m") + return old_value + + +def apply_var_to_file(filepath, var_name, value): + """Set a variable in the file, replacing if present or inserting after Color_Off.""" + with open(filepath, encoding="utf-8") as file_handle: + lines = file_handle.readlines() + + new_line = f"{var_name}={value}\n" + replaced = False + for i, line in enumerate(lines): + if line.startswith(f"{var_name}="): + lines[i] = new_line + replaced = True + break + + if not replaced: + for i, line in enumerate(lines): + if line.startswith("Color_Off="): + lines.insert(i + 1, new_line) + break + + with open(filepath, "w", encoding="utf-8") as file_handle: + file_handle.writelines(lines) + + +def main(): + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + old_file, new_file = sys.argv[1], sys.argv[2] + old_vars = extract_top_level_vars(old_file) + new_vars = extract_top_level_vars(new_file) + + vars_to_apply = {} + + for var_name, old_value in old_vars.items(): + if var_name not in new_vars: + print(f"\033[0;32mPreserving\033[0m {var_name}={old_value} (not in updated script)") + vars_to_apply[var_name] = old_value + elif old_value != new_vars[var_name]: + chosen = ask_user(var_name, old_value, new_vars[var_name]) + if chosen == old_value: + vars_to_apply[var_name] = old_value + # else: same value, nothing to do + + for var_name, value in vars_to_apply.items(): + apply_var_to_file(new_file, var_name, value) + + +if __name__ == "__main__": + main() From d88e8f6ba32f3f56bf3ddf1babeab0bf60dbc98c Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 00:51:24 +0000 Subject: [PATCH 03/60] pyc --- .gitignore | 1 + bin/ci_tool/__pycache__/__init__.cpython-38.pyc | Bin 137 -> 0 bytes bin/ci_tool/__pycache__/__main__.cpython-38.pyc | Bin 406 -> 0 bytes bin/ci_tool/__pycache__/ci_fix.cpython-38.pyc | Bin 6046 -> 0 bytes .../__pycache__/ci_reproduce.cpython-38.pyc | Bin 3205 -> 0 bytes .../__pycache__/claude_setup.cpython-38.pyc | Bin 6195 -> 0 bytes bin/ci_tool/__pycache__/cli.cpython-38.pyc | Bin 2449 -> 0 bytes .../__pycache__/containers.cpython-38.pyc | Bin 4582 -> 0 bytes bin/ci_tool/__pycache__/preflight.cpython-38.pyc | Bin 6903 -> 0 bytes 9 files changed, 1 insertion(+) create mode 100644 .gitignore delete mode 100644 bin/ci_tool/__pycache__/__init__.cpython-38.pyc delete mode 100644 bin/ci_tool/__pycache__/__main__.cpython-38.pyc delete mode 100644 bin/ci_tool/__pycache__/ci_fix.cpython-38.pyc delete mode 100644 bin/ci_tool/__pycache__/ci_reproduce.cpython-38.pyc delete mode 100644 bin/ci_tool/__pycache__/claude_setup.cpython-38.pyc delete mode 100644 bin/ci_tool/__pycache__/cli.cpython-38.pyc delete mode 100644 bin/ci_tool/__pycache__/containers.cpython-38.pyc delete mode 100644 bin/ci_tool/__pycache__/preflight.cpython-38.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/bin/ci_tool/__pycache__/__init__.cpython-38.pyc b/bin/ci_tool/__pycache__/__init__.cpython-38.pyc deleted file mode 100644 index 287b173d208ee4a2c2d2c98cc912b1048fafd80a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 137 zcmWIL<>g`kf)wVdnIQTxh(HF6K#l_t7qb9~6oz01O-8?!3`HPe1o2BxKRLgsB(*|6 zwJ1KRG&3h9z9c_Cr&vEJGfzJ`6U@<%kI&4@EQycTE2zB1VUwGmQks)$2Qud~5HkP( DDjgnZ diff --git a/bin/ci_tool/__pycache__/__main__.cpython-38.pyc b/bin/ci_tool/__pycache__/__main__.cpython-38.pyc deleted file mode 100644 index a32d8973f844808f01c4a1064c5263dd591ccfc4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 406 zcmYjLJx{|h6ttbRNxxR+Oh_H52R4L&g_Q*=p)5~H?5YOGj%-&Vto#Oc82L+HnfMD# z*ino6EZ@oR-aFrVv6!Mzo8(=bV1&Noz)*=8W<=r=4@^qJ@l*1VGR#Tf z(GiY$_KAl>aNJ5{-cHI1MtsWBXJQ`s0bK@@zC88`?Rfu@;N|cfaT0*08BRkKC!*^L|dQ6Wj_|m za^rl)gi%$?eOA?WP{Q^`NDffW_V%kr3PZvd(K!wSd?B+4jRAgFw9&@#7s?IPJ0aN+ k*wR|4Lqd9pwCZ}FU$&ac+tXA1nCj}U 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 diff --git a/bin/ci_tool/__pycache__/ci_reproduce.cpython-38.pyc b/bin/ci_tool/__pycache__/ci_reproduce.cpython-38.pyc deleted file mode 100644 index f71d8923cc6b4adbf0eb222018539141a02d7692..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3205 zcmZ8jOLH5?5uSZu0W3g>4~P_HNnXo}DYy(Gj^e0FQPc?6-kwprJ1e8&Jb8} zAJEJ|7PkN&LSI#-OAaYVRn#r{2l*{qx%s4v4?a3&JKeJY#Rx3UbkA$MzwY^Z9?ZP@;78Gc(>-tk z&U_grJ;!%$q`^MpPLL+j4->{$UxRO?rR6n0Nm2>R(nM^4UI~*Y!;mu$a(N8~(};ms zy6-0}YMJ@sjrE(?AKcsa*1p)-zJ6z8ebd{x{-^bP_V{RzJqv{tdGpOkC_Fw)5-=s7 znLNKJ)Xu6OP+>2O=6bF)x2`+wzvJo-Sv&U0oZJ< zZ$G#{(Mfi9BYkUoI!x%QvxUb_wmB(x{(s}i^PQ(Y4-uJFh=rbtygd1MZuFSU>zl(w zhA~sfVAzzE|04V}IQ!#oIzh@Md)8sx>kh++0-|XoI^8ho0Ai?Me+=X5;2^KVvECH> zvv7*2Lqqg)nyKlU{)=hwCC~$ecKSnzP54(Ze+GgCFb}mGWcSx!>SJxJj{x$0i&sW) zv6hGgcn`IG0y-&1mWfU@fc!~CrMQ`0U4JIIA4sR&2J6yJn*$KhkQMm*yeFIplI|k} zk25*siL;R=0Fs_r#gJ541Vc0#~atY=|1sbByYG<^0jb8xyW}xHHW9H1d!npPED(`!@elBV$7&*#&7Hm8d>4z9laqKVg@3RHg<>56LHlnq%W@ z;}Q8YN%TiVR>r2Rjv#OO57K^IqotAgEuq#+gO+K9R{xs8_)Hl`YSRn zjY@Q8U+4d%<`x0D0`kA*44$T;UIYCH*+5x=iTuz6IJN z)TpsUmdUsb(%GwITul~7m7~+eIz$Fn;P?ye$p`eEQ5i5e_tF^K%EI$Xzc8`z-H}C| zQMqT1DobQsQ+~ZiFOF>G*Btov{&RC&SL2uHuSWI9i}ZsfLRVl%4f>P5Usk@i-uT}7 zFnfOunV2~~67=+XA?2PRWaQpyer1>Koo*UYSW9=0>CV{$R%Ge;b$PRHnJKp zbwTXTN_Kw5@gw*kJ#Y#tUUeS!eF==^(DV=4!lq(i$4~HlRU?=SpDrZ09cJRkEH9}G zmYu)L*q}HEE6HWC9}XskB~4ncxewF1wdN-Qi*hSaJ)BXFnGk-D<>jECh5-|~Rd9P= z64K{#8_6l?vtU1aZ>Pr@OLiX!{D86;h2sJN9#vO%I>n;w?+TdR%_-Fvh^mq$9JrBi zuM;U&3ygMcd;OUo4t9S49pKqGxA2jW_%(+g#P*j)|MxC6JHCA7`cZYsk- zAX|osWPHzu3ft~FF;q|>qBMyP+6iNn(KclR;g%kt$BK1B88MD*)6##OZMQ|h!+{j- zE(d)2*_#os7ix!u_vS3wwrwaRdS@3z`Nrh^B3xFzqCfLtzx1K15!0vZkW- zX|h4V4Oc}!TiFQ?{ABkglR+O*hJ2hDS5RqT&ow72?{w74%x<@P{kEzj{6jF)H3MM4 z%xv#%-2>um1pWZ{mU-z=4u&!}p}t6d85ZVd*M~3a?I0`J7cYH&5yW=3U{At*GGpgg zoKQeqAZ4e4ZY^=-Ohu=>h7#5tNT=dSl9vV?f|48JK;$MQjpP~z{7->a0<)Vo9E5e+UA}6+gsirH?ME3-R4;Na?2kK z0Dd@HQgF95!{32nw}E%U8%$p%{9RmIV)!@#c%~QAQ@6^3A_n)KH;au$UQDvUo!JW` zFqBbMTKG9!Q~QeJS2>m)x4JisA~osO_b`M|k;E{PZx^ik$*|))M|=$p(XnJ(T19JU zv+#FLH}wjXaT{9r`$_-VuA;6qXq!;ZOx=PVu<*Z?vJJ8cXPB_Rq19m@w1)kQUYa*_ z0*?Wr|EwGOPi6_v01b2t z9ScMEq0U<-lj%dBGVaKa{u6!dLw^EZJDq9%Kp!%bwBK2P6e&?@8gYc(1@?0GobOye zoSUm?`1@Do&))A&Y1%(%F#cy?a8=Xv=&$H7t*J4c8NS{%nubpOoS$pvRBJX()#jUd zw5DI^7Mn#iC-0ZKmmh1*IbPhEf2y(KX{~vPm6{8z+&s)G zZ01v~xyWYO9NHsno*hDalr6BsXpgZ)b_DGbJIaosJx8sgyvmSeeLARRB@!g2zJQE%IF`m)o&`HIf+?x?5b>{2_OfrEK8# zY#G|4KYpYtF-Bg1a}K6S71hmr?Ogd3ss+^Ft`T~{XxvOntQ@V`nT zI)RSVc66pc)pbo81C1GvNlbKkGd@4O{2Fdu-;AtFuUNP4tkeBvhwpNr9$&iaZacxf zY`zz?t+6GTKBB%}uitH`x$$q@zVNMDD_Xy0b)@V?Z#Ei?Z`1=E7KMG`aw5JFaoO+L zmtLtqvB;$?RO44z;~t zS3bwEWmLjLGb*~N8~+jW=>K3D)P^z`2jBzGv4UQAiXdLyCy4QN2T0<4x>v@?wsqAB zWJiQO&#gILcJ-Qzi>`?x?jpzlDAuKqISiB2LxyCW8fVE_Oo&W8h$Z3>+I{RG2`NaP zL1!-{S&o^X=K#w*U|EQt8?vMox2CY*!af#c^G4Di1L650j`}_3NPYmau)z)BC+~K+ zyY(GRI5A`b7EFNRMcita1B-h(KsJ;3qd0H_9SKW$T6?4q^d0@F7M+y_GY0yX+7sik z{zqNrc1)I2Dw~JOnxwL_Ad3V2v5s;6G4ZMZUKQgDt7Mom47h?b9!SseBk)azd$ss* zeQXBwJq+S$OJT*VR@jHtbEFl&oU)tFA-&&!)EKvyx?9W>R;_0>L>S7((2_krF~sX# zLKPWn)lxFEdh6PUZ(X<*Mc0cW^5M;y-uQs=mecp;yVx_) zKZ)~-12?L<#H3@ALey`yyoX7Fai2^66{$Q4y$WD%c^TC@D$a?X^6(!^JhYJ$47fP&i`y2E8*`zuQYpa)^l$ zMoFO`_+GG8Ev7m%pJ_vvDUE54WVUyx4k!%?Pja%`YnJess%VQ7il!o_pW`Btg@@8H z=vhTyP|tb&sIg!y>I)bf_!k#2XP>^2?kj!!3pzu6%gpCdW~?K+8R{C|noqU2w0i=Y zXfP8RnH%Jkp}lMzTeQ{uH=D&oNJ;_0nNK48CjA4>teg`J@ zvmU_+%hI4gW|+RQ%Ai1Np5#cM?mxgTprkqzzn1Dy8dLT}xXE25@g)C|Dxp8r)#MCeQp$kW}g3fSRyhpu< z*KNebM!G64k37iNdZSvNrWc@8asfXh&VhnmSD;j72K_xv(^=Grb zl1cN}D1xxF_vA#7n<&94?(AjC1klrYw~0AKTIx(pIFng8l@c7vA_a`bJfKda0{-W} z5tlG?s^}Ele}%6UFCib&vxvb!#IUSl1cMAW1@mfTDmOyV9g7nXH6T_P1^vbNX9}8s zXw*C0hk>&x}Ieg80haZSMQwC-)BAauiQov?7?; z9sqr}ku6$_Ym=5er7BGXMpW%$JwB5e(y7$PqAhsOs@=C<%IsD4KmsU$62N@-1C+zYAO}K*TFix4th@TdsoKmVZ^>&*$CW}%CP{=+2tp$Jx zzi-8B(_l0TyMK?bQrpyb45Z@5Q{&OTAX64#&+@Qj6l_x130t@OW}u6Y)msFisf|?g zO0a{v0#_M!uM)qrj+Cw6v)Y6m6^qL4i!;4)UB~mS<@klEy9mF&Jj`X|+EvWy^fy!*yIiN-fIIBHmNl9({(TMB>?KGcDe~Zf zC*a9*$ZyBoJ;^GWd;8`c`@{9?AJ}Wx5C_kXhS%S{cJo$pe3*bBD6@%`cDsXk)_ayk zz$FY7xA)p9z$#@Pw?WiI?h@nN3|18QF6xU0tQ3BgZ_T{8g0C()li?rrN3gn#!9m%a zdY^{2RCs_@0&t2?5D;hCUI~yX$FC2wy&)bb@4(F{k#Q+y`vgo@nGB5h1AvO9c&f+s z6c*Wfg^CnX)6-z*pQw$IJZZJV3&Q$A z>y;T>Mxn*JOSD~i@jUBoktV28NlLFoIMNrqYDHc<;H{r}T?Z&cJdfJqkW4I4hU zpisdwcp^Es8eF;!q~wj%JV$kk4eBi8XN?$|u2LT?nA(w;#%e;}naj63)f-{~xQu_C z;^W#bn07HT=1~eM$Kta@B%ndyv;43P6xC`3zM9ggtSmr?-=kdO7kKv>{v!(3v$9ax z_;+E3l1S*0ZhWKWOnCW;LfuDLH>pRD;n`(wpsCi(T9{=uMsTWH_;%I846C-BwN&yO z#RgR~UcEs;N9^A3y$M^J7J{-&UR6AIOPrXO*+o@BKtHX5*5fxScQ-;GrBJE_!p>73 zNVY!PXP?sTPL6MhyiSLZnAj4>9%G?9#KwEE2SWF^lNc W%$3Te0)BI)Q>Bxoh0@{D(tiQ-T*NN` diff --git a/bin/ci_tool/__pycache__/cli.cpython-38.pyc b/bin/ci_tool/__pycache__/cli.cpython-38.pyc deleted file mode 100644 index ac2ac4e19b326c60aa562541d96aae88fefcd217..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2449 zcmbtWOK;pZ5GM8Bhn@J5hn>Vx3be?!j`v;!ZG(k_8g1PmY0yGOD##-3Rjie^6lHsb z^~voq$DWb^_OaLg9A10MUr14)GhEAS_-YB{42O^X$nP6!U(U=l2|Ppd&t%0T}@?A%qrxgTqKb7-dFE2S#8}^v%=^Ot4mJ4eY?yF*|hzZs2O)NxeZMXi&o4V-hr( z%RJV2XRsz~Y1?Kq+Rn0T+FoaK+Rn2Dw7X8wVvFp??<8omo9q_Y8Frg3ft_W`><-v# z>@K?p_BvZ(_rcCFpFIFO&&(I3^GWq!Ba>W2u}t>4-}`>UAMmX7cXHwPHhh`q>DmX} zaEGc^lx4Y$WRhpaCbYdI`>jj_7vQ@+2<9mV*M1gdJgx3O=R=XRGKN(*{D-mNk>q}y zXEI7Mn4=n^9?&tAl|=)t?ENS$c{TquImAf^iR}BmG%6Wa=1y{0ExzdUH1(5A=F^pw zU0}3Ydd?q-GV>)bWC3F?jBo_UXtmHwd6fC(aN1WWJH}vOtsf>*SsdyZH(Wmy90y7d zSx&)`MAFyBF=cem606e07Nbi#$_!@yVJwkjWFO8~S|_%2n8j@7yfIEGCDMKEF;_N_ z=Qrj_^OPKsF=5R;L;R{|m}9bgKEKndy6c$~BY&8~uKh=T(a#Si6ahmlDTa}Z`+iYw z$N69oWo+#O0{&n?wOOrfZRaWT<585og7f?d*xc$)Lr*(2aQNX6rd7*7_ao^a_>Rvm&|R5Nu$XP=s_o0C+VcI)g?<}R1w zCZa+Tl=T|qtgJmgD#QXtwSX*8)&!r{PZ4-$J1heC%+thAsyjIE22@`_nb2x#GLg`K z9k)q6IuA5AsU8($Uepg#j78ImiqO|K8PFr7f{ux%f&dlfrof3HzV`V+gmD5_*5aAO z*1(>3iO+DbAwC78{mX#wLC0e#1uBcsHWe7TGB^h)nZTO801JZRAuL}5LjN+L6^K27 zG6jT@D+5~8vIz024yTvCq2|xw)n9@_d=5WYLqz{F#QP9?3S|lrBUgsFFp(-?yogYs zoWv?rH6-*eL-HZ^9h4~~j9eMgb)79!I0azPcpky(H6HXY<9PtFXHceiFw$92ZWyvW z4#S`shC8J!1rI}k(${$xG&jz^;m<~sJGd>9xW9IGMGF*Z(9|LLO7L`YS?fH;tkL(S z&MvKDfyxocjqtHT2p53joh>kHmG^i6{|r+86#^+xl@@Iqp3#PUXd8~{LF%5pAFZah K=*@ZdEbWDg!a$f22^$3H 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 From b9c9e590b95172bf71de0f6f5e336d4e7324b400 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 00:57:23 +0000 Subject: [PATCH 04/60] move fix mode selection to interactive Python menu, remove hardcoded git identity --- bin/ci_tool/ci_fix.py | 97 ++++++++++++++++++++----------------- bin/ci_tool/ci_reproduce.py | 15 ++---- bin/ci_tool/claude_setup.py | 27 +++++++++-- bin/ci_tool/containers.py | 9 ++-- 4 files changed, 84 insertions(+), 64 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 6f5ab16..8c3e984 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -23,16 +23,7 @@ console = Console() -DEFAULT_PROMPT = ( - "You are inside a CI reproduction container at /ros_ws. " - "Source the ROS workspace: " - "`source /opt/ros/noetic/setup.bash && source /ros_ws/install/setup.bash`.\n\n" - "The CI tests have already been run. Your job:\n" - "1. Examine the test output in /ros_ws/test_output.log to identify failures\n" - "2. Find and fix the root cause in the source code under /ros_ws/src/\n" - "3. Rebuild the affected packages\n" - "4. Re-run the failing tests to verify your fix\n" - "5. Iterate until all tests pass\n\n" +SUMMARY_FORMAT = ( "When done, print EXACTLY this format:\n\n" "--- SUMMARY ---\n" "Problem: \n" @@ -43,11 +34,26 @@ "--- END ---" ) -CI_RUN_COMPARE_PROMPT = ( +ROS_SOURCE_PREAMBLE = ( "You are inside a CI reproduction container at /ros_ws. " "Source the ROS workspace: " "`source /opt/ros/noetic/setup.bash && source /ros_ws/install/setup.bash`.\n\n" - "A GitHub Actions CI run is available at: {ci_run_url}\n" +) + +FIX_FROM_LOG_PROMPT = ( + ROS_SOURCE_PREAMBLE + + "The CI tests have already been run. Your job:\n" + "1. Examine the test output in /ros_ws/test_output.log to identify failures\n" + "2. Find and fix the root cause in the source code under /ros_ws/src/\n" + "3. Rebuild the affected packages\n" + "4. Re-run the failing tests to verify your fix\n" + "5. Iterate until all tests pass\n\n" + + SUMMARY_FORMAT +) + +CI_RUN_COMPARE_PROMPT_TEMPLATE = ( + ROS_SOURCE_PREAMBLE + + "A GitHub Actions CI run is available at: {ci_run_url}\n" "Use `gh run view {run_id} --log-failed` or `gh run view {run_id} --log` " "to fetch the CI logs.\n\n" "There is a discrepancy between local and CI test results. Your job:\n" @@ -57,16 +63,15 @@ "4. Investigate the root cause of any discrepancy (environment differences, " "timing, missing deps, etc.)\n" "5. Fix the issue and verify locally\n\n" - "When done, print EXACTLY this format:\n\n" - "--- SUMMARY ---\n" - "Problem: \n" - "Fix: \n" - "Assumptions: \n\n" - "--- COMMIT MESSAGE ---\n" - "\n" - "--- END ---" + + SUMMARY_FORMAT ) +FIX_MODE_CHOICES = [ + {"name": "Fix CI failures (from test_output.log)", "value": "fix_from_log"}, + {"name": "Compare with GitHub Actions CI run", "value": "compare_ci_run"}, + {"name": "Custom prompt", "value": "custom"}, +] + def extract_run_id_from_url(ci_run_url): """Extract the numeric run ID from a GitHub Actions URL. @@ -75,7 +80,6 @@ def extract_run_id_from_url(ci_run_url): https://github.com/org/repo/actions/runs/12345678901 https://github.com/org/repo/actions/runs/12345678901/job/98765 """ - # Split on /runs/ and take the next path segment parts = ci_run_url.rstrip("/").split("/runs/") if len(parts) < 2: raise ValueError(f"Cannot extract run ID from URL: {ci_run_url}") @@ -85,24 +89,39 @@ def extract_run_id_from_url(ci_run_url): return run_id +def select_fix_mode(): + """Let the user choose how Claude should fix CI failures.""" + mode = inquirer.select( + message="How should Claude fix CI?", + choices=FIX_MODE_CHOICES, + default="fix_from_log", + ).execute() + + if mode == "fix_from_log": + return FIX_FROM_LOG_PROMPT + + if mode == "compare_ci_run": + ci_run_url = inquirer.text( + message="GitHub Actions run URL:", + validate=lambda url: "/runs/" in url, + invalid_message="URL must contain /runs/ (e.g. https://github.com/org/repo/actions/runs/12345)", + ).execute() + run_id = extract_run_id_from_url(ci_run_url) + return CI_RUN_COMPARE_PROMPT_TEMPLATE.format(ci_run_url=ci_run_url, run_id=run_id) + + return inquirer.text(message="Enter your custom prompt for Claude:").execute() + + def parse_fix_args(args): """Parse fix-specific arguments, separating them from reproduce args.""" parsed = { - "prompt": None, "container_name": DEFAULT_CONTAINER_NAME, - "ci_run_url": None, "reproduce_args": [], } i = 0 while i < len(args): - if args[i] == "--prompt" and i + 1 < len(args): - parsed["prompt"] = args[i + 1] - i += 2 - elif args[i] == "--ci-run" and i + 1 < len(args): - parsed["ci_run_url"] = args[i + 1] - i += 2 - elif args[i] in ("--container-name", "-n") and i + 1 < len(args): + if args[i] in ("--container-name", "-n") and i + 1 < len(args): parsed["container_name"] = args[i + 1] i += 2 else: @@ -112,24 +131,10 @@ def parse_fix_args(args): return parsed -def build_prompt(parsed): - """Build the Claude prompt based on parsed args.""" - if parsed["prompt"] is not None: - return parsed["prompt"] - - ci_run_url = parsed["ci_run_url"] - if ci_run_url: - run_id = extract_run_id_from_url(ci_run_url) - return CI_RUN_COMPARE_PROMPT.format(ci_run_url=ci_run_url, run_id=run_id) - - return DEFAULT_PROMPT - - def fix_ci(args): """Main fix workflow: preflight -> ensure container -> install Claude -> run -> drop to shell.""" parsed = parse_fix_args(args) container_name = parsed["container_name"] - prompt = build_prompt(parsed) console.print(Panel("[bold cyan]CI Fix with Claude[/bold cyan]", expand=False)) @@ -188,7 +193,9 @@ def fix_ci(args): # Step 2: Install Claude in container setup_claude_in_container(container_name) - # Step 3: Launch Claude + # Step 3: Select fix mode and launch Claude + prompt = select_fix_mode() + console.print("\n[bold cyan]Launching Claude Code...[/bold cyan]") console.print("[dim]Claude will attempt to fix CI failures autonomously[/dim]\n") diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index 406ab81..929b09b 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -23,16 +23,6 @@ DEFAULT_SCRIPTS_BRANCH = "ERD-1633_reproduce_ci_locally" -def get_gh_token(): - """Get GitHub token from environment.""" - token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") or "" - if not token: - raise RuntimeError( - "No GitHub token found. Set GH_TOKEN or ER_SETUP_TOKEN environment variable." - ) - return token - - def extract_repo_url_from_args(args): """Extract --repo/-r value from args list, or return None.""" for i, arg in enumerate(args): @@ -84,7 +74,10 @@ def reproduce_ci(args, skip_preflight=False): )) sys.exit(1) - token = get_gh_token() + token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") or "" + if not token: + console.print("[red]No GitHub token found. Set GH_TOKEN or ER_SETUP_TOKEN.[/red]") + sys.exit(1) scripts_branch = DEFAULT_SCRIPTS_BRANCH filtered_args = [] diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index f9d8ea9..7c0c92b 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -4,6 +4,7 @@ import json import os +import subprocess import tempfile from pathlib import Path @@ -14,8 +15,6 @@ console = Console() CLAUDE_HOME = Path.home() / ".claude" -GIT_USER_NAME = "Tom Queen" -GIT_USER_EMAIL = "tom.queen@extendrobotics.com" def install_node_in_container(container_name): @@ -119,19 +118,38 @@ def copy_helper_bash_functions(container_name): ) +def get_host_git_config(key): + """Read a value from the host's git config.""" + result = subprocess.run( + ["git", "config", "--global", key], + capture_output=True, text=True, check=False, + ) + value = result.stdout.strip() + if not value: + raise RuntimeError( + f"git config --global {key} is not set on the host. " + f"Set it with: git config --global {key} 'Your Value'" + ) + return value + + def configure_git_in_container(container_name): """Set up git identity, token-based auth, and gh CLI auth in the container.""" gh_token = os.environ.get("GH_TOKEN", "") + git_user_name = get_host_git_config("user.name") + git_user_email = get_host_git_config("user.email") + console.print("[cyan]Configuring git in container...[/cyan]") - docker_exec(container_name, f'git config --global user.name "{GIT_USER_NAME}"') - docker_exec(container_name, f'git config --global user.email "{GIT_USER_EMAIL}"') + docker_exec(container_name, f'git config --global user.name "{git_user_name}"') + docker_exec(container_name, f'git config --global user.email "{git_user_email}"') if gh_token: docker_exec( container_name, f'git config --global url."https://{gh_token}@github.com/"' f'.insteadOf "https://github.com/"', + quiet=True, ) install_and_auth_gh_cli(container_name, gh_token) @@ -155,6 +173,7 @@ def install_and_auth_gh_cli(container_name, gh_token): container_name, f'echo "{gh_token}" | gh auth login --with-token', check=False, + quiet=True, ) diff --git a/bin/ci_tool/containers.py b/bin/ci_tool/containers.py index 1c3cb5f..6bce9ec 100644 --- a/bin/ci_tool/containers.py +++ b/bin/ci_tool/containers.py @@ -21,9 +21,10 @@ def require_docker(): sys.exit(1) -def run_command(command, capture_output=False, check=True): +def run_command(command, capture_output=False, check=True, quiet=False): """Run a shell command, raising on failure.""" - console.print(f"[dim]$ {' '.join(command)}[/dim]") + if not quiet: + console.print(f"[dim]$ {' '.join(command)}[/dim]") return subprocess.run(command, capture_output=capture_output, check=check, text=True) @@ -56,13 +57,13 @@ def remove_container(container_name=DEFAULT_CONTAINER_NAME): console.print(f"[green]Container '{container_name}' removed[/green]") -def docker_exec(container_name, command, interactive=False, check=True): +def docker_exec(container_name, command, interactive=False, check=True, quiet=False): """Run a command inside a container.""" docker_command = ["docker", "exec"] if interactive: docker_command.extend(["-it"]) docker_command.extend([container_name, "bash", "-c", command]) - return run_command(docker_command, check=check) + return run_command(docker_command, check=check, quiet=quiet) def docker_exec_interactive(container_name=DEFAULT_CONTAINER_NAME): From dc4d5ce2d285b9eb78fc3a932d748d3f2a77f4a0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 00:58:08 +0000 Subject: [PATCH 05/60] move pip to python --- .helper_bash_functions | 5 ----- bin/ci_tool/__main__.py | 11 +++++++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.helper_bash_functions b/.helper_bash_functions index 5eaccb5..31b26ac 100644 --- a/.helper_bash_functions +++ b/.helper_bash_functions @@ -227,11 +227,6 @@ install_ci_tool() { echo -e "${Red}Error: Failed to install ci_tool. Check network and branch '${THIS_SCRIPT_BRANCH}'.${Color_Off}" return 1 fi - echo -e "${Yellow}Installing Python dependencies...${Color_Off}" - if ! pip3 install --user -r "${CI_TOOL_CACHE_DIR}/ci_tool/requirements.txt"; then - echo -e "${Red}Error: Failed to install Python dependencies${Color_Off}" - return 1 - fi echo -e "${Green}ci_tool installed to ${CI_TOOL_CACHE_DIR}${Color_Off}" } diff --git a/bin/ci_tool/__main__.py b/bin/ci_tool/__main__.py index 3de47be..378b7b9 100644 --- a/bin/ci_tool/__main__.py +++ b/bin/ci_tool/__main__.py @@ -1,9 +1,16 @@ #!/usr/bin/env python3 """Entry point for: python3 -m ci_tool or python3 /path/to/ci_tool""" -import sys import os +import subprocess +import sys + +ci_tool_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(ci_tool_dir)) -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +requirements_file = os.path.join(ci_tool_dir, "requirements.txt") +subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--user", "--quiet", "-r", requirements_file] +) from ci_tool.cli import main # noqa: E402 From 3c36283488c6000bebe6d3eaeb41efc16a7b297e Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 01:04:11 +0000 Subject: [PATCH 06/60] auto re-fetch and re-pip --- .helper_bash_functions | 16 +++++++++++++--- bin/ci_tool/__main__.py | 15 +++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/.helper_bash_functions b/.helper_bash_functions index 31b26ac..fa93793 100644 --- a/.helper_bash_functions +++ b/.helper_bash_functions @@ -233,9 +233,19 @@ install_ci_tool() { update_ci_tool() { install_ci_tool; } ci_tool() { - if [ ! -d "${CI_TOOL_CACHE_DIR}/ci_tool" ]; then - echo -e "${Yellow}ci_tool not found locally. Installing...${Color_Off}" - install_ci_tool || return 1 + mkdir -p "${CI_TOOL_CACHE_DIR}/ci_tool" + local fetch_failed="false" + for file in ${CI_TOOL_FILES}; do + if ! curl -fsSL "${CI_TOOL_RAW_URL}/${file}" -o "${CI_TOOL_CACHE_DIR}/ci_tool/${file}" 2>/dev/null; then + fetch_failed="true" + fi + done + if [ "${fetch_failed}" = "true" ]; then + if [ ! -f "${CI_TOOL_CACHE_DIR}/ci_tool/__main__.py" ]; then + echo -e "${Red}Error: Failed to fetch ci_tool and no cached version available${Color_Off}" + return 1 + fi + echo -e "${Yellow}Warning: Failed to fetch latest ci_tool, using cached version${Color_Off}" fi python3 "${CI_TOOL_CACHE_DIR}/ci_tool" "$@" } diff --git a/bin/ci_tool/__main__.py b/bin/ci_tool/__main__.py index 378b7b9..1123b1a 100644 --- a/bin/ci_tool/__main__.py +++ b/bin/ci_tool/__main__.py @@ -7,12 +7,15 @@ ci_tool_dir = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(ci_tool_dir)) -requirements_file = os.path.join(ci_tool_dir, "requirements.txt") -subprocess.check_call( - [sys.executable, "-m", "pip", "install", "--user", "--quiet", "-r", requirements_file] -) - -from ci_tool.cli import main # noqa: E402 +try: + from ci_tool.cli import main +except ImportError: + requirements_file = os.path.join(ci_tool_dir, "requirements.txt") + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--user", "--quiet", + "-r", requirements_file] + ) + from ci_tool.cli import main if __name__ == "__main__": main() From 607f3dc2f992878b7ec0412bb7c72ae5505bed02 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 01:09:56 +0000 Subject: [PATCH 07/60] ask for repo/branch interactively when no args provided Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_fix.py | 7 ------- bin/ci_tool/ci_reproduce.py | 35 ++++++++++++++++++++++++++--------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 8c3e984..f8caf64 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -181,13 +181,6 @@ def fix_ci(args): needs_reproduce = True if needs_reproduce: - if not parsed["reproduce_args"]: - console.print("[red]No container exists and no reproduce args provided.[/red]") - console.print( - "Pass repo args, e.g.: ci_tool fix -r https://github.com/... " - "-b main --only-needed-deps" - ) - sys.exit(1) reproduce_ci(parsed["reproduce_args"], skip_preflight=True) # Step 2: Install Claude in container diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index 929b09b..3a97522 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -8,7 +8,6 @@ from InquirerPy import inquirer from rich.console import Console -from rich.panel import Panel from ci_tool.containers import ( DEFAULT_CONTAINER_NAME, @@ -31,6 +30,31 @@ def extract_repo_url_from_args(args): return None +def prompt_for_reproduce_args(): + """Interactively ask user for the required reproduce arguments.""" + repo_url = inquirer.text( + message="Repository URL:", + validate=lambda url: url.startswith("https://github.com/"), + invalid_message="Must be a GitHub URL (https://github.com/...)", + ).execute() + + branch = inquirer.text( + message="Branch name:", + validate=lambda b: len(b.strip()) > 0, + invalid_message="Branch name cannot be empty", + ).execute() + + build_everything = inquirer.confirm( + message="Build everything (slower, disable --only-needed-deps)?", + default=False, + ).execute() + + args = ["-r", repo_url, "-b", branch] + if not build_everything: + args.append("--only-needed-deps") + return args + + def reproduce_ci(args, skip_preflight=False): """Create a CI reproduction container.""" if not skip_preflight: @@ -65,14 +89,7 @@ def reproduce_ci(args, skip_preflight=False): return if not args: - console.print(Panel( - "[bold]Reproduce CI requires arguments.[/bold]\n\n" - "Example:\n" - " ci_tool reproduce -r https://github.com/extend-robotics/er_interface " - "-b main --only-needed-deps", - title="Usage", - )) - sys.exit(1) + args = prompt_for_reproduce_args() token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") or "" if not token: From 75b30f6564ebb27b20671178cf8a7e171c2932c4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 01:20:19 +0000 Subject: [PATCH 08/60] add session management, CI URL auto-extraction, and improved compare prompt - Ask for optional CI run URL first, extract repo/branch/run ID via GitHub API - Named sessions: containers are named er_ci_ for tracking - Resume existing sessions or start new ones from interactive menu - Improved CI compare prompt: verify commits match, structured comparison steps Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_fix.py | 207 ++++++++++++++++++++++++++---------- bin/ci_tool/ci_reproduce.py | 8 ++ bin/ci_tool/containers.py | 30 ++++++ 3 files changed, 190 insertions(+), 55 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index f8caf64..5d9285c 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -2,7 +2,10 @@ """Fix CI test failures using Claude Code inside a container.""" from __future__ import annotations +import json +import os import sys +from urllib.request import Request, urlopen from InquirerPy import inquirer from rich.console import Console @@ -15,10 +18,17 @@ container_is_running, docker_exec, docker_exec_interactive, + list_ci_containers, remove_container, + rename_container, + sanitize_container_name, start_container, ) -from ci_tool.ci_reproduce import reproduce_ci, extract_repo_url_from_args +from ci_tool.ci_reproduce import ( + reproduce_ci, + extract_branch_from_args, + extract_repo_url_from_args, +) from ci_tool.preflight import run_all_preflight_checks, PreflightError console = Console() @@ -53,16 +63,19 @@ CI_RUN_COMPARE_PROMPT_TEMPLATE = ( ROS_SOURCE_PREAMBLE - + "A GitHub Actions CI run is available at: {ci_run_url}\n" - "Use `gh run view {run_id} --log-failed` or `gh run view {run_id} --log` " - "to fetch the CI logs.\n\n" - "There is a discrepancy between local and CI test results. Your job:\n" - "1. Run the tests locally and capture the results\n" - "2. Fetch the CI run logs using the gh CLI\n" - "3. Compare the two - identify tests that pass locally but fail in CI, or vice versa\n" - "4. Investigate the root cause of any discrepancy (environment differences, " - "timing, missing deps, etc.)\n" - "5. Fix the issue and verify locally\n\n" + + "Investigate CI failure: {ci_run_url}\n\n" + "1. Verify local and CI are on the same commit:\n" + " - Local: check HEAD in the repo under /ros_ws/src/\n" + " - CI: `gh api repos/{owner_repo}/actions/runs/{run_id} --jq '.head_sha'`\n" + " - If they differ, determine whether the missing/extra commits explain the failure\n\n" + "2. Fetch CI logs: `gh run view {run_id} --log-failed` " + "(use `--log` for full output if needed)\n\n" + "3. Run the same tests locally and compare:\n" + " - Both fail identically: fix the underlying bug\n" + " - CI fails but local passes: investigate environment differences " + "(timing, deps, config)\n" + " - Local fails but CI passes: check for local setup issues\n\n" + "4. Fix the root cause and re-run tests to verify\n\n" + SUMMARY_FORMAT ) @@ -89,6 +102,34 @@ def extract_run_id_from_url(ci_run_url): return run_id +def extract_info_from_ci_url(ci_run_url): + """Extract repo URL, branch, and run ID from a GitHub Actions run URL via the API.""" + run_id = extract_run_id_from_url(ci_run_url) + + owner_repo = ci_run_url.split("github.com/")[1].split("/actions/")[0] + repo_url = f"https://github.com/{owner_repo}" + + token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") + if not token: + raise ValueError("No GitHub token found (GH_TOKEN or ER_SETUP_TOKEN)") + + api_url = f"https://api.github.com/repos/{owner_repo}/actions/runs/{run_id}" + request = Request(api_url, headers={ + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + }) + with urlopen(request) as response: + data = json.loads(response.read()) + + return { + "repo_url": repo_url, + "owner_repo": owner_repo, + "branch": data["head_branch"], + "run_id": run_id, + "ci_run_url": ci_run_url, + } + + def select_fix_mode(): """Let the user choose how Claude should fix CI failures.""" mode = inquirer.select( @@ -106,8 +147,8 @@ def select_fix_mode(): validate=lambda url: "/runs/" in url, invalid_message="URL must contain /runs/ (e.g. https://github.com/org/repo/actions/runs/12345)", ).execute() - run_id = extract_run_id_from_url(ci_run_url) - return CI_RUN_COMPARE_PROMPT_TEMPLATE.format(ci_run_url=ci_run_url, run_id=run_id) + ci_run_info = extract_info_from_ci_url(ci_run_url) + return CI_RUN_COMPARE_PROMPT_TEMPLATE.format(**ci_run_info) return inquirer.text(message="Enter your custom prompt for Claude:").execute() @@ -131,14 +172,93 @@ def parse_fix_args(args): return parsed +def prompt_for_session_name(branch_hint=None): + """Ask the user for a session name. Returns full container name (er_ci_).""" + default = sanitize_container_name(branch_hint) if branch_hint else "" + name = inquirer.text( + message="Session name (used for container naming):", + default=default, + validate=lambda n: len(n.strip()) > 0, + invalid_message="Session name cannot be empty", + ).execute().strip() + + container_name = f"er_ci_{sanitize_container_name(name)}" + + if container_exists(container_name): + console.print( + f"[red]Container '{container_name}' already exists. " + f"Choose a different name or clean up first.[/red]" + ) + sys.exit(1) + + return container_name + + +def select_or_create_session(parsed): + """Let user resume an existing session or start a new one. + + Returns (container_name, ci_run_info, needs_reproduce). + Mutates parsed["reproduce_args"] if CI URL is provided. + """ + existing = list_ci_containers() + + if existing: + choices = [{"name": "Start new session", "value": "_new"}] + for container in existing: + choices.append({ + "name": f"Resume '{container['name']}' ({container['status']})", + "value": container["name"], + }) + + selection = inquirer.select( + message="Select a session:", + choices=choices, + ).execute() + + if selection != "_new": + if not container_is_running(selection): + start_container(selection) + return selection, None, False + + # New session: ask for optional CI URL + ci_run_info = None + ci_run_url = inquirer.text( + message="GitHub Actions run URL (leave blank to skip):", + default="", + ).execute().strip() + + if ci_run_url: + ci_run_info = extract_info_from_ci_url(ci_run_url) + console.print(f" [green]Repo:[/green] {ci_run_info['repo_url']}") + console.print(f" [green]Branch:[/green] {ci_run_info['branch']}") + console.print(f" [green]Run ID:[/green] {ci_run_info['run_id']}") + parsed["reproduce_args"] = [ + "-r", ci_run_info["repo_url"], + "-b", ci_run_info["branch"], + "--only-needed-deps", + ] + + branch_hint = ci_run_info["branch"] if ci_run_info else None + container_name = prompt_for_session_name(branch_hint) + return container_name, ci_run_info, True + + def fix_ci(args): - """Main fix workflow: preflight -> ensure container -> install Claude -> run -> drop to shell.""" + """Main fix workflow: session select -> preflight -> reproduce -> Claude -> shell.""" parsed = parse_fix_args(args) - container_name = parsed["container_name"] console.print(Panel("[bold cyan]CI Fix with Claude[/bold cyan]", expand=False)) - # Step 0: Preflight checks + # Step 0: Session selection + ci_run_info = None + if parsed["reproduce_args"]: + branch_hint = extract_branch_from_args(parsed["reproduce_args"]) + container_name = prompt_for_session_name(branch_hint) + needs_reproduce = True + else: + container_name, ci_run_info, needs_reproduce = select_or_create_session(parsed) + + # Step 1: Preflight checks repo_url = extract_repo_url_from_args(parsed["reproduce_args"]) try: run_all_preflight_checks(repo_url=repo_url) @@ -146,57 +266,34 @@ def fix_ci(args): console.print(f"\n[bold red]Preflight failed:[/bold red] {error}") sys.exit(1) - # Step 1: Ensure container exists - needs_reproduce = False - - if container_exists(container_name): - if container_is_running(container_name): - action = inquirer.select( - message=f"Container '{container_name}' is running. What to do?", - choices=[ - {"name": "Use existing container (skip CI reproduction)", "value": "reuse"}, - {"name": "Remove and recreate from scratch", "value": "recreate"}, - {"name": "Cancel", "value": "cancel"}, - ], - ).execute() - else: - action = inquirer.select( - message=f"Container '{container_name}' exists but is stopped.", - choices=[ - {"name": "Start and reuse it", "value": "reuse"}, - {"name": "Remove and recreate from scratch", "value": "recreate"}, - {"name": "Cancel", "value": "cancel"}, - ], - ).execute() - - if action == "cancel": - return - if action == "recreate": - remove_container(container_name) - needs_reproduce = True - elif action == "reuse": - if not container_is_running(container_name): - start_container(container_name) - else: - needs_reproduce = True - + # Step 2: Reproduce CI in container if needs_reproduce: + if container_exists(DEFAULT_CONTAINER_NAME): + remove_container(DEFAULT_CONTAINER_NAME) reproduce_ci(parsed["reproduce_args"], skip_preflight=True) + if container_name != DEFAULT_CONTAINER_NAME: + rename_container(DEFAULT_CONTAINER_NAME, container_name) - # Step 2: Install Claude in container + # Step 3: Install Claude in container setup_claude_in_container(container_name) - # Step 3: Select fix mode and launch Claude - prompt = select_fix_mode() + # Step 4: Select fix mode and launch Claude + if ci_run_info: + prompt = CI_RUN_COMPARE_PROMPT_TEMPLATE.format(**ci_run_info) + else: + prompt = select_fix_mode() console.print("\n[bold cyan]Launching Claude Code...[/bold cyan]") console.print("[dim]Claude will attempt to fix CI failures autonomously[/dim]\n") escaped_prompt = prompt.replace("'", "'\\''") - claude_command = f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions -p '{escaped_prompt}'" + claude_command = ( + f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " + f"-p '{escaped_prompt}'" + ) docker_exec(container_name, claude_command, check=False) - # Step 4: Drop into interactive shell + # Step 5: Drop into interactive shell console.print("\n[bold green]Claude has finished.[/bold green]") console.print("[cyan]Dropping you into the container shell.[/cyan]") console.print("[dim]You can run 'git diff', 'git add', 'git commit' etc.[/dim]") diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index 3a97522..908e222 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -30,6 +30,14 @@ def extract_repo_url_from_args(args): return None +def extract_branch_from_args(args): + """Extract --branch/-b value from args list, or return None.""" + for i, arg in enumerate(args): + if arg in ("--branch", "-b") and i + 1 < len(args): + return args[i + 1] + return None + + def prompt_for_reproduce_args(): """Interactively ask user for the required reproduce arguments.""" repo_url = inquirer.text( diff --git a/bin/ci_tool/containers.py b/bin/ci_tool/containers.py index 6bce9ec..c4ef496 100644 --- a/bin/ci_tool/containers.py +++ b/bin/ci_tool/containers.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re import shutil import subprocess import sys @@ -57,6 +58,35 @@ def remove_container(container_name=DEFAULT_CONTAINER_NAME): console.print(f"[green]Container '{container_name}' removed[/green]") +def list_ci_containers(): + """List all CI containers (er_ci_* prefix) with their status.""" + result = subprocess.run( + ["docker", "ps", "-a", "--filter", "name=er_ci_", + "--format", "{{.Names}}\t{{.Status}}"], + capture_output=True, text=True, check=False, + ) + if not result.stdout.strip(): + return [] + containers = [] + for line in result.stdout.strip().split("\n"): + parts = line.split("\t") + containers.append({ + "name": parts[0], + "status": parts[1] if len(parts) > 1 else "unknown", + }) + return containers + + +def rename_container(old_name, new_name): + """Rename a Docker container.""" + run_command(["docker", "rename", old_name, new_name]) + + +def sanitize_container_name(name): + """Replace characters invalid for Docker container names with underscores.""" + return re.sub(r'[^a-zA-Z0-9_.-]', '_', name) + + def docker_exec(container_name, command, interactive=False, check=True, quiet=False): """Run a command inside a container.""" docker_command = ["docker", "exec"] From 9fd56fe075d0e2324c58b8bd6eabbaa2f4fdcca8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 01:24:13 +0000 Subject: [PATCH 09/60] fix removesuffix for Python 3.8 compatibility Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/preflight.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/ci_tool/preflight.py b/bin/ci_tool/preflight.py index ffb9689..ccf86fa 100644 --- a/bin/ci_tool/preflight.py +++ b/bin/ci_tool/preflight.py @@ -71,7 +71,9 @@ def validate_gh_token(repo_url=None): raise PreflightError(f"Cannot reach GitHub API: {error.reason}") from error if repo_url: - repo_url_clean = repo_url.rstrip("/").removesuffix(".git") + repo_url_clean = repo_url.rstrip("/") + if repo_url_clean.endswith(".git"): + repo_url_clean = repo_url_clean[:-4] repo_path = repo_url_clean.split("github.com/")[-1] try: _github_api_get(f"/repos/{repo_path}", gh_token) From 01c919fa2d3bac876ba88cc3085556fae015ba3e Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 01:56:15 +0000 Subject: [PATCH 10/60] add interactive Claude session, setup script, and CI context for ci_tool - Add claude_session.py for launching interactive Claude sessions in CI containers - Add ci_context/CLAUDE.md with ROS workspace instructions for in-container Claude - Add bin/setup.sh one-command setup script that installs helpers, sets GH_TOKEN, and verifies Claude auth - Add "Claude session (interactive)" option to ci_tool CLI menu - Add is_claude_installed_in_container() to skip redundant setup on re-entry - Add copy_ci_context() to inject CI-specific CLAUDE.md into containers - Update .helper_bash_functions to fetch CI_TOOL_DATA_FILES (subdirectory assets) - Update merge_helper_vars.py to handle export prefixes and support --set mode - Update ci_reproduce.py to not raise on non-zero exit (test failures are expected) - Add comprehensive README documentation for ci_tool usage and setup --- .helper_bash_functions | 17 +++- README.md | 71 +++++++++++++++++ bin/ci_tool/ci_context/CLAUDE.md | 104 ++++++++++++++++++++++++ bin/ci_tool/ci_reproduce.py | 9 ++- bin/ci_tool/claude_session.py | 78 ++++++++++++++++++ bin/ci_tool/claude_setup.py | 23 ++++++ bin/ci_tool/cli.py | 7 ++ bin/merge_helper_vars.py | 78 ++++++++++++++---- bin/setup.sh | 132 +++++++++++++++++++++++++++++++ 9 files changed, 502 insertions(+), 17 deletions(-) create mode 100644 bin/ci_tool/ci_context/CLAUDE.md create mode 100644 bin/ci_tool/claude_session.py create mode 100644 bin/setup.sh diff --git a/.helper_bash_functions b/.helper_bash_functions index fa93793..adb8564 100644 --- a/.helper_bash_functions +++ b/.helper_bash_functions @@ -210,7 +210,8 @@ remove_ci_container() { # CI tool (Python CLI) - interactive CI reproduction + Claude-powered fix CI_TOOL_CACHE_DIR="${HOME}/.ci_tool" CI_TOOL_RAW_URL="https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/refs/heads/${THIS_SCRIPT_BRANCH}/bin/ci_tool" -CI_TOOL_FILES="__init__.py __main__.py cli.py preflight.py containers.py ci_reproduce.py claude_setup.py ci_fix.py requirements.txt" +CI_TOOL_FILES="__init__.py __main__.py cli.py preflight.py containers.py ci_reproduce.py claude_setup.py ci_fix.py claude_session.py requirements.txt" +CI_TOOL_DATA_FILES="ci_context/CLAUDE.md" install_ci_tool() { echo -e "${Yellow}Installing ci_tool from er_build_tools (branch: ${THIS_SCRIPT_BRANCH})...${Color_Off}" @@ -223,6 +224,14 @@ install_ci_tool() { failed="true" fi done + for file in ${CI_TOOL_DATA_FILES}; do + echo " Fetching ${file}..." + mkdir -p "${CI_TOOL_CACHE_DIR}/ci_tool/$(dirname "${file}")" + if ! curl -fSL "${CI_TOOL_RAW_URL}/${file}" -o "${CI_TOOL_CACHE_DIR}/ci_tool/${file}"; then + echo -e "${Red} Failed to fetch ${file}${Color_Off}" + failed="true" + fi + done if [ "${failed}" = "true" ]; then echo -e "${Red}Error: Failed to install ci_tool. Check network and branch '${THIS_SCRIPT_BRANCH}'.${Color_Off}" return 1 @@ -240,6 +249,12 @@ ci_tool() { fetch_failed="true" fi done + for file in ${CI_TOOL_DATA_FILES}; do + mkdir -p "${CI_TOOL_CACHE_DIR}/ci_tool/$(dirname "${file}")" 2>/dev/null + if ! curl -fsSL "${CI_TOOL_RAW_URL}/${file}" -o "${CI_TOOL_CACHE_DIR}/ci_tool/${file}" 2>/dev/null; then + fetch_failed="true" + fi + done if [ "${fetch_failed}" = "true" ]; then if [ ! -f "${CI_TOOL_CACHE_DIR}/ci_tool/__main__.py" ]; then echo -e "${Red}Error: Failed to fetch ci_tool and no cached version available${Color_Off}" diff --git a/README.md b/README.md index 5f56e6a..aa87917 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,77 @@ Public build tools and utilities for Extend Robotics repositories. +## Quick Setup + +Install helper bash functions, set your GitHub token, and authenticate Claude — all in one command: + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/refs/heads/main/bin/setup.sh) +``` + +This installs `~/.helper_bash_functions` which provides build helpers, git aliases, and `ci_tool`. + +## ci_tool — Fix CI Failures with Claude + +`ci_tool` is an interactive CLI that reproduces CI failures locally in Docker and uses Claude Code to autonomously fix them. + +### Prerequisites + +- **Docker** installed and running +- **Claude Code** installed and authenticated — `npm install -g @anthropic-ai/claude-code && claude` +- **GitHub token** with `repo` scope — [create one](https://github.com/settings/tokens) + +### Usage + +```bash +source ~/.helper_bash_functions +ci_tool +``` + +| Command | Description | +|---------|-------------| +| `ci_tool` | Interactive menu | +| `ci_fix` | Fix CI failures with Claude (shortcut for `ci_tool fix`) | +| `ci_tool reproduce` | Reproduce CI environment in Docker | +| `ci_tool shell` | Shell into an existing CI container | +| `ci_tool retest` | Re-run tests in a CI container | +| `ci_tool clean` | Remove CI containers | + +### Fix Workflow + +1. Run `ci_fix` +2. Create a new session or reuse an existing container +3. Optionally paste a GitHub Actions URL to target a specific failure +4. ci_tool reproduces the CI environment in Docker +5. Claude analyses the test output and applies fixes +6. You're dropped into a shell to review changes, commit, and push + +### Manual Setup + +If you prefer not to use the setup script: + +1. Download helper functions: + ```bash + curl -fsSL https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/refs/heads/main/.helper_bash_functions \ + -o ~/.helper_bash_functions + ``` +2. Add your GitHub token to the top of `~/.helper_bash_functions`: + ```bash + export GH_TOKEN="ghp_your_token_here" + ``` +3. Source in your shell: + ```bash + echo 'source ~/.helper_bash_functions' >> ~/.bashrc + source ~/.helper_bash_functions + ``` +4. Install and authenticate Claude Code: + ```bash + npm install -g @anthropic-ai/claude-code + claude + ``` + +--- + ## reproduce_ci.sh — Reproduce CI Locally When CI fails, debugging requires pushing commits and waiting for results. This script reproduces the exact CI environment locally in a persistent Docker container, so you can debug interactively. diff --git a/bin/ci_tool/ci_context/CLAUDE.md b/bin/ci_tool/ci_context/CLAUDE.md new file mode 100644 index 0000000..c6abab8 --- /dev/null +++ b/bin/ci_tool/ci_context/CLAUDE.md @@ -0,0 +1,104 @@ +# CI Context — Extend Robotics ROS1 Workspace + +You are inside a CI reproduction container. The ROS workspace is at `/ros_ws/`. +Source code is under `/ros_ws/src/`. Built packages install to `/ros_ws/install/`. + +## Environment Setup + +```bash +source /opt/ros/noetic/setup.bash && source /ros_ws/install/setup.bash +source ~/.helper_bash_functions +``` + +## Build Commands + +```bash +source ~/.helper_bash_functions + +# Full workspace build +colcon_build + +# Single package (with deps) +colcon_build + +# Single package (no deps — use after editing only Python in that package) +colcon_build_no_deps + +# Multiple packages +colcon_build " " +``` + +Python imports resolve to installed `.pyc` in `/ros_ws/install/`, not source. Always build after editing Python before running tests. + +## Testing + +```bash +# Run all tests in a package +colcon_test_this_package + +# Run specific rostest +rostest .test +``` + +`colcon_test_this_package` does NOT build dependencies — only builds and tests the named package. If you changed code in other packages, build those first. + +When reporting test results, check per-package XML files in `build//test_results/` — `colcon test-result --verbose` without `--test-result-base` aggregates stale results from the entire `build/` directory. + +## Linting + +```bash +source ~/.helper_bash_functions && cd /ros_ws/src/er_interface/ && er_python_linters_here +``` + +Linters must pass including warnings. Don't use `# pylint: disable` unless absolutely necessary. Always lint before committing. + +After changing `er_robot_description`, run `rosrun er_interface xacro_lint.py` to validate all assembly XACRO permutations. `er_python_linters_here` does NOT run this. + +## Style Guide + +- Code must follow KISS, YAGNI, and SOLID principles. +- Self-documenting code with verbose variable names. Comments only for maths or external doc links. +- Fail fast — no fallback behaviour or silent defaults. If something goes wrong, raise a clear exception. +- Never add try/except, None-return, or fallback behaviour to existing functions that currently raise on error. +- Scope changes to what was requested — no cosmetic cleanups, no "while I'm here" changes. +- Do not rename functions, variables, or files unless renaming is the task. +- Keep diffs minimal. Every changed line must serve the requested purpose. +- Do not mention Claude in commit messages or PRs. + +## Common CI Failure Patterns + +1. **Missing package.xml dependencies**: Code works locally because a dependency is installed system-wide, but CI only installs declared dependencies. Check ``, ``, and `` tags match all imports. + +2. **Import errors**: If a node crashes with `ModuleNotFoundError`, the package is missing from `package.xml`. Trace the import chain to find which dependency is needed. + +3. **Race conditions in launch files**: Use `conditional_delayed_rostool` to wait for topics/params/services before launching dependent nodes. Don't restructure node startup code or add timeouts. + +4. **Stale test results**: `colcon test-result --verbose` aggregates stale results from the entire `build/` directory. Always check per-package XML files in `build//test_results/`. + +5. **XACRO validation failures**: After changing `er_robot_description`, run `rosrun er_interface xacro_lint.py`. The CI `er_xacro_checks.yml` workflow runs this automatically. + +6. **Test tolerance failures**: Check per-joint tolerance overrides in test config YAML files. Some joints (e.g. thumb) have higher variability under IK. + +## Architecture Overview + +ROS Noetic catkin workspace for multi-robot assemblies: + +- **er_robot_description**: URDF/XACRO files for all robots +- **er_robot_config**: Configuration generation (SRDF, controllers, kinematics from Jinja2 templates) +- **er_robot_launch**: Main launch entrypoint for complete robot system +- **er_robot_hand_interface**: Human hand pose projection to robot hands/grippers via IK +- **er_state_validity_checker**: In-process collision checking, joint limits, manipulability via MoveIt +- **er_auto_moveit_config**: MoveIt configuration generation +- **er_utilities_common**: Shared utilities (conditional_delayed_rostool, joint state aggregation) +- **er_moveit_collisions_updater_python**: Automatic MoveIt collision pair exclusion via randomised sampling + +Configuration pipeline: Assembly configs → robot configs → Jinja2 templates → URDF/SRDF/controllers. Generated files output to `/tmp/`. + +## Design Principles + +- Explicit over implicit. Use named fields with clear values, not absence-of-key or empty-dict semantics. +- Mode-dispatching methods must branch on mode first. No computation before the branch unless genuinely shared. +- After any code change, run existing tests before declaring done. A test passing before and failing after is a regression. +- Before modifying shared helper functions, read the whole file and check all callers. +- When adding fail-fast errors, trace all call paths. +- Check assumptions empirically before asserting them. Don't dismiss failures without data. diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index 908e222..b6036a7 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -132,9 +132,14 @@ def reproduce_ci(args, skip_preflight=False): capture_output=True, text=True, check=True, ) - subprocess.run( + result = subprocess.run( ["bash", "-c", fetch_result.stdout + '\n"$@"', "--"] + full_args, - check=True, + check=False, ) + if result.returncode != 0: + console.print( + f"\n[yellow]CI reproduction exited with code {result.returncode} " + f"(expected — tests likely failed)[/yellow]" + ) console.print(f"\n[green]Container '{container_name}' is ready[/green]") diff --git a/bin/ci_tool/claude_session.py b/bin/ci_tool/claude_session.py new file mode 100644 index 0000000..baf0bc7 --- /dev/null +++ b/bin/ci_tool/claude_session.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Launch an interactive Claude Code session inside a CI container.""" +from __future__ import annotations + +import os +import sys + +from rich.console import Console + +from ci_tool.claude_setup import ( + copy_ci_context, + copy_claude_credentials, + is_claude_installed_in_container, + setup_claude_in_container, +) +from ci_tool.containers import ( + container_exists, + container_is_running, + list_ci_containers, + require_docker, + start_container, +) + +console = Console() + + +def select_container(args): + """Select a running CI container from args or interactive prompt.""" + if args: + return args[0] + + existing = list_ci_containers() + if not existing: + console.print("[red]No CI containers found. Run 'Reproduce CI' first.[/red]") + sys.exit(1) + + from InquirerPy import inquirer + choices = [] + for container in existing: + choices.append({ + "name": f"{container['name']} ({container['status']})", + "value": container["name"], + }) + + return inquirer.select( + message="Select a container:", + choices=choices, + ).execute() + + +def claude_session(args): + """Launch an interactive Claude session in a CI container.""" + require_docker() + container_name = select_container(args) + + if not container_exists(container_name): + console.print(f"[red]Container '{container_name}' does not exist[/red]") + sys.exit(1) + + if not container_is_running(container_name): + console.print(f"[yellow]Starting container '{container_name}'...[/yellow]") + start_container(container_name) + + if not is_claude_installed_in_container(container_name): + console.print("[cyan]Claude not installed — running full setup...[/cyan]") + setup_claude_in_container(container_name) + else: + copy_claude_credentials(container_name) + copy_ci_context(container_name) + + console.print(f"\n[bold cyan]Starting Claude session in '{container_name}'...[/bold cyan]") + console.print("[dim]Type /exit or Ctrl+C to leave Claude[/dim]\n") + + os.execvp("docker", [ + "docker", "exec", "-it", container_name, + "bash", "-c", + "source ~/.bashrc && cd /ros_ws && claude --dangerously-skip-permissions", + ]) diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index 7c0c92b..eab76cc 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -15,6 +15,7 @@ console = Console() CLAUDE_HOME = Path.home() / ".claude" +CI_CONTEXT_DIR = Path(__file__).parent / "ci_context" def install_node_in_container(container_name): @@ -77,6 +78,18 @@ def copy_claude_config(container_name): os.unlink(tmp_path) +def copy_ci_context(container_name): + """Copy CI-specific CLAUDE.md into the container, replacing the host's global CLAUDE.md.""" + ci_claude_md = CI_CONTEXT_DIR / "CLAUDE.md" + if not ci_claude_md.exists(): + console.print("[yellow]CI context CLAUDE.md not found, skipping[/yellow]") + return + + console.print("[cyan]Copying CI context CLAUDE.md...[/cyan]") + docker_exec(container_name, "mkdir -p /root/.claude") + docker_cp_to_container(str(ci_claude_md), container_name, "/root/.claude/CLAUDE.md") + + def copy_claude_memory(container_name): """Copy Claude project memory files into the container.""" projects_dir = CLAUDE_HOME / "projects" @@ -177,6 +190,15 @@ def install_and_auth_gh_cli(container_name, gh_token): ) +def is_claude_installed_in_container(container_name): + """Check if Claude Code is already installed in the container.""" + result = subprocess.run( + ["docker", "exec", container_name, "bash", "-c", "which claude"], + capture_output=True, text=True, check=False, + ) + return result.returncode == 0 + + def setup_claude_in_container(container_name): """Full setup: install Claude Code and copy all config into container.""" console.print("\n[bold cyan]Setting up Claude in container...[/bold cyan]") @@ -186,6 +208,7 @@ def setup_claude_in_container(container_name): install_fzf_in_container(container_name) copy_claude_credentials(container_name) copy_claude_config(container_name) + copy_ci_context(container_name) copy_claude_memory(container_name) copy_helper_bash_functions(container_name) configure_git_in_container(container_name) diff --git a/bin/ci_tool/cli.py b/bin/ci_tool/cli.py index a2d77bc..f55dc3e 100644 --- a/bin/ci_tool/cli.py +++ b/bin/ci_tool/cli.py @@ -13,6 +13,7 @@ MENU_CHOICES = [ {"name": "Reproduce CI (create container)", "value": "reproduce"}, {"name": "Fix CI with Claude", "value": "fix"}, + {"name": "Claude session (interactive)", "value": "claude"}, {"name": "Shell into container", "value": "shell"}, {"name": "Re-run tests in container", "value": "retest"}, {"name": "Clean up containers", "value": "clean"}, @@ -45,6 +46,7 @@ def dispatch_subcommand(command, args): handlers = { "reproduce": _handle_reproduce, "fix": _handle_fix, + "claude": _handle_claude, "shell": _handle_shell, "retest": _handle_retest, "clean": _handle_clean, @@ -67,6 +69,11 @@ def _handle_fix(args): fix_ci(args) +def _handle_claude(args): + from ci_tool.claude_session import claude_session + claude_session(args) + + def _handle_shell(args): from ci_tool.containers import shell_into_container shell_into_container(args) diff --git a/bin/merge_helper_vars.py b/bin/merge_helper_vars.py index 8f318cd..fa4f67d 100644 --- a/bin/merge_helper_vars.py +++ b/bin/merge_helper_vars.py @@ -7,22 +7,33 @@ - Same value in both: keep new (no-op) - Different value: ask the user which to keep +Can also set individual variables: + python3 merge_helper_vars.py --set VAR=VALUE + python3 merge_helper_vars.py --set --export VAR=VALUE + Writes the merged result to the new file path. -Usage: python3 merge_helper_vars.py +Usage: + python3 merge_helper_vars.py + python3 merge_helper_vars.py --set [--export] VAR=VALUE """ import re import sys SKIP_VARS = {"Red", "Green", "Yellow", "Color_Off"} -VAR_PATTERN = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)') +VAR_PATTERN = re.compile(r'^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)') REFERENCES_OTHER_VAR = re.compile(r'\$\{') FUNCTION_OR_SECTION = re.compile(r'^[a-zA-Z_]+\(\)|^# [A-Z]') def extract_top_level_vars(filepath): - """Extract VAR=value lines from the top of the file, before functions start.""" + """Extract VAR=value lines from the top of the file, before functions start. + + Returns (variables, exports) where variables is {name: value} and + exports is the set of variable names that had an 'export' prefix. + """ variables = {} + exports = set() with open(filepath, encoding="utf-8") as file_handle: for line in file_handle: stripped = line.rstrip('\n') @@ -31,13 +42,16 @@ def extract_top_level_vars(filepath): match = VAR_PATTERN.match(stripped) if not match: continue - var_name = match.group(1) + is_export = bool(match.group(1)) + var_name = match.group(2) if var_name in SKIP_VARS: continue - if REFERENCES_OTHER_VAR.search(match.group(2)): + if REFERENCES_OTHER_VAR.search(match.group(3)): continue - variables[var_name] = match.group(2) - return variables + variables[var_name] = match.group(3) + if is_export: + exports.add(var_name) + return variables, exports def ask_user(var_name, old_value, new_value): @@ -53,20 +67,24 @@ def ask_user(var_name, old_value, new_value): return old_value -def apply_var_to_file(filepath, var_name, value): +def apply_var_to_file(filepath, var_name, value, export=False): """Set a variable in the file, replacing if present or inserting after Color_Off.""" with open(filepath, encoding="utf-8") as file_handle: lines = file_handle.readlines() - new_line = f"{var_name}={value}\n" replaced = False for i, line in enumerate(lines): - if line.startswith(f"{var_name}="): - lines[i] = new_line + if line.startswith(f"export {var_name}=") or line.startswith(f"{var_name}="): + had_export = line.startswith("export ") + use_export = had_export or export + prefix = "export " if use_export else "" + lines[i] = f"{prefix}{var_name}={value}\n" replaced = True break if not replaced: + prefix = "export " if export else "" + new_line = f"{prefix}{var_name}={value}\n" for i, line in enumerate(lines): if line.startswith("Color_Off="): lines.insert(i + 1, new_line) @@ -76,14 +94,45 @@ def apply_var_to_file(filepath, var_name, value): file_handle.writelines(lines) +def set_variable(args): + """Handle --set mode: set a single variable in a file.""" + use_export = False + remaining = list(args) + + if "--export" in remaining: + use_export = True + remaining.remove("--export") + + if len(remaining) != 2: + print(f"Usage: {sys.argv[0]} --set [--export] VAR=VALUE ") + sys.exit(1) + + assignment, filepath = remaining + match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)', assignment) + if not match: + print(f"Error: Invalid assignment '{assignment}'. Expected VAR=VALUE.") + sys.exit(1) + + var_name = match.group(1) + value = match.group(2) + apply_var_to_file(filepath, var_name, value, export=use_export) + prefix = "export " if use_export else "" + print(f"\033[0;32mSet {prefix}{var_name} in {filepath}\033[0m") + + def main(): + if len(sys.argv) >= 2 and sys.argv[1] == "--set": + set_variable(sys.argv[2:]) + return + if len(sys.argv) != 3: print(f"Usage: {sys.argv[0]} ") + print(f" {sys.argv[0]} --set [--export] VAR=VALUE ") sys.exit(1) old_file, new_file = sys.argv[1], sys.argv[2] - old_vars = extract_top_level_vars(old_file) - new_vars = extract_top_level_vars(new_file) + old_vars, old_exports = extract_top_level_vars(old_file) + new_vars, _ = extract_top_level_vars(new_file) vars_to_apply = {} @@ -98,7 +147,8 @@ def main(): # else: same value, nothing to do for var_name, value in vars_to_apply.items(): - apply_var_to_file(new_file, var_name, value) + is_export = var_name in old_exports + apply_var_to_file(new_file, var_name, value, export=is_export) if __name__ == "__main__": diff --git a/bin/setup.sh b/bin/setup.sh new file mode 100644 index 0000000..3499495 --- /dev/null +++ b/bin/setup.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# Setup script for ci_tool and helper bash functions. +# +# Run with: +# bash <(curl -fsSL https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/refs/heads/main/bin/setup.sh) + +set -euo pipefail + +Red='\033[0;31m' +Green='\033[0;32m' +Yellow='\033[0;33m' +Cyan='\033[0;36m' +Bold='\033[1m' +Color_Off='\033[0m' + +BRANCH="main" +BASE_URL="https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/refs/heads/${BRANCH}" +HELPER_URL="${BASE_URL}/.helper_bash_functions" +MERGE_VARS_URL="${BASE_URL}/bin/merge_helper_vars.py" +HELPER_PATH="${HOME}/.helper_bash_functions" + +echo -e "${Bold}${Cyan}" +echo "╔══════════════════════════════════════════════╗" +echo "║ ci_tool Setup — Extend Robotics ║" +echo "╚══════════════════════════════════════════════╝" +echo -e "${Color_Off}" + +# --- Step 1: Install/update .helper_bash_functions --- + +echo -e "${Bold}[1/4] Installing helper bash functions...${Color_Off}" +if [ -f "${HELPER_PATH}" ]; then + echo -e "${Yellow} Existing ~/.helper_bash_functions found — updating while preserving your variables...${Color_Off}" + tmp_new=$(mktemp) + curl -fsSL "${HELPER_URL}" -o "${tmp_new}" + merge_script=$(curl -fsSL "${MERGE_VARS_URL}") + python3 <(echo "${merge_script}") "${HELPER_PATH}" "${tmp_new}" + cp "${tmp_new}" "${HELPER_PATH}" + rm -f "${tmp_new}" + echo -e "${Green} Updated ~/.helper_bash_functions (custom variables preserved).${Color_Off}" +else + curl -fsSL "${HELPER_URL}" -o "${HELPER_PATH}" + echo -e "${Green} Installed ~/.helper_bash_functions${Color_Off}" +fi + +# --- Step 2: GitHub token --- + +echo "" +echo -e "${Bold}[2/4] GitHub token${Color_Off}" +echo -e " ci_tool needs a GitHub token with ${Bold}repo${Color_Off} scope to access private repos." +echo -e " Create one at: ${Cyan}https://github.com/settings/tokens${Color_Off}" +echo "" + +current_token="" +if [ -n "${GH_TOKEN:-}" ]; then + current_token="${GH_TOKEN}" + echo -e " ${Green}GH_TOKEN is already set in your environment.${Color_Off}" + echo -n " Keep current token? [Y/n] " + read -r keep_token + if [[ "${keep_token}" =~ ^[nN] ]]; then + current_token="" + fi +fi + +if [ -z "${current_token}" ]; then + echo -n " Enter your GitHub token (ghp_...): " + read -r current_token + if [ -z "${current_token}" ]; then + echo -e " ${Yellow}Skipped. Set it later by editing ~/.helper_bash_functions${Color_Off}" + fi +fi + +if [ -n "${current_token}" ]; then + merge_script=$(curl -fsSL "${MERGE_VARS_URL}") + python3 <(echo "${merge_script}") --set --export "GH_TOKEN=\"${current_token}\"" "${HELPER_PATH}" +fi + +# --- Step 3: Shell integration --- + +echo "" +echo -e "${Bold}[3/4] Shell integration${Color_Off}" +BASHRC="${HOME}/.bashrc" +if [ -f "${BASHRC}" ] && grep -q 'source ~/.helper_bash_functions' "${BASHRC}"; then + echo -e " ${Green}Already sourced in ~/.bashrc${Color_Off}" +else + echo 'source ~/.helper_bash_functions' >> "${BASHRC}" + echo -e " ${Green}Added 'source ~/.helper_bash_functions' to ~/.bashrc${Color_Off}" +fi + +# --- Step 4: Claude Code authentication --- + +echo "" +echo -e "${Bold}[4/4] Claude Code authentication${Color_Off}" +echo -e " ci_tool uses Claude Code to autonomously fix CI failures." +echo -e " Claude must be installed and authenticated on your host machine." +echo "" + +if command -v claude &> /dev/null; then + echo -e " ${Green}Claude Code is installed.${Color_Off}" + if claude -p "say ok" --max-turns 1 &> /dev/null; then + echo -e " ${Green}Claude authentication is working.${Color_Off}" + else + echo -e " ${Yellow}Claude is installed but not authenticated.${Color_Off}" + echo -e " Run ${Bold}claude${Color_Off} in another terminal to authenticate, then press Enter." + echo -n " Press Enter when done (or 's' to skip): " + read -r auth_response + if [[ ! "${auth_response}" =~ ^[sS] ]]; then + if claude -p "say ok" --max-turns 1 &> /dev/null; then + echo -e " ${Green}Claude authentication verified!${Color_Off}" + else + echo -e " ${Yellow}Still not working — you can fix this later.${Color_Off}" + fi + fi + fi +else + echo -e " ${Yellow}Claude Code is not installed.${Color_Off}" + echo -e " Install with: ${Bold}npm install -g @anthropic-ai/claude-code${Color_Off}" + echo -e " Then run ${Bold}claude${Color_Off} to authenticate." +fi + +# --- Done --- + +echo "" +echo -e "${Bold}${Green}Setup complete!${Color_Off}" +echo "" +echo -e " Reload your shell or run:" +echo -e " ${Bold}source ~/.helper_bash_functions${Color_Off}" +echo "" +echo -e " Then start ci_tool:" +echo -e " ${Bold}ci_tool${Color_Off} Interactive menu" +echo -e " ${Bold}ci_fix${Color_Off} Fix CI failures with Claude" +echo -e " ${Bold}ci_tool reproduce${Color_Off} Reproduce CI locally" +echo "" From 3cdb530d0c54a14b761c81fbd23d63f7c02179c9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 02:02:17 +0000 Subject: [PATCH 11/60] interactive help --- bin/ci_tool/ci_fix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 5d9285c..628293c 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -291,7 +291,7 @@ def fix_ci(args): f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " f"-p '{escaped_prompt}'" ) - docker_exec(container_name, claude_command, check=False) + docker_exec(container_name, claude_command, interactive=True, check=False) # Step 5: Drop into interactive shell console.print("\n[bold green]Claude has finished.[/bold green]") From 79ce05bc9c5052df67189106a8705c6e45cfd9f1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 13:05:13 +0000 Subject: [PATCH 12/60] add session resume, progress display, and incremental container setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add display_progress.py to render Claude stream-json output with live spinner, tool activity, and elapsed time - support resuming previous Claude sessions by reading state file from container - make container setup idempotent — skip full install if Claude already present, refresh config only - inject resume_claude bash function into container for interactive session resumption - install Python deps (rich) in container for display script - simplify parse_fix_args by removing container-name parsing - write session state (session_id, phase, attempt_count) on exit for later resume --- .helper_bash_functions | 2 +- bin/ci_tool/ci_fix.py | 146 +++++++++++++++++++++++--------- bin/ci_tool/claude_setup.py | 71 ++++++++++++++++ bin/ci_tool/display_progress.py | 143 +++++++++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 42 deletions(-) create mode 100644 bin/ci_tool/display_progress.py diff --git a/.helper_bash_functions b/.helper_bash_functions index adb8564..9fc1fbe 100644 --- a/.helper_bash_functions +++ b/.helper_bash_functions @@ -210,7 +210,7 @@ remove_ci_container() { # CI tool (Python CLI) - interactive CI reproduction + Claude-powered fix CI_TOOL_CACHE_DIR="${HOME}/.ci_tool" CI_TOOL_RAW_URL="https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/refs/heads/${THIS_SCRIPT_BRANCH}/bin/ci_tool" -CI_TOOL_FILES="__init__.py __main__.py cli.py preflight.py containers.py ci_reproduce.py claude_setup.py ci_fix.py claude_session.py requirements.txt" +CI_TOOL_FILES="__init__.py __main__.py cli.py preflight.py containers.py ci_reproduce.py claude_setup.py ci_fix.py claude_session.py display_progress.py requirements.txt" CI_TOOL_DATA_FILES="ci_context/CLAUDE.md" install_ci_tool() { diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 628293c..7c3fdda 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -4,6 +4,7 @@ import json import os +import subprocess import sys from urllib.request import Request, urlopen @@ -11,7 +12,14 @@ from rich.console import Console from rich.panel import Panel -from ci_tool.claude_setup import setup_claude_in_container +from ci_tool.claude_setup import ( + setup_claude_in_container, + is_claude_installed_in_container, + copy_claude_credentials, + copy_ci_context, + copy_display_script, + inject_resume_function, +) from ci_tool.containers import ( DEFAULT_CONTAINER_NAME, container_exists, @@ -86,6 +94,20 @@ ] +def read_container_state(container_name): + """Read the ci_fix state file from a container. Returns dict or None.""" + result = subprocess.run( + ["docker", "exec", container_name, "cat", "/ros_ws/.ci_fix_state.json"], + capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return None + + def extract_run_id_from_url(ci_run_url): """Extract the numeric run ID from a GitHub Actions URL. @@ -155,21 +177,7 @@ def select_fix_mode(): def parse_fix_args(args): """Parse fix-specific arguments, separating them from reproduce args.""" - parsed = { - "container_name": DEFAULT_CONTAINER_NAME, - "reproduce_args": [], - } - - i = 0 - while i < len(args): - if args[i] in ("--container-name", "-n") and i + 1 < len(args): - parsed["container_name"] = args[i + 1] - i += 2 - else: - parsed["reproduce_args"].append(args[i]) - i += 1 - - return parsed + return {"reproduce_args": list(args)} def prompt_for_session_name(branch_hint=None): @@ -197,7 +205,7 @@ def prompt_for_session_name(branch_hint=None): def select_or_create_session(parsed): """Let user resume an existing session or start a new one. - Returns (container_name, ci_run_info, needs_reproduce). + Returns (container_name, ci_run_info, needs_reproduce, resume_session_id). Mutates parsed["reproduce_args"] if CI URL is provided. """ existing = list_ci_containers() @@ -218,7 +226,29 @@ def select_or_create_session(parsed): if selection != "_new": if not container_is_running(selection): start_container(selection) - return selection, None, False + + state = read_container_state(selection) + if state and state.get("session_id"): + session_id = state["session_id"] + phase = state.get("phase", "unknown") + attempt = state.get("attempt_count", 0) + console.print( + f" [dim]Previous session: {phase} " + f"(attempt {attempt}, id: {session_id})[/dim]" + ) + + resume_choice = inquirer.select( + message="Resume previous Claude session or start fresh?", + choices=[ + {"name": f"Resume session ({phase})", "value": "resume"}, + {"name": "Start fresh fix attempt", "value": "fresh"}, + ], + ).execute() + + if resume_choice == "resume": + return selection, None, False, session_id + + return selection, None, False, None # New session: ask for optional CI URL ci_run_info = None @@ -240,7 +270,7 @@ def select_or_create_session(parsed): branch_hint = ci_run_info["branch"] if ci_run_info else None container_name = prompt_for_session_name(branch_hint) - return container_name, ci_run_info, True + return container_name, ci_run_info, True, None def fix_ci(args): @@ -255,8 +285,9 @@ def fix_ci(args): branch_hint = extract_branch_from_args(parsed["reproduce_args"]) container_name = prompt_for_session_name(branch_hint) needs_reproduce = True + resume_session_id = None else: - container_name, ci_run_info, needs_reproduce = select_or_create_session(parsed) + container_name, ci_run_info, needs_reproduce, resume_session_id = select_or_create_session(parsed) # Step 1: Preflight checks repo_url = extract_repo_url_from_args(parsed["reproduce_args"]) @@ -274,29 +305,62 @@ def fix_ci(args): if container_name != DEFAULT_CONTAINER_NAME: rename_container(DEFAULT_CONTAINER_NAME, container_name) - # Step 3: Install Claude in container - setup_claude_in_container(container_name) - - # Step 4: Select fix mode and launch Claude - if ci_run_info: - prompt = CI_RUN_COMPARE_PROMPT_TEMPLATE.format(**ci_run_info) + # Step 3: Setup Claude in container (idempotent — skips if already installed) + if is_claude_installed_in_container(container_name): + console.print("[green]Claude already installed — refreshing config...[/green]") + copy_claude_credentials(container_name) + copy_ci_context(container_name) + copy_display_script(container_name) + inject_resume_function(container_name) else: - prompt = select_fix_mode() - - console.print("\n[bold cyan]Launching Claude Code...[/bold cyan]") - console.print("[dim]Claude will attempt to fix CI failures autonomously[/dim]\n") + setup_claude_in_container(container_name) + + # Step 3.5: If resuming, launch interactive Claude + if resume_session_id: + console.print("\n[bold cyan]Resuming Claude session...[/bold cyan]") + console.print("[dim]You are now in an interactive Claude session[/dim]\n") + docker_exec( + container_name, + f'cd /ros_ws && claude --dangerously-skip-permissions --resume "{resume_session_id}"', + interactive=True, check=False, + ) + else: + # Step 4: Select fix mode and launch Claude + if ci_run_info: + prompt = CI_RUN_COMPARE_PROMPT_TEMPLATE.format(**ci_run_info) + else: + prompt = select_fix_mode() - escaped_prompt = prompt.replace("'", "'\\''") - claude_command = ( - f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " - f"-p '{escaped_prompt}'" - ) - docker_exec(container_name, claude_command, interactive=True, check=False) + console.print("\n[bold cyan]Launching Claude Code...[/bold cyan]") + console.print("[dim]Claude will attempt to fix CI failures autonomously[/dim]") + console.print("[dim]Progress will be displayed below[/dim]\n") - # Step 5: Drop into interactive shell - console.print("\n[bold green]Claude has finished.[/bold green]") - console.print("[cyan]Dropping you into the container shell.[/cyan]") - console.print("[dim]You can run 'git diff', 'git add', 'git commit' etc.[/dim]") - console.print("[dim]The repo is at /ros_ws/src/[/dim]\n") + escaped_prompt = prompt.replace("'", "'\\''") + claude_command = ( + f"cd /ros_ws && claude --dangerously-skip-permissions " + f"-p '{escaped_prompt}' --output-format stream-json " + f"2>/dev/null | ci_fix_display" + ) + docker_exec(container_name, claude_command, check=False) + + # Step 4.5: Show outcome + state = read_container_state(container_name) + if state: + phase = state.get("phase", "unknown") + session_id = state.get("session_id") + attempt = state.get("attempt_count", 1) + console.print(f"\n[bold]Claude finished — phase: {phase}, attempt: {attempt}[/bold]") + if session_id: + console.print(f"[dim]Session ID: {session_id}[/dim]") + else: + console.print("\n[yellow]Could not read state file from container[/yellow]") + + # Step 5: Drop into container shell (both paths converge here) + console.print("\n[bold green]Dropping into container shell.[/bold green]") + console.print("[cyan]Useful commands:[/cyan]") + console.print(" [bold]resume_claude[/bold] — resume the Claude session interactively") + console.print(" [bold]git diff[/bold] — review changes") + console.print(" [bold]git add && git commit[/bold] — commit fixes") + console.print(f" [dim]Repo is at /ros_ws/src/[/dim]\n") docker_exec_interactive(container_name) diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index eab76cc..7cba73c 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -39,6 +39,22 @@ def install_fzf_in_container(container_name): docker_exec(container_name, "apt-get update && apt-get install -y fzf", check=False) +def install_python_deps_in_container(container_name): + """Install ci_tool Python dependencies (rich, etc.) in the container.""" + requirements_file = Path(__file__).parent / "requirements.txt" + if not requirements_file.exists(): + return + + console.print("[cyan]Installing Python dependencies in container...[/cyan]") + docker_cp_to_container( + str(requirements_file), container_name, "/tmp/ci_tool_requirements.txt" + ) + docker_exec( + container_name, + "pip install --quiet -r /tmp/ci_tool_requirements.txt", + ) + + def copy_claude_credentials(container_name): """Copy Claude credentials into the container.""" credentials_path = CLAUDE_HOME / ".credentials.json" @@ -90,6 +106,58 @@ def copy_ci_context(container_name): docker_cp_to_container(str(ci_claude_md), container_name, "/root/.claude/CLAUDE.md") +def copy_display_script(container_name): + """Copy the stream-json display processor into the container.""" + display_script = Path(__file__).parent / "display_progress.py" + if not display_script.exists(): + raise RuntimeError(f"display_progress.py not found at {display_script}") + + console.print("[cyan]Copying ci_fix display script...[/cyan]") + docker_cp_to_container( + str(display_script), container_name, "/usr/local/bin/ci_fix_display" + ) + docker_exec(container_name, "chmod +x /usr/local/bin/ci_fix_display") + + +RESUME_CLAUDE_FUNCTION = r''' +resume_claude() { + local state_file="/ros_ws/.ci_fix_state.json" + if [ ! -f "$state_file" ]; then + echo "No ci_fix state found. Run ci_fix first." + return 1 + fi + local session_id + session_id=$(python3 -c "import json,sys; print(json.load(open('$state_file'))['session_id'])") + if [ -z "$session_id" ] || [ "$session_id" = "None" ]; then + echo "No session_id in state file. Starting fresh Claude session." + cd /ros_ws && claude --dangerously-skip-permissions + return + fi + echo "Resuming Claude session ${session_id}..." + cd /ros_ws && claude --dangerously-skip-permissions --resume "$session_id" +} +''' + + +def inject_resume_function(container_name): + """Add resume_claude bash function to the container's bashrc.""" + console.print("[cyan]Injecting resume_claude function...[/cyan]") + marker = "# ci_fix resume_claude" + check_command = f"grep -q '{marker}' /root/.bashrc" + already_present = docker_exec( + container_name, check_command, check=False, quiet=True, + ) + if already_present.returncode == 0: + return + + docker_exec( + container_name, + f"echo '{marker}' >> /root/.bashrc && cat >> /root/.bashrc << 'RESUME_EOF'\n" + f"{RESUME_CLAUDE_FUNCTION}\nRESUME_EOF", + quiet=True, + ) + + def copy_claude_memory(container_name): """Copy Claude project memory files into the container.""" projects_dir = CLAUDE_HOME / "projects" @@ -206,9 +274,12 @@ def setup_claude_in_container(container_name): install_node_in_container(container_name) install_claude_in_container(container_name) install_fzf_in_container(container_name) + install_python_deps_in_container(container_name) copy_claude_credentials(container_name) copy_claude_config(container_name) copy_ci_context(container_name) + copy_display_script(container_name) + inject_resume_function(container_name) copy_claude_memory(container_name) copy_helper_bash_functions(container_name) configure_git_in_container(container_name) diff --git a/bin/ci_tool/display_progress.py b/bin/ci_tool/display_progress.py new file mode 100644 index 0000000..fd0b922 --- /dev/null +++ b/bin/ci_tool/display_progress.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Display human-readable progress from Claude Code stream-json output. + +Reads newline-delimited JSON from stdin (Claude's --output-format stream-json), +shows a live spinner + assistant text + tool activity via rich, captures the +session_id, and writes a state file on exit. + +Designed to run INSIDE a CI container. Requires: rich (from requirements.txt). +""" +from __future__ import annotations + +import json +import sys +import time +import traceback +from datetime import datetime, timezone + +from rich.console import Console +from rich.live import Live +from rich.spinner import Spinner +from rich.text import Text + +STATE_FILE = "/ros_ws/.ci_fix_state.json" + +console = Console(stderr=True) + + +def write_state(session_id, phase, attempt_count=1): + """Write the ci_fix state file.""" + state = { + "session_id": session_id, + "phase": phase, + "attempt_count": attempt_count, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + with open(STATE_FILE, "w", encoding="utf-8") as state_file: + json.dump(state, state_file, indent=2) + + +def read_existing_attempt_count(): + """Read attempt_count from existing state file, or return 0.""" + try: + with open(STATE_FILE, encoding="utf-8") as state_file: + return json.load(state_file).get("attempt_count", 0) + except (FileNotFoundError, json.JSONDecodeError, KeyError): + return 0 + + +def format_elapsed(start_time): + """Format elapsed time as 'Xm Ys'.""" + elapsed_seconds = int(time.time() - start_time) + minutes = elapsed_seconds // 60 + seconds = elapsed_seconds % 60 + if minutes > 0: + return f"{minutes}m {seconds:02d}s" + return f"{seconds}s" + + +def main(): + """Read stream-json from stdin, display progress, write state on exit.""" + session_id = None + attempt_count = read_existing_attempt_count() + 1 + phase = "fixing" + start_time = time.time() + current_activity = "Starting up" + + try: + with Live( + Spinner("dots", text=Text(f" {current_activity}...", style="cyan")), + console=console, + refresh_per_second=10, + transient=True, + ) as live: + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + + if "session_id" in event and event["session_id"]: + session_id = event["session_id"] + + inner = event.get("event", event) + event_type = inner.get("type", "") + + if event_type == "content_block_start": + block = inner.get("content_block", {}) + if block.get("type") == "tool_use": + tool_name = block.get("name", "unknown") + current_activity = f"Using {tool_name}" + live.update(Spinner( + "dots", + text=Text( + f" {current_activity} " + f"[{format_elapsed(start_time)}]", + style="cyan", + ), + )) + console.print( + f" [dim]tool:[/dim] [bold]{tool_name}[/bold]" + ) + + elif event_type == "content_block_delta": + delta = inner.get("delta", {}) + if delta.get("type") == "text_delta": + text = delta.get("text", "") + console.file.write(text) + console.file.flush() + current_activity = "Thinking" + live.update(Spinner( + "dots", + text=Text( + f" {current_activity} " + f"[{format_elapsed(start_time)}]", + style="cyan", + ), + )) + + phase = "completed" + + except KeyboardInterrupt: + phase = "interrupted" + except Exception: + console.print(f"\n[red]Display processor error:[/red]\n{traceback.format_exc()}") + phase = "stuck" + + write_state(session_id, phase, attempt_count) + + elapsed = format_elapsed(start_time) + if session_id: + console.print( + f"\n[green]Session saved ({session_id}). " + f"Elapsed: {elapsed}. Use 'resume_claude' to continue.[/green]" + ) + else: + console.print(f"\n[yellow]No session ID captured. Elapsed: {elapsed}.[/yellow]") + + +if __name__ == "__main__": + main() From 57339356461446b2c0137dc5a4cd7a66bed6d497 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 13:21:43 +0000 Subject: [PATCH 13/60] testing --- bin/ci_tool/ci_fix.py | 157 ++++++++++++++++++++++++++---------- bin/ci_tool/claude_setup.py | 52 ++++++++++++ 2 files changed, 166 insertions(+), 43 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 7c3fdda..adb68ce 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -19,6 +19,8 @@ copy_ci_context, copy_display_script, inject_resume_function, + inject_rerun_tests_function, + save_package_list, ) from ci_tool.containers import ( DEFAULT_CONTAINER_NAME, @@ -58,32 +60,36 @@ "`source /opt/ros/noetic/setup.bash && source /ros_ws/install/setup.bash`.\n\n" ) -FIX_FROM_LOG_PROMPT = ( +ANALYSIS_PROMPT_TEMPLATE = ( ROS_SOURCE_PREAMBLE - + "The CI tests have already been run. Your job:\n" - "1. Examine the test output in /ros_ws/test_output.log to identify failures\n" - "2. Find and fix the root cause in the source code under /ros_ws/src/\n" - "3. Rebuild the affected packages\n" - "4. Re-run the failing tests to verify your fix\n" - "5. Iterate until all tests pass\n\n" - + SUMMARY_FORMAT + + "The CI tests have already been run. Analyse the failures:\n" + "1. Examine the test output in /ros_ws/test_output.log\n" + "2. For each failing test, report:\n" + " - Package and test name\n" + " - The error/assertion message\n" + " - Your hypothesis for the root cause\n" + "3. Suggest a fix strategy for each failure\n\n" + "Do NOT make any code changes. Only analyse and report.\n" + "{extra_context}" ) -CI_RUN_COMPARE_PROMPT_TEMPLATE = ( - ROS_SOURCE_PREAMBLE - + "Investigate CI failure: {ci_run_url}\n\n" - "1. Verify local and CI are on the same commit:\n" - " - Local: check HEAD in the repo under /ros_ws/src/\n" - " - CI: `gh api repos/{owner_repo}/actions/runs/{run_id} --jq '.head_sha'`\n" - " - If they differ, determine whether the missing/extra commits explain the failure\n\n" - "2. Fetch CI logs: `gh run view {run_id} --log-failed` " - "(use `--log` for full output if needed)\n\n" - "3. Run the same tests locally and compare:\n" - " - Both fail identically: fix the underlying bug\n" - " - CI fails but local passes: investigate environment differences " - "(timing, deps, config)\n" - " - Local fails but CI passes: check for local setup issues\n\n" - "4. Fix the root cause and re-run tests to verify\n\n" +CI_COMPARE_EXTRA_CONTEXT_TEMPLATE = ( + "\nAlso investigate the CI run: {ci_run_url}\n" + "- Verify local and CI are on the same commit:\n" + " - Local: check HEAD in the repo under /ros_ws/src/\n" + " - CI: `gh api repos/{owner_repo}/actions/runs/{run_id} --jq '.head_sha'`\n" + " - If they differ, determine whether the missing/extra commits explain the failure\n" + "- Fetch CI logs: `gh run view {run_id} --log-failed` " + "(use `--log` for full output if needed)\n" + "- Compare CI failures with local test results\n" +) + +FIX_PROMPT_TEMPLATE = ( + "The user has reviewed your analysis. Their feedback:\n" + "{user_feedback}\n\n" + "Now fix the CI failures based on this understanding.\n" + "Rebuild the affected packages and re-run the failing tests to verify.\n" + "Iterate until all tests pass.\n\n" + SUMMARY_FORMAT ) @@ -153,7 +159,10 @@ def extract_info_from_ci_url(ci_run_url): def select_fix_mode(): - """Let the user choose how Claude should fix CI failures.""" + """Let the user choose how Claude should fix CI failures. + + Returns (ci_run_info_or_none, custom_prompt_or_none). + """ mode = inquirer.select( message="How should Claude fix CI?", choices=FIX_MODE_CHOICES, @@ -161,7 +170,7 @@ def select_fix_mode(): ).execute() if mode == "fix_from_log": - return FIX_FROM_LOG_PROMPT + return None, None if mode == "compare_ci_run": ci_run_url = inquirer.text( @@ -169,10 +178,10 @@ def select_fix_mode(): validate=lambda url: "/runs/" in url, invalid_message="URL must contain /runs/ (e.g. https://github.com/org/repo/actions/runs/12345)", ).execute() - ci_run_info = extract_info_from_ci_url(ci_run_url) - return CI_RUN_COMPARE_PROMPT_TEMPLATE.format(**ci_run_info) + return extract_info_from_ci_url(ci_run_url), None - return inquirer.text(message="Enter your custom prompt for Claude:").execute() + custom_prompt = inquirer.text(message="Enter your custom prompt for Claude:").execute() + return None, custom_prompt def parse_fix_args(args): @@ -273,6 +282,48 @@ def select_or_create_session(parsed): return container_name, ci_run_info, True, None +def build_analysis_prompt(ci_run_info): + """Build the analysis prompt, optionally including CI compare context.""" + if ci_run_info: + extra_context = CI_COMPARE_EXTRA_CONTEXT_TEMPLATE.format(**ci_run_info) + else: + extra_context = "" + return ANALYSIS_PROMPT_TEMPLATE.format(extra_context=extra_context) + + +def run_claude_streamed(container_name, prompt): + """Run Claude non-interactively with stream-json output piped to ci_fix_display.""" + escaped_prompt = prompt.replace("'", "'\\''") + claude_command = ( + f"cd /ros_ws && claude --dangerously-skip-permissions " + f"-p '{escaped_prompt}' --output-format stream-json " + f"2>/dev/null | ci_fix_display" + ) + docker_exec(container_name, claude_command, check=False) + + +def run_claude_resumed(container_name, session_id, prompt): + """Resume a Claude session with a new prompt, streaming output.""" + escaped_prompt = prompt.replace("'", "'\\''") + claude_command = ( + f"cd /ros_ws && claude --dangerously-skip-permissions " + f"--resume '{session_id}' -p '{escaped_prompt}' --output-format stream-json " + f"2>/dev/null | ci_fix_display" + ) + docker_exec(container_name, claude_command, check=False) + + +def prompt_user_for_feedback(): + """Ask the user to review Claude's analysis and provide corrections.""" + feedback = inquirer.text( + message="Review the analysis above. Provide corrections or context (Enter to accept as-is):", + default="", + ).execute().strip() + if not feedback: + return "Analysis looks correct, proceed with fixing." + return feedback + + def fix_ci(args): """Main fix workflow: session select -> preflight -> reproduce -> Claude -> shell.""" parsed = parse_fix_args(args) @@ -304,6 +355,7 @@ def fix_ci(args): reproduce_ci(parsed["reproduce_args"], skip_preflight=True) if container_name != DEFAULT_CONTAINER_NAME: rename_container(DEFAULT_CONTAINER_NAME, container_name) + save_package_list(container_name) # Step 3: Setup Claude in container (idempotent — skips if already installed) if is_claude_installed_in_container(container_name): @@ -312,6 +364,7 @@ def fix_ci(args): copy_ci_context(container_name) copy_display_script(container_name) inject_resume_function(container_name) + inject_rerun_tests_function(container_name) else: setup_claude_in_container(container_name) @@ -325,23 +378,40 @@ def fix_ci(args): interactive=True, check=False, ) else: - # Step 4: Select fix mode and launch Claude + # Step 4: Determine fix mode if ci_run_info: - prompt = CI_RUN_COMPARE_PROMPT_TEMPLATE.format(**ci_run_info) + custom_prompt = None else: - prompt = select_fix_mode() + ci_run_info, custom_prompt = select_fix_mode() - console.print("\n[bold cyan]Launching Claude Code...[/bold cyan]") - console.print("[dim]Claude will attempt to fix CI failures autonomously[/dim]") - console.print("[dim]Progress will be displayed below[/dim]\n") - - escaped_prompt = prompt.replace("'", "'\\''") - claude_command = ( - f"cd /ros_ws && claude --dangerously-skip-permissions " - f"-p '{escaped_prompt}' --output-format stream-json " - f"2>/dev/null | ci_fix_display" - ) - docker_exec(container_name, claude_command, check=False) + if custom_prompt: + # Custom prompt: single-shot, no analysis phase + console.print("\n[bold cyan]Launching Claude Code (custom prompt)...[/bold cyan]") + run_claude_streamed(container_name, custom_prompt) + else: + # Step 4a: Analysis phase + analysis_prompt = build_analysis_prompt(ci_run_info) + console.print("\n[bold cyan]Launching Claude Code — analysis phase...[/bold cyan]") + console.print("[dim]Claude will analyse failures before attempting fixes[/dim]\n") + run_claude_streamed(container_name, analysis_prompt) + + # Step 4b: User review + console.print() + user_feedback = prompt_user_for_feedback() + + # Step 4c: Fix phase (resume session) + state = read_container_state(container_name) + session_id = state["session_id"] if state else None + if session_id: + console.print("\n[bold cyan]Resuming Claude — fix phase...[/bold cyan]") + console.print("[dim]Claude will now fix the failures[/dim]\n") + fix_prompt = FIX_PROMPT_TEMPLATE.format(user_feedback=user_feedback) + run_claude_resumed(container_name, session_id, fix_prompt) + else: + console.print( + "\n[yellow]No session ID from analysis phase — " + "cannot resume. Dropping to shell.[/yellow]" + ) # Step 4.5: Show outcome state = read_container_state(container_name) @@ -358,9 +428,10 @@ def fix_ci(args): # Step 5: Drop into container shell (both paths converge here) console.print("\n[bold green]Dropping into container shell.[/bold green]") console.print("[cyan]Useful commands:[/cyan]") + console.print(" [bold]rerun_tests[/bold] — rebuild and re-run CI tests locally") console.print(" [bold]resume_claude[/bold] — resume the Claude session interactively") console.print(" [bold]git diff[/bold] — review changes") console.print(" [bold]git add && git commit[/bold] — commit fixes") - console.print(f" [dim]Repo is at /ros_ws/src/[/dim]\n") + console.print(" [dim]Repo is at /ros_ws/src/[/dim]\n") docker_exec_interactive(container_name) diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index 7cba73c..7d85f46 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -119,6 +119,25 @@ def copy_display_script(container_name): docker_exec(container_name, "chmod +x /usr/local/bin/ci_fix_display") +RERUN_TESTS_FUNCTION = r''' +rerun_tests() { + local packages_file="/ros_ws/.ci_packages" + if [ ! -f "$packages_file" ]; then + echo "No package list found at $packages_file" + return 1 + fi + local packages + packages=$(tr '\n' ' ' < "$packages_file") + echo "Rebuilding and testing: ${packages}" + cd /ros_ws + colcon build --packages-select ${packages} --cmake-args -DSETUPTOOLS_DEB_LAYOUT=OFF + source /ros_ws/install/setup.bash + colcon test --packages-select ${packages} + colcon test-result --verbose +} +''' + + RESUME_CLAUDE_FUNCTION = r''' resume_claude() { local state_file="/ros_ws/.ci_fix_state.json" @@ -158,6 +177,38 @@ def inject_resume_function(container_name): ) +def save_package_list(container_name): + """Run colcon list in the container and save package names to /ros_ws/.ci_packages.""" + console.print("[cyan]Saving workspace package list...[/cyan]") + result = docker_exec( + container_name, + "cd /ros_ws && colcon list --names-only > /ros_ws/.ci_packages", + check=False, + quiet=True, + ) + if result.returncode != 0: + console.print("[yellow]Could not save package list (colcon list failed)[/yellow]") + + +def inject_rerun_tests_function(container_name): + """Add rerun_tests bash function to the container's bashrc.""" + console.print("[cyan]Injecting rerun_tests function...[/cyan]") + marker = "# ci_fix rerun_tests" + check_command = f"grep -q '{marker}' /root/.bashrc" + already_present = docker_exec( + container_name, check_command, check=False, quiet=True, + ) + if already_present.returncode == 0: + return + + docker_exec( + container_name, + f"echo '{marker}' >> /root/.bashrc && cat >> /root/.bashrc << 'RERUN_EOF'\n" + f"{RERUN_TESTS_FUNCTION}\nRERUN_EOF", + quiet=True, + ) + + def copy_claude_memory(container_name): """Copy Claude project memory files into the container.""" projects_dir = CLAUDE_HOME / "projects" @@ -280,6 +331,7 @@ def setup_claude_in_container(container_name): copy_ci_context(container_name) copy_display_script(container_name) inject_resume_function(container_name) + inject_rerun_tests_function(container_name) copy_claude_memory(container_name) copy_helper_bash_functions(container_name) configure_git_in_container(container_name) From e3abea0397b2a5e18f0a967c5cd9a0510c7ea5fc Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 13:22:24 +0000 Subject: [PATCH 14/60] error --- bin/ci_tool/ci_fix.py | 7 +++++-- bin/ci_tool/display_progress.py | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index adb68ce..dd7651d 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -291,13 +291,16 @@ def build_analysis_prompt(ci_run_info): return ANALYSIS_PROMPT_TEMPLATE.format(extra_context=extra_context) +CLAUDE_STDERR_LOG = "/ros_ws/.claude_stderr.log" + + def run_claude_streamed(container_name, prompt): """Run Claude non-interactively with stream-json output piped to ci_fix_display.""" escaped_prompt = prompt.replace("'", "'\\''") claude_command = ( f"cd /ros_ws && claude --dangerously-skip-permissions " f"-p '{escaped_prompt}' --output-format stream-json " - f"2>/dev/null | ci_fix_display" + f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) docker_exec(container_name, claude_command, check=False) @@ -308,7 +311,7 @@ def run_claude_resumed(container_name, session_id, prompt): claude_command = ( f"cd /ros_ws && claude --dangerously-skip-permissions " f"--resume '{session_id}' -p '{escaped_prompt}' --output-format stream-json " - f"2>/dev/null | ci_fix_display" + f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) docker_exec(container_name, claude_command, check=False) diff --git a/bin/ci_tool/display_progress.py b/bin/ci_tool/display_progress.py index fd0b922..7117ff3 100644 --- a/bin/ci_tool/display_progress.py +++ b/bin/ci_tool/display_progress.py @@ -21,6 +21,7 @@ from rich.text import Text STATE_FILE = "/ros_ws/.ci_fix_state.json" +CLAUDE_STDERR_LOG = "/ros_ws/.claude_stderr.log" console = Console(stderr=True) @@ -137,6 +138,14 @@ def main(): ) else: console.print(f"\n[yellow]No session ID captured. Elapsed: {elapsed}.[/yellow]") + try: + with open(CLAUDE_STDERR_LOG, encoding="utf-8") as stderr_log: + stderr_content = stderr_log.read().strip() + if stderr_content: + console.print("[yellow]Claude stderr output:[/yellow]") + console.print(stderr_content) + except FileNotFoundError: + pass if __name__ == "__main__": From d31a242375a98c2673f46fec68e42e2b528a5533 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 14:01:27 +0000 Subject: [PATCH 15/60] handle ctrl+c during reproduce to continue to claude phase --- bin/ci_tool/ci_reproduce.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index b6036a7..8b340a1 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -132,14 +132,20 @@ def reproduce_ci(args, skip_preflight=False): capture_output=True, text=True, check=True, ) - result = subprocess.run( - ["bash", "-c", fetch_result.stdout + '\n"$@"', "--"] + full_args, - check=False, - ) - - if result.returncode != 0: + try: + result = subprocess.run( + ["bash", "-c", fetch_result.stdout + '\n"$@"', "--"] + full_args, + check=False, + ) + except KeyboardInterrupt: console.print( - f"\n[yellow]CI reproduction exited with code {result.returncode} " - f"(expected — tests likely failed)[/yellow]" + "\n[yellow]Interrupted — continuing with whatever test output " + "was captured[/yellow]" ) + else: + if result.returncode != 0: + console.print( + f"\n[yellow]CI reproduction exited with code {result.returncode} " + f"(expected — tests likely failed)[/yellow]" + ) console.print(f"\n[green]Container '{container_name}' is ready[/green]") From de88bbba443ada440870d16f57f815e7e5077179 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 14:04:53 +0000 Subject: [PATCH 16/60] pass container name to reproduce instead of rename after --- bin/ci_tool/ci_fix.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index dd7651d..17f5602 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -23,14 +23,12 @@ save_package_list, ) from ci_tool.containers import ( - DEFAULT_CONTAINER_NAME, container_exists, container_is_running, docker_exec, docker_exec_interactive, list_ci_containers, remove_container, - rename_container, sanitize_container_name, start_container, ) @@ -353,11 +351,10 @@ def fix_ci(args): # Step 2: Reproduce CI in container if needs_reproduce: - if container_exists(DEFAULT_CONTAINER_NAME): - remove_container(DEFAULT_CONTAINER_NAME) - reproduce_ci(parsed["reproduce_args"], skip_preflight=True) - if container_name != DEFAULT_CONTAINER_NAME: - rename_container(DEFAULT_CONTAINER_NAME, container_name) + if container_exists(container_name): + remove_container(container_name) + reproduce_args = parsed["reproduce_args"] + ["--container-name", container_name] + reproduce_ci(reproduce_args, skip_preflight=True) save_package_list(container_name) # Step 3: Setup Claude in container (idempotent — skips if already installed) From c9a2441a757c4178415d4a75fd4eaf8afa0c372a Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 14:09:01 +0000 Subject: [PATCH 17/60] use --container-name from args for python-side container checks --- bin/ci_tool/ci_reproduce.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index 8b340a1..ae0fb4c 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -38,6 +38,14 @@ def extract_branch_from_args(args): return None +def extract_container_name_from_args(args): + """Extract --container-name/-n value from args list, or return None.""" + for i, arg in enumerate(args): + if arg in ("--container-name", "-n") and i + 1 < len(args): + return args[i + 1] + return None + + def prompt_for_reproduce_args(): """Interactively ask user for the required reproduce arguments.""" repo_url = inquirer.text( @@ -74,7 +82,7 @@ def reproduce_ci(args, skip_preflight=False): console.print(f"\n[bold red]Preflight failed:[/bold red] {error}") sys.exit(1) - container_name = DEFAULT_CONTAINER_NAME + container_name = extract_container_name_from_args(args) or DEFAULT_CONTAINER_NAME if container_exists(container_name): action = inquirer.select( From 82308c8bbb2779ade162abaefc35608c48179b7d Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 14:25:31 +0000 Subject: [PATCH 18/60] set IS_SANDBOX=1 to allow claude as root in container --- bin/ci_tool/ci_fix.py | 6 +++--- bin/ci_tool/claude_setup.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 17f5602..cd49def 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -296,7 +296,7 @@ def run_claude_streamed(container_name, prompt): """Run Claude non-interactively with stream-json output piped to ci_fix_display.""" escaped_prompt = prompt.replace("'", "'\\''") claude_command = ( - f"cd /ros_ws && claude --dangerously-skip-permissions " + f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " f"-p '{escaped_prompt}' --output-format stream-json " f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) @@ -307,7 +307,7 @@ def run_claude_resumed(container_name, session_id, prompt): """Resume a Claude session with a new prompt, streaming output.""" escaped_prompt = prompt.replace("'", "'\\''") claude_command = ( - f"cd /ros_ws && claude --dangerously-skip-permissions " + f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " f"--resume '{session_id}' -p '{escaped_prompt}' --output-format stream-json " f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) @@ -374,7 +374,7 @@ def fix_ci(args): console.print("[dim]You are now in an interactive Claude session[/dim]\n") docker_exec( container_name, - f'cd /ros_ws && claude --dangerously-skip-permissions --resume "{resume_session_id}"', + f'cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions --resume "{resume_session_id}"', interactive=True, check=False, ) else: diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index 7d85f46..588909c 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -318,6 +318,16 @@ def is_claude_installed_in_container(container_name): return result.returncode == 0 +def set_sandbox_env(container_name): + """Set IS_SANDBOX=1 so Claude allows --dangerously-skip-permissions as root.""" + docker_exec( + container_name, + "grep -q 'export IS_SANDBOX=1' /root/.bashrc " + "|| echo 'export IS_SANDBOX=1' >> /root/.bashrc", + quiet=True, + ) + + def setup_claude_in_container(container_name): """Full setup: install Claude Code and copy all config into container.""" console.print("\n[bold cyan]Setting up Claude in container...[/bold cyan]") @@ -332,6 +342,7 @@ def setup_claude_in_container(container_name): copy_display_script(container_name) inject_resume_function(container_name) inject_rerun_tests_function(container_name) + set_sandbox_env(container_name) copy_claude_memory(container_name) copy_helper_bash_functions(container_name) configure_git_in_container(container_name) From ad61f305b3829d59867f26072ea82d8b08a53b34 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 14:38:02 +0000 Subject: [PATCH 19/60] add --verbose flag required for stream-json with -p --- bin/ci_tool/ci_fix.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index cd49def..a2c17a5 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -297,7 +297,7 @@ def run_claude_streamed(container_name, prompt): escaped_prompt = prompt.replace("'", "'\\''") claude_command = ( f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " - f"-p '{escaped_prompt}' --output-format stream-json " + f"-p '{escaped_prompt}' --verbose --output-format stream-json " f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) docker_exec(container_name, claude_command, check=False) @@ -308,7 +308,7 @@ def run_claude_resumed(container_name, session_id, prompt): escaped_prompt = prompt.replace("'", "'\\''") claude_command = ( f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " - f"--resume '{session_id}' -p '{escaped_prompt}' --output-format stream-json " + f"--resume '{session_id}' -p '{escaped_prompt}' --verbose --output-format stream-json " f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) docker_exec(container_name, claude_command, check=False) From cdff64d1454598d8f2d5aac3a99256d31ee13501 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 15:46:09 +0000 Subject: [PATCH 20/60] add ci_tool rearchitect design doc Co-Authored-By: Claude Opus 4.6 --- .../2026-02-18-ci-tool-rearchitect-design.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/plans/2026-02-18-ci-tool-rearchitect-design.md diff --git a/docs/plans/2026-02-18-ci-tool-rearchitect-design.md b/docs/plans/2026-02-18-ci-tool-rearchitect-design.md new file mode 100644 index 0000000..66dea63 --- /dev/null +++ b/docs/plans/2026-02-18-ci-tool-rearchitect-design.md @@ -0,0 +1,180 @@ +# CI Tool Rearchitect Design + +## Problem + +When the CI tool is run without a GitHub Actions URL, it fails to ask for a repo URL and branch, then crashes through a cascade of "No such container" errors. This is symptomatic of deeper architectural issues: + +1. **Prompting scattered across modules** — `select_or_create_session` (ci_fix.py), `prompt_for_reproduce_args` (ci_reproduce.py), and `fix_ci` all collect user input at different stages. +2. **No fail-fast after reproduction failure** — The tool continues to container setup even when `reproduce_ci.sh` fails and no container exists. +3. **Absurd fetch chain** — Python curls a public bash wrapper, which curls 3 private scripts, which run bash that does what Python could do directly. +4. **Side-effect mutation** — `select_or_create_session` mutates `parsed["reproduce_args"]` rather than returning clean data. +5. **Dead code and redundant abstractions** — `rename_container`, `parse_fix_args`, `extract_*_from_args` helpers. +6. **Container collision handled in 3 places** — `prompt_for_session_name`, `reproduce_ci`, and `fix_ci` all handle existing containers differently. +7. **Preflight skips repo validation** — When no CI URL is provided, `repo_url` is None and the token's repo-access check is silently skipped. + +## Design + +### Entry Points + +``` +setup.sh (bash, one-time setup + hand-off) + Install helpers → configure GH_TOKEN → install ci_tool → exec ci_tool + +ci_tool / ci_fix (Python, primary interface) + All functionality: reproduce, fix with Claude, shell, clean, retest + +reproduce_ci.sh (bash, backward-compatible standalone) + Standalone CI reproduction without Python. No changes. +``` + +### Core Flow: `ci_tool fix` + +``` +gather_session_info() <- All prompts happen here + |- Existing containers? -> Resume menu + '- New session: + |- CI URL? (optional) + |- Repo URL + Branch (if no CI URL) + |- Only needed deps? + '- Session name + +run_all_preflight_checks() <- Always validates repo_url + +reproduce_ci() <- Python does Docker orchestration directly + |- Fetch ci_workspace_setup.sh + ci_repull_and_retest.sh via urllib + |- Validate deps.repos reachable + |- docker create (env vars, volume mounts, graphical forwarding) + |- docker start + |- docker exec ci_workspace_setup.sh + '- Guard: raise if container doesn't exist + +setup_claude_in_container() <- Existing, no major changes + +run_claude_workflow() <- Analysis -> Review -> Fix (or custom/resume) + +drop_to_shell() <- Interactive container shell +``` + +### Data Structures + +`gather_session_info` returns a dict: + +```python +# New session: +{ + "mode": "new", + "container_name": "er_ci_my_branch", + "repo_url": "https://github.com/Extend-Robotics/er_interface", + "branch": "my-branch", + "only_needed_deps": True, + "ci_run_info": {...} or None, +} + +# Resume existing container: +{ + "mode": "resume", + "container_name": "er_ci_existing", + "resume_session_id": "abc123" or None, +} +``` + +### Module Responsibilities + +| Module | Responsibility | +|--------|---------------| +| `ci_fix.py` | `gather_session_info()`, `fix_ci()` linear orchestration, Claude prompts/templates | +| `ci_reproduce.py` | Docker orchestration (create/start/exec), fetch container-side scripts, validate deps.repos | +| `preflight.py` | Validate Docker, GH token (with repo access — always), Claude credentials | +| `claude_setup.py` | Install/configure Claude in container (unchanged) | +| `containers.py` | Low-level Docker helpers (exists, running, exec, cp, remove, list) | +| `cli.py` | Menu dispatcher (minor adapter for `_handle_reproduce`) | +| `display_progress.py` | Stream-json display processor (unchanged, runs in container) | +| `claude_session.py` | Interactive Claude session launcher (unchanged) | + +### `reproduce_ci` New Interface + +```python +def reproduce_ci( + repo_url: str, + branch: str, + container_name: str, + gh_token: str, + only_needed_deps: bool = True, + scripts_branch: str = "main", + graphical: bool = True, +): + """Create a CI reproduction container. + + Fetches container-side scripts from er_build_tools_internal, + creates Docker container with proper env/volumes, runs workspace setup. + + Raises RuntimeError if container doesn't exist after execution. + """ +``` + +Explicit parameters instead of a string arg list. No interactive prompts. +The CLI `reproduce` subcommand uses a thin adapter that parses CLI args or +calls `prompt_for_reproduce_args()` before calling this function. + +### Python Docker Orchestration (replaces bash wrapper) + +Currently Python shells out to a bash wrapper that shells out to another +bash script. The new `reproduce_ci` does the Docker orchestration directly: + +1. **Fetch container-side scripts** via `urllib` with GH token auth header + from `er_build_tools_internal` (configurable branch). +2. **Write to `/tmp/er_reproduce_ci/`** — same location the bash wrapper uses. +3. **Validate deps.repos** is reachable (curl-equivalent HTTP HEAD check). +4. **Build `docker create` args** — env vars (GH_TOKEN, REPO_URL, BRANCH, etc.), + volume mounts (scripts as read-only), network/IPC host, optional graphical + forwarding (X11, NVIDIA). +5. **`docker create`** + **`docker start`** + **`docker exec bash /tmp/ci_workspace_setup.sh`** + via `subprocess.run`. +6. **Container guard** — verify `container_exists()` after execution, raise if not. + +### Rich UI Throughout + +All existing UI stays: +- **InquirerPy** for interactive prompts (select menus, text inputs, confirmations). +- **Rich** for colored console output, panels, status messages. +- **display_progress.py** for Claude stream-json spinner/activity display. + +The reproduce step gets improved UI by moving from plain bash output to rich: +- Spinner with elapsed time during long-running docker exec +- Checkmark confirmations for each setup step +- Colored error messages on failure + +### Preflight Changes + +`run_all_preflight_checks` always requires `repo_url`: +- `gather_session_info` always provides a repo URL (from CI URL extraction or direct prompt) +- For resume mode, preflight is skipped (container already exists) +- The GH token repo-access check always runs for new sessions + +### setup.sh Changes + +Add ci_tool Python package installation and hand-off: +- After existing setup steps (helpers, GH token, shell integration, Claude check) +- `pip install` ci_tool from er_build_tools +- `exec python3 -m ci_tool` to hand off to the Python tool + +### Files Changed + +| File | Change | Repo | +|------|--------|------| +| `ci_fix.py` | Major refactor: `gather_session_info`, linear `fix_ci` flow | er_build_tools | +| `ci_reproduce.py` | Rewrite: Python Docker orchestration, explicit params | er_build_tools | +| `preflight.py` | `repo_url` always required for new sessions | er_build_tools | +| `containers.py` | Remove `rename_container` (dead code) | er_build_tools | +| `cli.py` | Adapt `_handle_reproduce` for new `reproduce_ci` interface | er_build_tools | +| `setup.sh` | Add ci_tool install + hand-off to Python | er_build_tools | + +### Files Unchanged + +| File | Reason | +|------|--------| +| `claude_setup.py` | Works as-is | +| `display_progress.py` | Runs in container, works as-is | +| `claude_session.py` | Works as-is | +| `reproduce_ci.sh` (public wrapper) | Kept for backward compat | +| All `er_build_tools_internal` scripts | No changes needed | From f3e6b4725a2d2044b5e91b15cce3826b25d43282 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 15:51:35 +0000 Subject: [PATCH 21/60] add ci_tool rearchitect implementation plan Co-Authored-By: Claude Opus 4.6 --- .../2026-02-18-ci-tool-rearchitect-plan.md | 1157 +++++++++++++++++ 1 file changed, 1157 insertions(+) create mode 100644 docs/plans/2026-02-18-ci-tool-rearchitect-plan.md diff --git a/docs/plans/2026-02-18-ci-tool-rearchitect-plan.md b/docs/plans/2026-02-18-ci-tool-rearchitect-plan.md new file mode 100644 index 0000000..a425024 --- /dev/null +++ b/docs/plans/2026-02-18-ci-tool-rearchitect-plan.md @@ -0,0 +1,1157 @@ +# CI Tool Rearchitect Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix the missing repo/branch prompt bug and rearchitect ci_tool so prompting is consolidated, Docker orchestration is done in Python (not bash), and the tool fails fast on errors. + +**Architecture:** All user prompts happen up-front in `gather_session_info()`. `reproduce_ci()` takes explicit params and does Docker orchestration directly in Python (bypassing the bash wrapper chain). A container existence guard prevents cascading failures. + +**Tech Stack:** Python 3.8+, InquirerPy, Rich, Docker CLI via subprocess, urllib for GitHub API + +--- + +### Task 1: Remove dead code from containers.py + +**Files:** +- Modify: `bin/ci_tool/containers.py:80-83` (remove `rename_container`) + +**Step 1: Remove `rename_container` function** + +Delete the `rename_container` function at line 80-83 of `containers.py`: + +```python +# DELETE this entire function: +def rename_container(old_name, new_name): + """Rename a Docker container.""" + run_command(["docker", "rename", old_name, new_name]) +``` + +**Step 2: Verify no references remain** + +Run: `cd /cortex/er_build_tools && grep -r "rename_container" bin/ci_tool/` +Expected: No matches + +**Step 3: Lint** + +Run: `source ~/.helper_bash_functions && cd /cortex/.catkin_ws/src/er_build_tools/bin/ci_tool && python3 -m pylint containers.py --disable=all --enable=E` +Expected: No errors (warnings OK if pre-existing) + +**Step 4: Commit** + +```bash +git add bin/ci_tool/containers.py +git commit -m "remove unused rename_container from containers.py" +``` + +--- + +### Task 2: Rewrite ci_reproduce.py — Python Docker orchestration + +**Files:** +- Modify: `bin/ci_tool/ci_reproduce.py` (full rewrite) + +**Step 1: Write the new ci_reproduce.py** + +Replace the entire file with: + +```python +#!/usr/bin/env python3 +"""Reproduce CI locally by creating a Docker container.""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +from rich.console import Console +from rich.panel import Panel + +from ci_tool.containers import container_exists + +console = Console() + +DEFAULT_SCRIPTS_BRANCH = "main" +SCRIPTS_CACHE_DIR = "/tmp/er_reproduce_ci" +INTERNAL_REPO = "Extend-Robotics/er_build_tools_internal" +DEFAULT_DOCKER_IMAGE = ( + "rostooling/setup-ros-docker:ubuntu-focal-ros-noetic-desktop-latest" +) + +CONTAINER_SIDE_SCRIPTS = [ + "ci_workspace_setup.sh", + "ci_repull_and_retest.sh", +] + + +def fetch_internal_script(script_name, gh_token, scripts_branch): + """Fetch a script from er_build_tools_internal and save to cache dir.""" + url = ( + f"https://raw.githubusercontent.com/{INTERNAL_REPO}" + f"/refs/heads/{scripts_branch}/bin/{script_name}" + ) + request = Request(url, headers={"Authorization": f"token {gh_token}"}) + try: + with urlopen(request, timeout=30) as response: + content = response.read() + except HTTPError as error: + raise RuntimeError( + f"Failed to fetch {script_name} from {INTERNAL_REPO} " + f"(branch: {scripts_branch}): HTTP {error.code}" + ) from error + + cache_dir = Path(SCRIPTS_CACHE_DIR) + cache_dir.mkdir(parents=True, exist_ok=True) + script_path = cache_dir / script_name + script_path.write_bytes(content) + script_path.chmod(0o755) + return str(script_path) + + +def fetch_container_side_scripts(gh_token, scripts_branch): + """Fetch all container-side scripts from er_build_tools_internal.""" + console.print( + f"[cyan]Fetching CI scripts from {INTERNAL_REPO} " + f"(branch: {scripts_branch})...[/cyan]" + ) + script_paths = {} + for script_name in CONTAINER_SIDE_SCRIPTS: + path = fetch_internal_script(script_name, gh_token, scripts_branch) + console.print(f" [green]\u2713[/green] {script_name}") + script_paths[script_name] = path + return script_paths + + +def validate_deps_repos_reachable( + repo_url, branch, gh_token, deps_file="deps.repos" +): + """Validate that deps.repos is reachable at the given branch.""" + repo_path = parse_repo_path(repo_url) + branch_for_raw = branch or "main" + deps_url = ( + f"https://raw.githubusercontent.com/{repo_path}" + f"/{branch_for_raw}/{deps_file}" + ) + console.print(f"[cyan]Validating {deps_file} is reachable...[/cyan]") + request = Request( + deps_url, method="HEAD", headers={"Authorization": f"token {gh_token}"} + ) + try: + with urlopen(request, timeout=10): + pass + except HTTPError as error: + raise RuntimeError( + f"Could not reach {deps_file} at {deps_url} (HTTP {error.code}). " + f"Check that branch '{branch_for_raw}' exists and " + f"{deps_file} is present." + ) from error + console.print( + f" [green]\u2713[/green] {deps_file} reachable " + f"at branch {branch_for_raw}" + ) + + +def parse_repo_path(repo_url): + """Extract 'org/repo' from a GitHub URL.""" + repo_url_clean = repo_url.rstrip("/").removesuffix(".git") + return repo_url_clean.split("github.com/")[1] + + +def parse_repo_parts(repo_url): + """Extract org, repo_name, and cleaned URL from a GitHub URL.""" + repo_url_clean = repo_url.rstrip("/").removesuffix(".git") + repo_path = repo_url_clean.split("github.com/")[1] + org, repo_name = repo_path.split("/", 1) + return org, repo_name, repo_url_clean + + +def build_docker_create_command( + container_name, + script_paths, + gh_token, + repo_url_clean, + repo_name, + org, + branch, + only_needed_deps, + graphical, +): + """Build the full docker create command with all args.""" + docker_args = [ + "docker", "create", + "--name", container_name, + "--network=host", + "--ipc=host", + "-v", f"{script_paths['ci_workspace_setup.sh']}:/tmp/ci_workspace_setup.sh:ro", + "-v", f"{script_paths['ci_repull_and_retest.sh']}:/tmp/ci_repull_and_retest.sh:ro", + "-e", f"GH_TOKEN={gh_token}", + "-e", f"REPO_URL={repo_url_clean}", + "-e", f"REPO_NAME={repo_name}", + "-e", f"ORG={org}", + "-e", "DEPS_FILE=deps.repos", + "-e", f"BRANCH={branch}", + "-e", f"ONLY_NEEDED_DEPS={'true' if only_needed_deps else 'false'}", + "-e", "SKIP_TESTS=false", + "-e", "ADDITIONAL_COMMAND=", + ] + + if graphical: + display = os.environ.get("DISPLAY", "") + if display: + console.print("[cyan]Enabling graphical forwarding...[/cyan]") + subprocess.run( + ["xhost", "+local:"], check=False, capture_output=True + ) + docker_args.extend([ + "--runtime", "nvidia", + "--gpus", "all", + "--privileged", + "--security-opt", "seccomp=unconfined", + "-v", "/tmp/.X11-unix:/tmp/.X11-unix:rw", + "-e", f"DISPLAY={display}", + "-e", "QT_X11_NO_MITSHM=1", + "-e", "NVIDIA_DRIVER_CAPABILITIES=all", + "-e", "NVIDIA_VISIBLE_DEVICES=all", + ]) + + docker_args.extend([DEFAULT_DOCKER_IMAGE, "sleep", "infinity"]) + return docker_args + + +def reproduce_ci( + repo_url, + branch, + container_name, + gh_token, + only_needed_deps=True, + scripts_branch=DEFAULT_SCRIPTS_BRANCH, + graphical=True, +): + """Create a CI reproduction container. + + Fetches container-side scripts from er_build_tools_internal, + creates Docker container with proper env/volumes, runs workspace setup. + + Raises RuntimeError if container doesn't exist after execution. + """ + console.print(Panel("[bold]Reproducing CI Locally[/bold]", expand=False)) + + script_paths = fetch_container_side_scripts(gh_token, scripts_branch) + validate_deps_repos_reachable(repo_url, branch, gh_token) + + org, repo_name, repo_url_clean = parse_repo_parts(repo_url) + console.print(f" Organization: {org}") + console.print(f" Repository: {repo_name}") + + create_command = build_docker_create_command( + container_name, script_paths, gh_token, repo_url_clean, + repo_name, org, branch, only_needed_deps, graphical, + ) + + console.print(f"\n[cyan]Creating container '{container_name}'...[/cyan]") + subprocess.run(create_command, check=True) + console.print(f" [green]\u2713[/green] Container created") + + subprocess.run(["docker", "start", container_name], check=True) + console.print(f" [green]\u2713[/green] Container started") + + console.print("\n[cyan]Running CI workspace setup...[/cyan]") + workspace_setup_exit_code = 0 + try: + result = subprocess.run( + ["docker", "exec", container_name, + "bash", "/tmp/ci_workspace_setup.sh"], + check=False, + ) + workspace_setup_exit_code = result.returncode + except KeyboardInterrupt: + console.print( + "\n[yellow]Interrupted \u2014 continuing with whatever test " + "output was captured[/yellow]" + ) + + if workspace_setup_exit_code != 0: + console.print( + f"\n[yellow]CI workspace setup exited with code " + f"{workspace_setup_exit_code} " + f"(expected \u2014 tests likely failed)[/yellow]" + ) + + if not container_exists(container_name): + raise RuntimeError( + f"Container '{container_name}' was not created. " + "Check the output above for errors." + ) + console.print( + f"\n[green]\u2713 Container '{container_name}' is ready[/green]" + ) + + +def prompt_for_reproduce_args(): + """Interactively ask user for reproduce arguments. + + Used by the CLI 'reproduce' subcommand only. + Returns (repo_url, branch, only_needed_deps). + """ + from InquirerPy import inquirer + + repo_url = inquirer.text( + message="Repository URL:", + validate=lambda url: url.startswith("https://github.com/"), + invalid_message="Must be a GitHub URL (https://github.com/...)", + ).execute() + + branch = inquirer.text( + message="Branch name:", + validate=lambda b: len(b.strip()) > 0, + invalid_message="Branch name cannot be empty", + ).execute() + + only_needed_deps = not inquirer.confirm( + message="Build everything (slower, disable --only-needed-deps)?", + default=False, + ).execute() + + return repo_url, branch, only_needed_deps +``` + +**Step 2: Lint** + +Run: `source ~/.helper_bash_functions && cd /cortex/.catkin_ws/src/er_build_tools/bin/ci_tool && python3 -m pylint ci_reproduce.py --disable=all --enable=E` +Expected: No errors + +**Step 3: Commit** + +```bash +git add bin/ci_tool/ci_reproduce.py +git commit -m "rewrite ci_reproduce.py with Python Docker orchestration + +Replaces the bash wrapper fetch chain with direct Python Docker +orchestration. reproduce_ci() now takes explicit params, fetches +container-side scripts via urllib, and raises on failure." +``` + +--- + +### Task 3: Refactor ci_fix.py — consolidated prompting and linear flow + +**Files:** +- Modify: `bin/ci_tool/ci_fix.py` (major refactor) + +**Step 1: Write the new ci_fix.py** + +Replace the entire file. Key changes: +- New `gather_session_info()` consolidates all up-front prompts +- `fix_ci()` is a linear sequence with no scattered prompts +- Removed: `parse_fix_args`, `select_or_create_session`, the args-based path +- Uses new `reproduce_ci` interface with explicit params + +```python +#!/usr/bin/env python3 +"""Fix CI test failures using Claude Code inside a container.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from urllib.request import Request, urlopen + +from InquirerPy import inquirer +from rich.console import Console +from rich.panel import Panel + +from ci_tool.claude_setup import ( + copy_ci_context, + copy_claude_credentials, + copy_display_script, + inject_rerun_tests_function, + inject_resume_function, + is_claude_installed_in_container, + save_package_list, + setup_claude_in_container, +) +from ci_tool.containers import ( + container_exists, + container_is_running, + docker_exec, + docker_exec_interactive, + list_ci_containers, + remove_container, + sanitize_container_name, + start_container, +) +from ci_tool.ci_reproduce import reproduce_ci +from ci_tool.preflight import run_all_preflight_checks, PreflightError + +console = Console() + +SUMMARY_FORMAT = ( + "When done, print EXACTLY this format:\n\n" + "--- SUMMARY ---\n" + "Problem: \n" + "Fix: \n" + "Assumptions: \n\n" + "--- COMMIT MESSAGE ---\n" + "\n" + "--- END ---" +) + +ROS_SOURCE_PREAMBLE = ( + "You are inside a CI reproduction container at /ros_ws. " + "Source the ROS workspace: " + "`source /opt/ros/noetic/setup.bash && source /ros_ws/install/setup.bash`." + "\n\n" +) + +ANALYSIS_PROMPT_TEMPLATE = ( + ROS_SOURCE_PREAMBLE + + "The CI tests have already been run. Analyse the failures:\n" + "1. Examine the test output in /ros_ws/test_output.log\n" + "2. For each failing test, report:\n" + " - Package and test name\n" + " - The error/assertion message\n" + " - Your hypothesis for the root cause\n" + "3. Suggest a fix strategy for each failure\n\n" + "Do NOT make any code changes. Only analyse and report.\n" + "{extra_context}" +) + +CI_COMPARE_EXTRA_CONTEXT_TEMPLATE = ( + "\nAlso investigate the CI run: {ci_run_url}\n" + "- Verify local and CI are on the same commit:\n" + " - Local: check HEAD in the repo under /ros_ws/src/\n" + " - CI: `gh api repos/{owner_repo}/actions/runs/{run_id}" + " --jq '.head_sha'`\n" + " - If they differ, determine whether the missing/extra commits " + "explain the failure\n" + "- Fetch CI logs: `gh run view {run_id} --log-failed` " + "(use `--log` for full output if needed)\n" + "- Compare CI failures with local test results\n" +) + +FIX_PROMPT_TEMPLATE = ( + "The user has reviewed your analysis. Their feedback:\n" + "{user_feedback}\n\n" + "Now fix the CI failures based on this understanding.\n" + "Rebuild the affected packages and re-run the failing tests to verify.\n" + "Iterate until all tests pass.\n\n" + + SUMMARY_FORMAT +) + +FIX_MODE_CHOICES = [ + {"name": "Fix CI failures (from test_output.log)", "value": "fix_from_log"}, + {"name": "Compare with GitHub Actions CI run", "value": "compare_ci_run"}, + {"name": "Custom prompt", "value": "custom"}, +] + +CLAUDE_STDERR_LOG = "/ros_ws/.claude_stderr.log" + + +def read_container_state(container_name): + """Read the ci_fix state file from a container. Returns dict or None.""" + result = subprocess.run( + ["docker", "exec", container_name, + "cat", "/ros_ws/.ci_fix_state.json"], + capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return None + + +def extract_run_id_from_url(ci_run_url): + """Extract the numeric run ID from a GitHub Actions URL. + + Handles URLs like: + https://github.com/org/repo/actions/runs/12345678901 + https://github.com/org/repo/actions/runs/12345678901/job/98765 + """ + parts = ci_run_url.rstrip("/").split("/runs/") + if len(parts) < 2: + raise ValueError(f"Cannot extract run ID from URL: {ci_run_url}") + run_id = parts[1].split("/")[0] + if not run_id.isdigit(): + raise ValueError(f"Run ID is not numeric: {run_id}") + return run_id + + +def extract_info_from_ci_url(ci_run_url): + """Extract repo URL, branch, and run ID from a GitHub Actions URL.""" + run_id = extract_run_id_from_url(ci_run_url) + + owner_repo = ci_run_url.split("github.com/")[1].split("/actions/")[0] + repo_url = f"https://github.com/{owner_repo}" + + token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") + if not token: + raise ValueError( + "No GitHub token found (GH_TOKEN or ER_SETUP_TOKEN)" + ) + + api_url = ( + f"https://api.github.com/repos/{owner_repo}/actions/runs/{run_id}" + ) + request = Request(api_url, headers={ + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + }) + with urlopen(request) as response: + data = json.loads(response.read()) + + return { + "repo_url": repo_url, + "owner_repo": owner_repo, + "branch": data["head_branch"], + "run_id": run_id, + "ci_run_url": ci_run_url, + } + + +def prompt_for_session_name(branch_hint=None): + """Ask user for a session name. Returns full container name (er_ci_). + + Exits if the container already exists. + """ + default = sanitize_container_name(branch_hint) if branch_hint else "" + name = inquirer.text( + message="Session name (used for container naming):", + default=default, + validate=lambda n: len(n.strip()) > 0, + invalid_message="Session name cannot be empty", + ).execute().strip() + + container_name = f"er_ci_{sanitize_container_name(name)}" + + if container_exists(container_name): + console.print( + f"[red]Container '{container_name}' already exists. " + f"Choose a different name or clean up first.[/red]" + ) + sys.exit(1) + + return container_name + + +def gather_session_info(): + """Collect all session information up front via interactive prompts. + + Returns a dict with 'mode' key: + - mode='new': container_name, repo_url, branch, only_needed_deps, + ci_run_info (or None) + - mode='resume': container_name, resume_session_id (or None) + """ + existing = list_ci_containers() + + if existing: + choices = [{"name": "Start new session", "value": "_new"}] + for container in existing: + choices.append({ + "name": ( + f"Resume '{container['name']}' ({container['status']})" + ), + "value": container["name"], + }) + + selection = inquirer.select( + message="Select a session:", + choices=choices, + ).execute() + + if selection != "_new": + if not container_is_running(selection): + start_container(selection) + + resume_session_id = None + state = read_container_state(selection) + if state and state.get("session_id"): + session_id = state["session_id"] + phase = state.get("phase", "unknown") + attempt = state.get("attempt_count", 0) + console.print( + f" [dim]Previous session: {phase} " + f"(attempt {attempt}, id: {session_id})[/dim]" + ) + + resume_choice = inquirer.select( + message=( + "Resume previous Claude session or start fresh?" + ), + choices=[ + { + "name": f"Resume session ({phase})", + "value": "resume", + }, + { + "name": "Start fresh fix attempt", + "value": "fresh", + }, + ], + ).execute() + + if resume_choice == "resume": + resume_session_id = session_id + + return { + "mode": "resume", + "container_name": selection, + "resume_session_id": resume_session_id, + } + + # New session: collect all info + ci_run_info = None + ci_run_url = inquirer.text( + message="GitHub Actions run URL (leave blank to skip):", + default="", + ).execute().strip() + + if ci_run_url: + ci_run_info = extract_info_from_ci_url(ci_run_url) + repo_url = ci_run_info["repo_url"] + branch = ci_run_info["branch"] + console.print(f" [green]Repo:[/green] {repo_url}") + console.print(f" [green]Branch:[/green] {branch}") + console.print(f" [green]Run ID:[/green] {ci_run_info['run_id']}") + else: + repo_url = inquirer.text( + message="Repository URL:", + validate=lambda url: url.startswith("https://github.com/"), + invalid_message="Must be a GitHub URL (https://github.com/...)", + ).execute() + + branch = inquirer.text( + message="Branch name:", + validate=lambda b: len(b.strip()) > 0, + invalid_message="Branch name cannot be empty", + ).execute() + + only_needed_deps = not inquirer.confirm( + message="Build everything (slower, disable --only-needed-deps)?", + default=False, + ).execute() + + container_name = prompt_for_session_name(branch if branch else None) + + return { + "mode": "new", + "container_name": container_name, + "repo_url": repo_url, + "branch": branch, + "only_needed_deps": only_needed_deps, + "ci_run_info": ci_run_info, + } + + +def select_fix_mode(): + """Let the user choose how Claude should fix CI failures. + + Returns (ci_run_info_or_none, custom_prompt_or_none). + """ + mode = inquirer.select( + message="How should Claude fix CI?", + choices=FIX_MODE_CHOICES, + default="fix_from_log", + ).execute() + + if mode == "fix_from_log": + return None, None + + if mode == "compare_ci_run": + ci_run_url = inquirer.text( + message="GitHub Actions run URL:", + validate=lambda url: "/runs/" in url, + invalid_message=( + "URL must contain /runs/ " + "(e.g. https://github.com/org/repo/actions/runs/12345)" + ), + ).execute() + return extract_info_from_ci_url(ci_run_url), None + + custom_prompt = inquirer.text( + message="Enter your custom prompt for Claude:" + ).execute() + return None, custom_prompt + + +def build_analysis_prompt(ci_run_info): + """Build the analysis prompt, optionally including CI compare context.""" + if ci_run_info: + extra_context = CI_COMPARE_EXTRA_CONTEXT_TEMPLATE.format( + **ci_run_info + ) + else: + extra_context = "" + return ANALYSIS_PROMPT_TEMPLATE.format(extra_context=extra_context) + + +def run_claude_streamed(container_name, prompt): + """Run Claude non-interactively with stream-json output.""" + escaped_prompt = prompt.replace("'", "'\\''") + claude_command = ( + f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " + f"-p '{escaped_prompt}' --verbose --output-format stream-json " + f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" + ) + docker_exec(container_name, claude_command, check=False) + + +def run_claude_resumed(container_name, session_id, prompt): + """Resume a Claude session with a new prompt, streaming output.""" + escaped_prompt = prompt.replace("'", "'\\''") + claude_command = ( + f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " + f"--resume '{session_id}' -p '{escaped_prompt}' " + f"--verbose --output-format stream-json " + f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" + ) + docker_exec(container_name, claude_command, check=False) + + +def prompt_user_for_feedback(): + """Ask user to review Claude's analysis and provide corrections.""" + feedback = inquirer.text( + message=( + "Review the analysis above. " + "Provide corrections or context (Enter to accept as-is):" + ), + default="", + ).execute().strip() + if not feedback: + return "Analysis looks correct, proceed with fixing." + return feedback + + +def refresh_claude_config(container_name): + """Refresh Claude config in an existing container.""" + console.print( + "[green]Claude already installed \u2014 refreshing config...[/green]" + ) + copy_claude_credentials(container_name) + copy_ci_context(container_name) + copy_display_script(container_name) + inject_resume_function(container_name) + inject_rerun_tests_function(container_name) + + +def run_claude_workflow(container_name, ci_run_info): + """Run the Claude analysis -> feedback -> fix workflow.""" + if ci_run_info: + custom_prompt = None + else: + ci_run_info, custom_prompt = select_fix_mode() + + if custom_prompt: + console.print( + "\n[bold cyan]Launching Claude Code (custom prompt)...[/bold cyan]" + ) + run_claude_streamed(container_name, custom_prompt) + else: + # Analysis phase + analysis_prompt = build_analysis_prompt(ci_run_info) + console.print( + "\n[bold cyan]Launching Claude Code " + "\u2014 analysis phase...[/bold cyan]" + ) + console.print( + "[dim]Claude will analyse failures before " + "attempting fixes[/dim]\n" + ) + run_claude_streamed(container_name, analysis_prompt) + + # User review + console.print() + user_feedback = prompt_user_for_feedback() + + # Fix phase (resume session) + state = read_container_state(container_name) + session_id = state["session_id"] if state else None + if session_id: + console.print( + "\n[bold cyan]Resuming Claude " + "\u2014 fix phase...[/bold cyan]" + ) + console.print( + "[dim]Claude will now fix the failures[/dim]\n" + ) + fix_prompt = FIX_PROMPT_TEMPLATE.format( + user_feedback=user_feedback + ) + run_claude_resumed(container_name, session_id, fix_prompt) + else: + console.print( + "\n[yellow]No session ID from analysis phase \u2014 " + "cannot resume. Dropping to shell.[/yellow]" + ) + + # Show outcome + state = read_container_state(container_name) + if state: + phase = state.get("phase", "unknown") + session_id = state.get("session_id") + attempt = state.get("attempt_count", 1) + console.print( + f"\n[bold]Claude finished \u2014 " + f"phase: {phase}, attempt: {attempt}[/bold]" + ) + if session_id: + console.print(f"[dim]Session ID: {session_id}[/dim]") + else: + console.print( + "\n[yellow]Could not read state file from container[/yellow]" + ) + + +def drop_to_shell(container_name): + """Drop user into an interactive container shell.""" + console.print("\n[bold green]Dropping into container shell.[/bold green]") + console.print("[cyan]Useful commands:[/cyan]") + console.print( + " [bold]rerun_tests[/bold] " + "\u2014 rebuild and re-run CI tests locally" + ) + console.print( + " [bold]resume_claude[/bold] " + "\u2014 resume the Claude session interactively" + ) + console.print(" [bold]git diff[/bold] \u2014 review changes") + console.print( + " [bold]git add && git commit[/bold] \u2014 commit fixes" + ) + console.print(" [dim]Repo is at /ros_ws/src/[/dim]\n") + docker_exec_interactive(container_name) + + +def fix_ci(args): + """Main fix workflow: gather -> preflight -> reproduce -> Claude -> shell. + + Args are accepted for backward compat but ignored (interactive only). + """ + console.print( + Panel("[bold cyan]CI Fix with Claude[/bold cyan]", expand=False) + ) + + # Step 1: Gather all session info up front + session = gather_session_info() + container_name = session["container_name"] + + if session["mode"] == "new": + # Step 2: Preflight checks + try: + gh_token = run_all_preflight_checks( + repo_url=session["repo_url"] + ) + except PreflightError as error: + console.print( + f"\n[bold red]Preflight failed:[/bold red] {error}" + ) + sys.exit(1) + + # Step 3: Reproduce CI in container + if container_exists(container_name): + remove_container(container_name) + reproduce_ci( + repo_url=session["repo_url"], + branch=session["branch"], + container_name=container_name, + gh_token=gh_token, + only_needed_deps=session["only_needed_deps"], + ) + save_package_list(container_name) + + # Step 4: Setup Claude in container + if is_claude_installed_in_container(container_name): + refresh_claude_config(container_name) + else: + setup_claude_in_container(container_name) + + # Step 5: Run Claude + resume_session_id = session.get("resume_session_id") + if resume_session_id: + console.print( + "\n[bold cyan]Resuming Claude session...[/bold cyan]" + ) + console.print( + "[dim]You are now in an interactive Claude session[/dim]\n" + ) + docker_exec( + container_name, + "cd /ros_ws && IS_SANDBOX=1 claude " + "--dangerously-skip-permissions " + f'--resume "{resume_session_id}"', + interactive=True, check=False, + ) + else: + run_claude_workflow( + container_name, session.get("ci_run_info") + ) + + # Step 6: Drop to shell + drop_to_shell(container_name) +``` + +**Step 2: Lint** + +Run: `source ~/.helper_bash_functions && cd /cortex/.catkin_ws/src/er_build_tools/bin/ci_tool && python3 -m pylint ci_fix.py --disable=all --enable=E` +Expected: No errors + +**Step 3: Commit** + +```bash +git add bin/ci_tool/ci_fix.py +git commit -m "refactor ci_fix.py: consolidated prompting and linear flow + +gather_session_info() collects all user input up front. fix_ci() is +now a linear sequence: gather -> preflight -> reproduce -> claude -> shell. +Fixes the missing repo/branch prompt when CI URL is left blank." +``` + +--- + +### Task 4: Adapt cli.py for new reproduce_ci interface + +**Files:** +- Modify: `bin/ci_tool/cli.py` + +**Step 1: Update `_handle_reproduce`** + +The `reproduce` CLI subcommand needs a thin adapter since `reproduce_ci` now takes explicit params. Replace `_handle_reproduce` (and add necessary imports): + +In `cli.py`, replace the `_handle_reproduce` function (lines 62-64): + +```python +def _handle_reproduce(args): + import os + from ci_tool.ci_reproduce import ( + reproduce_ci, + prompt_for_reproduce_args, + DEFAULT_SCRIPTS_BRANCH, + ) + from ci_tool.containers import ( + DEFAULT_CONTAINER_NAME, + container_exists, + container_is_running, + remove_container, + ) + from ci_tool.preflight import ( + validate_docker_available, + validate_gh_token, + PreflightError, + ) + + try: + validate_docker_available() + except PreflightError as error: + console.print(f"\n[bold red]Preflight failed:[/bold red] {error}") + sys.exit(1) + + repo_url, branch, only_needed_deps = prompt_for_reproduce_args() + container_name = DEFAULT_CONTAINER_NAME + + if container_exists(container_name): + from InquirerPy import inquirer + action = inquirer.select( + message=f"Container '{container_name}' already exists. What to do?", + choices=[ + {"name": "Remove and recreate", "value": "recreate"}, + {"name": "Keep existing (skip creation)", "value": "keep"}, + {"name": "Cancel", "value": "cancel"}, + ], + ).execute() + + if action == "cancel": + return + if action == "recreate": + remove_container(container_name) + if action == "keep": + if not container_is_running(container_name): + import subprocess + subprocess.run( + ["docker", "start", container_name], check=True + ) + console.print( + f"[green]Using existing container " + f"'{container_name}'[/green]" + ) + return + + try: + gh_token = validate_gh_token(repo_url=repo_url) + except PreflightError as error: + console.print(f"\n[bold red]Preflight failed:[/bold red] {error}") + sys.exit(1) + + reproduce_ci( + repo_url=repo_url, + branch=branch, + container_name=container_name, + gh_token=gh_token, + only_needed_deps=only_needed_deps, + ) +``` + +**Step 2: Lint** + +Run: `source ~/.helper_bash_functions && cd /cortex/.catkin_ws/src/er_build_tools/bin/ci_tool && python3 -m pylint cli.py --disable=all --enable=E` +Expected: No errors + +**Step 3: Commit** + +```bash +git add bin/ci_tool/cli.py +git commit -m "adapt cli.py reproduce handler for new reproduce_ci interface" +``` + +--- + +### Task 5: Update setup.sh — install ci_tool and hand-off + +**Files:** +- Modify: `bin/setup.sh` + +**Step 1: Add ci_tool install step and hand-off** + +After the existing Step 4 (Claude Code authentication) and before the "Done" section, add a new step 5 that installs the ci_tool Python package. Then change the "Done" section to exec into ci_tool. + +After line 118 (`fi` closing the Claude auth block), add: + +```bash +# --- Step 5: Install ci_tool --- + +echo "" +echo -e "${Bold}[5/5] Installing ci_tool...${Color_Off}" + +CI_TOOL_DIR="${HOME}/.ci_tool" +CI_TOOL_URL="${BASE_URL}/bin/ci_tool" + +if [ -d "${CI_TOOL_DIR}/ci_tool" ]; then + echo -e " ${Green}ci_tool already installed at ${CI_TOOL_DIR}${Color_Off}" + echo -e " ${Cyan}Updating...${Color_Off}" +fi + +mkdir -p "${CI_TOOL_DIR}" + +# Download ci_tool package files +CI_TOOL_FILES=( + "__init__.py" + "__main__.py" + "cli.py" + "ci_fix.py" + "ci_reproduce.py" + "claude_setup.py" + "claude_session.py" + "containers.py" + "preflight.py" + "display_progress.py" + "requirements.txt" +) + +mkdir -p "${CI_TOOL_DIR}/ci_tool/ci_context" +for file in "${CI_TOOL_FILES[@]}"; do + curl -fsSL "${CI_TOOL_URL}/${file}" -o "${CI_TOOL_DIR}/ci_tool/${file}" || { + echo -e " ${Red}Failed to download ${file}${Color_Off}" + exit 1 + } +done + +# Download CI context CLAUDE.md +curl -fsSL "${CI_TOOL_URL}/ci_context/CLAUDE.md" \ + -o "${CI_TOOL_DIR}/ci_tool/ci_context/CLAUDE.md" 2>/dev/null || true + +# Install Python dependencies +pip3 install --user --quiet -r "${CI_TOOL_DIR}/ci_tool/requirements.txt" 2>/dev/null || { + echo -e " ${Yellow}Some dependencies may not have installed. ci_tool will retry on first run.${Color_Off}" +} + +echo -e " ${Green}ci_tool installed at ${CI_TOOL_DIR}${Color_Off}" +``` + +Then update the "Done" section to hand off: + +```bash +# --- Done --- + +echo "" +echo -e "${Bold}${Green}Setup complete!${Color_Off}" +echo "" +echo -e " Reload your shell or run:" +echo -e " ${Bold}source ~/.helper_bash_functions${Color_Off}" +echo "" + +# Source helper functions so GH_TOKEN is available for ci_tool +source "${HELPER_PATH}" 2>/dev/null || true + +echo -e " ${Bold}${Cyan}Launching ci_tool...${Color_Off}" +echo "" +exec python3 "${CI_TOOL_DIR}/ci_tool/__main__.py" +``` + +**Step 2: Update step numbering** + +Change step header counts from `[1/4]`, `[2/4]`, `[3/4]`, `[4/4]` to `[1/5]`, `[2/5]`, `[3/5]`, `[4/5]`. + +**Step 3: Commit** + +```bash +git add bin/setup.sh +git commit -m "setup.sh: install ci_tool and hand off after setup" +``` + +--- + +### Task 6: Lint all changed files + +**Files:** +- All modified Python files + +**Step 1: Run linters on all changed files** + +Run: `source ~/.helper_bash_functions && cd /cortex/.catkin_ws/src/er_build_tools/bin/ci_tool && er_python_linters_here` +Expected: No errors or warnings. Fix any that appear. + +**Step 2: Commit lint fixes if any** + +```bash +git add -A bin/ci_tool/ +git commit -m "fix lint issues from rearchitect" +``` + +--- + +### Task 7: Manual integration test + +**Step 1: Test new session without CI URL** + +Run: `python3 -m ci_tool` +Select: "Fix CI with Claude" +Leave CI URL blank. +Expected: Prompted for "Repository URL:" and "Branch name:" (the core bug fix). +Enter valid repo/branch. Verify preflight runs with repo validation, container is created, workspace setup runs. + +**Step 2: Test new session with CI URL** + +Run: `python3 -m ci_tool` +Select: "Fix CI with Claude" +Enter a valid GitHub Actions URL. +Expected: Repo/branch auto-extracted, not prompted again. Preflight validates repo. Full flow works. + +**Step 3: Test resume existing session** + +Run: `python3 -m ci_tool` (with existing container from previous test) +Select existing container from resume menu. +Expected: Container starts, Claude resume/fresh choice shown, flow continues without reproduction step. + +**Step 4: Test standalone reproduce** + +Run: `python3 -m ci_tool` +Select: "Reproduce CI (create container)" +Expected: Prompted for repo, branch, only-needed-deps. Container created via Python Docker orchestration (no bash wrapper chain). + +**Step 5: Test failure guard** + +Intentionally provide a bad repo URL (e.g. `https://github.com/fake/nonexistent`). +Expected: Preflight fails with clear error about repo access. Tool exits cleanly, no cascade of "No such container" errors. From 6fc9b9bba3624412dcef87820a15c5042ba0d29e Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 15:54:01 +0000 Subject: [PATCH 22/60] remove unused rename_container from containers.py Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/containers.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bin/ci_tool/containers.py b/bin/ci_tool/containers.py index c4ef496..0e1c111 100644 --- a/bin/ci_tool/containers.py +++ b/bin/ci_tool/containers.py @@ -77,11 +77,6 @@ def list_ci_containers(): return containers -def rename_container(old_name, new_name): - """Rename a Docker container.""" - run_command(["docker", "rename", old_name, new_name]) - - def sanitize_container_name(name): """Replace characters invalid for Docker container names with underscores.""" return re.sub(r'[^a-zA-Z0-9_.-]', '_', name) From 08831fa9d3d6c59a32d3d6dbc2a0b8742b4e05b0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 15:56:11 +0000 Subject: [PATCH 23/60] rewrite ci_reproduce.py with Python Docker orchestration Replace bash wrapper chain with direct Python Docker orchestration: - reproduce_ci() takes explicit params instead of args list - Fetch internal scripts via urllib with auth header - Validate deps.repos reachable before creating container - Docker create/start/exec directly from Python - Container existence guard after execution - prompt_for_reproduce_args() returns (repo_url, branch, only_needed_deps) tuple - Rich output with checkmarks and colors - Graceful KeyboardInterrupt handling during workspace setup Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_reproduce.py | 348 ++++++++++++++++++++++++++++-------- 1 file changed, 269 insertions(+), 79 deletions(-) diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index ae0fb4c..33addcc 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -1,53 +1,216 @@ #!/usr/bin/env python3 -"""Reproduce CI locally by creating a Docker container.""" +"""Reproduce CI locally by creating a Docker container with Python Docker orchestration.""" from __future__ import annotations import os import subprocess -import sys +import tempfile +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen from InquirerPy import inquirer from rich.console import Console +from rich.panel import Panel from ci_tool.containers import ( DEFAULT_CONTAINER_NAME, container_exists, container_is_running, remove_container, + run_command, + start_container, ) from ci_tool.preflight import validate_docker_available, validate_gh_token, PreflightError console = Console() +DEFAULT_DOCKER_IMAGE = "rostooling/setup-ros-docker:ubuntu-focal-ros-noetic-desktop-latest" DEFAULT_SCRIPTS_BRANCH = "ERD-1633_reproduce_ci_locally" +INTERNAL_REPO = "Extend-Robotics/er_build_tools_internal" +CONTAINER_SETUP_SCRIPT_PATH = "/tmp/ci_workspace_setup.sh" +CONTAINER_RETEST_SCRIPT_PATH = "/tmp/ci_repull_and_retest.sh" -def extract_repo_url_from_args(args): - """Extract --repo/-r value from args list, or return None.""" - for i, arg in enumerate(args): - if arg in ("--repo", "-r") and i + 1 < len(args): - return args[i + 1] - return None +def _parse_repo_url(repo_url): + """Extract org and repo name from a GitHub URL. + Returns (org, repo_name, clean_url) tuple. + """ + clean_url = repo_url.rstrip("/").removesuffix(".git") + repo_path = clean_url.removeprefix("https://github.com/") + parts = repo_path.split("/") + if len(parts) != 2 or not all(parts): + raise ValueError(f"Cannot parse org/repo from URL: {repo_url}") + return parts[0], parts[1], clean_url -def extract_branch_from_args(args): - """Extract --branch/-b value from args list, or return None.""" - for i, arg in enumerate(args): - if arg in ("--branch", "-b") and i + 1 < len(args): - return args[i + 1] - return None +def _fetch_github_raw_file(repo, file_path, branch, gh_token): + """Fetch a file from a GitHub repo via raw.githubusercontent.com. -def extract_container_name_from_args(args): - """Extract --container-name/-n value from args list, or return None.""" - for i, arg in enumerate(args): - if arg in ("--container-name", "-n") and i + 1 < len(args): - return args[i + 1] - return None + Returns the file content as a string. + Raises RuntimeError if the file cannot be fetched. + """ + url = f"https://raw.githubusercontent.com/{repo}/refs/heads/{branch}/{file_path}" + request = Request(url, headers={"Authorization": f"token {gh_token}"}) + try: + with urlopen(request, timeout=15) as response: + return response.read().decode() + except HTTPError as error: + raise RuntimeError( + f"Failed to fetch {file_path} from {repo} branch '{branch}' " + f"(HTTP {error.code}). Check the branch exists and your token has access." + ) from error + except URLError as error: + raise RuntimeError( + f"Cannot reach GitHub to fetch {file_path}: {error.reason}" + ) from error + + +def _validate_deps_repos_reachable(org, repo_name, branch, gh_token, deps_file="deps.repos"): + """Validate that deps.repos is reachable at the target branch before creating the container.""" + branch_for_raw = branch or "main" + deps_url = ( + f"https://raw.githubusercontent.com/{org}/{repo_name}/" + f"{branch_for_raw}/{deps_file}" + ) + console.print(f" Validating {deps_file} is reachable at: [dim]{deps_url}[/dim]") + request = Request(deps_url, headers={"Authorization": f"token {gh_token}"}) + try: + with urlopen(request, timeout=10) as response: + http_code = response.getcode() + except HTTPError as error: + http_code = error.code + hints = [ + f"Could not reach {deps_file} (HTTP {http_code})", + f"Check that '{org}/{repo_name}' exists and your token has access", + f"Check that '{deps_file}' exists at ref '{branch_for_raw}'", + ] + if branch: + hints.insert(1, f"Branch/commit '{branch}' may not exist in '{org}/{repo_name}'") + raise RuntimeError("\n ".join(hints)) from error + except URLError as error: + raise RuntimeError(f"Cannot reach GitHub to validate {deps_file}: {error.reason}") from error + + console.print(f" [green]\u2713[/green] Validation passed (HTTP {http_code})") + + +def _fetch_internal_scripts(gh_token, scripts_branch): + """Fetch ci_workspace_setup.sh and ci_repull_and_retest.sh from er_build_tools_internal. + + Returns (setup_script_path, retest_script_path) as temporary file paths on the host. + """ + console.print(f" Fetching CI scripts from [cyan]{INTERNAL_REPO}[/cyan] branch [cyan]{scripts_branch}[/cyan]") + + setup_content = _fetch_github_raw_file( + INTERNAL_REPO, "bin/ci_workspace_setup.sh", scripts_branch, gh_token, + ) + retest_content = _fetch_github_raw_file( + INTERNAL_REPO, "bin/ci_repull_and_retest.sh", scripts_branch, gh_token, + ) + + script_dir = tempfile.mkdtemp(prefix="ci_reproduce_scripts_") + setup_script_host_path = os.path.join(script_dir, "ci_workspace_setup.sh") + retest_script_host_path = os.path.join(script_dir, "ci_repull_and_retest.sh") + + with open(setup_script_host_path, "w", encoding="utf-8") as script_file: + script_file.write(setup_content) + os.chmod(setup_script_host_path, 0o755) + + with open(retest_script_host_path, "w", encoding="utf-8") as script_file: + script_file.write(retest_content) + os.chmod(retest_script_host_path, 0o755) + + console.print(" [green]\u2713[/green] Scripts fetched and saved to temp directory") + return setup_script_host_path, retest_script_host_path + + +def _build_graphical_docker_args(): + """Build Docker args for X11/NVIDIA graphical forwarding. + + Raises RuntimeError if DISPLAY is not set. + """ + display = os.environ.get("DISPLAY") + if not display: + raise RuntimeError( + "Graphical mode requires DISPLAY to be set (X11 forwarding). " + "Set DISPLAY or pass graphical=False." + ) + + console.print(" Enabling graphical forwarding (X11 + NVIDIA)...") + subprocess.run(["xhost", "+local:"], check=False, capture_output=True) + + return [ + "--runtime", "nvidia", + "--gpus", "all", + "--privileged", + "--security-opt", "seccomp=unconfined", + "-v", "/tmp/.X11-unix:/tmp/.X11-unix:rw", + "-e", f"DISPLAY={display}", + "-e", "QT_X11_NO_MITSHM=1", + "-e", "NVIDIA_DRIVER_CAPABILITIES=all", + "-e", "NVIDIA_VISIBLE_DEVICES=all", + ] + + +def _docker_create_and_start( + container_name, + docker_image, + env_vars, + volume_mounts, + graphical_args, +): + """Create and start a Docker container with the given configuration.""" + create_command = ["docker", "create", "--name", container_name] + create_command.extend(["--network=host", "--ipc=host"]) + + for volume_mount in volume_mounts: + create_command.extend(["-v", volume_mount]) + + for env_key, env_value in env_vars.items(): + create_command.extend(["-e", f"{env_key}={env_value}"]) + + create_command.extend(graphical_args) + create_command.extend([docker_image, "sleep", "infinity"]) + + console.print(f"\n Creating container [cyan]'{container_name}'[/cyan]...") + run_command(create_command, quiet=True) + console.print(f" [green]\u2713[/green] Container '{container_name}' created") + + console.print(f" Starting container [cyan]'{container_name}'[/cyan]...") + run_command(["docker", "start", container_name], quiet=True) + console.print(f" [green]\u2713[/green] Container '{container_name}' started") + + +def _docker_exec_workspace_setup(container_name): + """Run ci_workspace_setup.sh inside the container. + + Handles KeyboardInterrupt gracefully by letting the container keep running. + """ + console.print("\n Running CI workspace setup inside container...") + try: + result = subprocess.run( + ["docker", "exec", container_name, "bash", CONTAINER_SETUP_SCRIPT_PATH], + check=False, + ) + except KeyboardInterrupt: + console.print( + "\n[yellow]Interrupted during workspace setup " + "-- container is still running with partial setup[/yellow]" + ) + return + + if result.returncode != 0: + console.print( + f"\n[yellow]Workspace setup exited with code {result.returncode} " + f"(expected if tests failed)[/yellow]" + ) def prompt_for_reproduce_args(): - """Interactively ask user for the required reproduce arguments.""" + """Interactively ask user for the required reproduce arguments. + + Returns (repo_url, branch, only_needed_deps) tuple. + """ repo_url = inquirer.text( message="Repository URL:", validate=lambda url: url.startswith("https://github.com/"), @@ -65,25 +228,43 @@ def prompt_for_reproduce_args(): default=False, ).execute() - args = ["-r", repo_url, "-b", branch] - if not build_everything: - args.append("--only-needed-deps") - return args + only_needed_deps = not build_everything + return repo_url, branch, only_needed_deps -def reproduce_ci(args, skip_preflight=False): - """Create a CI reproduction container.""" +def reproduce_ci( + repo_url, + branch, + container_name=DEFAULT_CONTAINER_NAME, + gh_token=None, + only_needed_deps=True, + scripts_branch=DEFAULT_SCRIPTS_BRANCH, + graphical=True, + skip_preflight=False, +): + """Create a CI reproduction container using direct Docker orchestration. + + Fetches container-side scripts from er_build_tools_internal, validates + deps.repos is reachable, creates and starts a Docker container, then + runs workspace setup inside it. + """ if not skip_preflight: try: validate_docker_available() - repo_url = extract_repo_url_from_args(args) validate_gh_token(repo_url=repo_url) except PreflightError as error: - console.print(f"\n[bold red]Preflight failed:[/bold red] {error}") - sys.exit(1) + raise RuntimeError(f"Preflight failed: {error}") from error + + if gh_token is None: + gh_token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") or "" + if not gh_token: + raise RuntimeError( + "No GitHub token found. Set GH_TOKEN or ER_SETUP_TOKEN environment variable." + ) - container_name = extract_container_name_from_args(args) or DEFAULT_CONTAINER_NAME + console.print(Panel("[bold cyan]Reproduce CI[/bold cyan]", expand=False)) + # Handle existing container if container_exists(container_name): action = inquirer.select( message=f"Container '{container_name}' already exists. What to do?", @@ -100,60 +281,69 @@ def reproduce_ci(args, skip_preflight=False): remove_container(container_name) if action == "keep": if not container_is_running(container_name): - subprocess.run(["docker", "start", container_name], check=True) - console.print(f"[green]Using existing container '{container_name}'[/green]") + start_container(container_name) + console.print(f"[green]\u2713 Using existing container '{container_name}'[/green]") return - if not args: - args = prompt_for_reproduce_args() - - token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") or "" - if not token: - console.print("[red]No GitHub token found. Set GH_TOKEN or ER_SETUP_TOKEN.[/red]") - sys.exit(1) - scripts_branch = DEFAULT_SCRIPTS_BRANCH - - filtered_args = [] - i = 0 - while i < len(args): - if args[i] in ("--scripts-branch", "--scripts_branch"): - scripts_branch = args[i + 1] - i += 2 - else: - filtered_args.append(args[i]) - i += 1 - - wrapper_url = ( - f"https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/" - f"refs/heads/{scripts_branch}/bin/reproduce_ci.sh" + # Parse repo URL + org, repo_name, clean_repo_url = _parse_repo_url(repo_url) + console.print(f" Organization: [cyan]{org}[/cyan]") + console.print(f" Repository: [cyan]{repo_name}[/cyan]") + + # Validate deps.repos is reachable before doing anything expensive + _validate_deps_repos_reachable(org, repo_name, branch, gh_token) + + # Fetch internal scripts + setup_script_host_path, retest_script_host_path = _fetch_internal_scripts( + gh_token, scripts_branch, ) - console.print(f"[cyan]Fetching CI scripts from branch: {scripts_branch}[/cyan]") + # Build graphical args + graphical_docker_args = [] + if graphical: + try: + graphical_docker_args = _build_graphical_docker_args() + except RuntimeError: + console.print( + " [yellow]DISPLAY not set -- skipping graphical forwarding[/yellow]" + ) + + # Environment variables for the container-side scripts + container_env_vars = { + "GH_TOKEN": gh_token, + "REPO_URL": clean_repo_url, + "REPO_NAME": repo_name, + "ORG": org, + "DEPS_FILE": "deps.repos", + "BRANCH": branch or "", + "ONLY_NEEDED_DEPS": "true" if only_needed_deps else "false", + "SKIP_TESTS": "false", + "ADDITIONAL_COMMAND": "", + } - full_args = [ - "--gh-token", token, - "--scripts-branch", scripts_branch, - ] + filtered_args + # Volume mounts (scripts mounted read-only into the container) + volume_mounts = [ + f"{setup_script_host_path}:{CONTAINER_SETUP_SCRIPT_PATH}:ro", + f"{retest_script_host_path}:{CONTAINER_RETEST_SCRIPT_PATH}:ro", + ] - fetch_result = subprocess.run( - ["curl", "-fSL", wrapper_url], - capture_output=True, text=True, check=True, + # Create and start container + _docker_create_and_start( + container_name, + DEFAULT_DOCKER_IMAGE, + container_env_vars, + volume_mounts, + graphical_docker_args, ) - try: - result = subprocess.run( - ["bash", "-c", fetch_result.stdout + '\n"$@"', "--"] + full_args, - check=False, - ) - except KeyboardInterrupt: - console.print( - "\n[yellow]Interrupted — continuing with whatever test output " - "was captured[/yellow]" + # Run workspace setup + _docker_exec_workspace_setup(container_name) + + # Container existence guard + if not container_exists(container_name): + raise RuntimeError( + f"Container '{container_name}' does not exist after execution. " + "Docker create or start may have failed silently." ) - else: - if result.returncode != 0: - console.print( - f"\n[yellow]CI reproduction exited with code {result.returncode} " - f"(expected — tests likely failed)[/yellow]" - ) - console.print(f"\n[green]Container '{container_name}' is ready[/green]") + + console.print(f"\n[green]\u2713 Container '{container_name}' is ready[/green]") From 75f01128d403ff04899d79c2ad7ec590ee569372 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 15:58:41 +0000 Subject: [PATCH 24/60] fix spec review issues in ci_reproduce.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove graphical fallback: fail fast when DISPLAY not set (matches bash) - Remove skip_preflight param (YAGNI — callers handle preflight) - Standardize URL format (remove refs/heads/ prefix) Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_reproduce.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index 33addcc..90aa259 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -20,7 +20,6 @@ run_command, start_container, ) -from ci_tool.preflight import validate_docker_available, validate_gh_token, PreflightError console = Console() @@ -50,7 +49,7 @@ def _fetch_github_raw_file(repo, file_path, branch, gh_token): Returns the file content as a string. Raises RuntimeError if the file cannot be fetched. """ - url = f"https://raw.githubusercontent.com/{repo}/refs/heads/{branch}/{file_path}" + url = f"https://raw.githubusercontent.com/{repo}/{branch}/{file_path}" request = Request(url, headers={"Authorization": f"token {gh_token}"}) try: with urlopen(request, timeout=15) as response: @@ -240,21 +239,15 @@ def reproduce_ci( only_needed_deps=True, scripts_branch=DEFAULT_SCRIPTS_BRANCH, graphical=True, - skip_preflight=False, ): """Create a CI reproduction container using direct Docker orchestration. Fetches container-side scripts from er_build_tools_internal, validates deps.repos is reachable, creates and starts a Docker container, then runs workspace setup inside it. - """ - if not skip_preflight: - try: - validate_docker_available() - validate_gh_token(repo_url=repo_url) - except PreflightError as error: - raise RuntimeError(f"Preflight failed: {error}") from error + Callers are responsible for running preflight checks before calling this. + """ if gh_token is None: gh_token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") or "" if not gh_token: @@ -298,15 +291,10 @@ def reproduce_ci( gh_token, scripts_branch, ) - # Build graphical args + # Build graphical args (fail fast if DISPLAY not set) graphical_docker_args = [] if graphical: - try: - graphical_docker_args = _build_graphical_docker_args() - except RuntimeError: - console.print( - " [yellow]DISPLAY not set -- skipping graphical forwarding[/yellow]" - ) + graphical_docker_args = _build_graphical_docker_args() # Environment variables for the container-side scripts container_env_vars = { From 5f269495cc136ba4d0a7a046ab410d06b1ab08bd Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 16:01:45 +0000 Subject: [PATCH 25/60] address code quality review feedback in ci_reproduce.py - Validate URL prefix before parsing org/repo - Warn on xhost failure instead of silent swallow - Document bind-mount temp dir persistence - Clarify container guard comment Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_reproduce.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index 90aa259..ec3bad8 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -35,6 +35,10 @@ def _parse_repo_url(repo_url): Returns (org, repo_name, clean_url) tuple. """ + if not repo_url.startswith("https://github.com/"): + raise ValueError( + f"URL must start with https://github.com/, got: {repo_url}" + ) clean_url = repo_url.rstrip("/").removesuffix(".git") repo_path = clean_url.removeprefix("https://github.com/") parts = repo_path.split("/") @@ -107,6 +111,8 @@ def _fetch_internal_scripts(gh_token, scripts_branch): INTERNAL_REPO, "bin/ci_repull_and_retest.sh", scripts_branch, gh_token, ) + # Scripts are bind-mounted into the container, so they must persist on the + # host for the container's lifetime. Temp dir is not cleaned up here. script_dir = tempfile.mkdtemp(prefix="ci_reproduce_scripts_") setup_script_host_path = os.path.join(script_dir, "ci_workspace_setup.sh") retest_script_host_path = os.path.join(script_dir, "ci_repull_and_retest.sh") @@ -136,7 +142,11 @@ def _build_graphical_docker_args(): ) console.print(" Enabling graphical forwarding (X11 + NVIDIA)...") - subprocess.run(["xhost", "+local:"], check=False, capture_output=True) + xhost_result = subprocess.run( + ["xhost", "+local:"], check=False, capture_output=True + ) + if xhost_result.returncode != 0: + console.print(" [yellow]xhost +local: failed — graphical forwarding may not work[/yellow]") return [ "--runtime", "nvidia", @@ -327,11 +337,10 @@ def reproduce_ci( # Run workspace setup _docker_exec_workspace_setup(container_name) - # Container existence guard + # Safety net: verify the container survived workspace setup if not container_exists(container_name): raise RuntimeError( - f"Container '{container_name}' does not exist after execution. " - "Docker create or start may have failed silently." + f"Container '{container_name}' does not exist after workspace setup." ) console.print(f"\n[green]\u2713 Container '{container_name}' is ready[/green]") From 095355377fb35f8695192077a17608d30023f646 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 16:05:31 +0000 Subject: [PATCH 26/60] refactor ci_fix.py: consolidated prompting and linear flow gather_session_info() collects all user input up front. fix_ci() is now a linear sequence: gather -> preflight -> reproduce -> claude -> shell. Fixes the missing repo/branch prompt when CI URL is left blank. Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_fix.py | 488 ++++++++++++++++++++++++++---------------- 1 file changed, 301 insertions(+), 187 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index a2c17a5..9946ecf 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -13,14 +13,14 @@ from rich.panel import Panel from ci_tool.claude_setup import ( - setup_claude_in_container, - is_claude_installed_in_container, - copy_claude_credentials, copy_ci_context, + copy_claude_credentials, copy_display_script, - inject_resume_function, inject_rerun_tests_function, + inject_resume_function, + is_claude_installed_in_container, save_package_list, + setup_claude_in_container, ) from ci_tool.containers import ( container_exists, @@ -32,11 +32,7 @@ sanitize_container_name, start_container, ) -from ci_tool.ci_reproduce import ( - reproduce_ci, - extract_branch_from_args, - extract_repo_url_from_args, -) +from ci_tool.ci_reproduce import reproduce_ci from ci_tool.preflight import run_all_preflight_checks, PreflightError console = Console() @@ -55,7 +51,8 @@ ROS_SOURCE_PREAMBLE = ( "You are inside a CI reproduction container at /ros_ws. " "Source the ROS workspace: " - "`source /opt/ros/noetic/setup.bash && source /ros_ws/install/setup.bash`.\n\n" + "`source /opt/ros/noetic/setup.bash && source /ros_ws/install/setup.bash`." + "\n\n" ) ANALYSIS_PROMPT_TEMPLATE = ( @@ -75,8 +72,10 @@ "\nAlso investigate the CI run: {ci_run_url}\n" "- Verify local and CI are on the same commit:\n" " - Local: check HEAD in the repo under /ros_ws/src/\n" - " - CI: `gh api repos/{owner_repo}/actions/runs/{run_id} --jq '.head_sha'`\n" - " - If they differ, determine whether the missing/extra commits explain the failure\n" + " - CI: `gh api repos/{owner_repo}/actions/runs/{run_id}" + " --jq '.head_sha'`\n" + " - If they differ, determine whether the missing/extra commits " + "explain the failure\n" "- Fetch CI logs: `gh run view {run_id} --log-failed` " "(use `--log` for full output if needed)\n" "- Compare CI failures with local test results\n" @@ -97,11 +96,14 @@ {"name": "Custom prompt", "value": "custom"}, ] +CLAUDE_STDERR_LOG = "/ros_ws/.claude_stderr.log" + def read_container_state(container_name): """Read the ci_fix state file from a container. Returns dict or None.""" result = subprocess.run( - ["docker", "exec", container_name, "cat", "/ros_ws/.ci_fix_state.json"], + ["docker", "exec", container_name, + "cat", "/ros_ws/.ci_fix_state.json"], capture_output=True, text=True, check=False, ) if result.returncode != 0: @@ -129,7 +131,7 @@ def extract_run_id_from_url(ci_run_url): def extract_info_from_ci_url(ci_run_url): - """Extract repo URL, branch, and run ID from a GitHub Actions run URL via the API.""" + """Extract repo URL, branch, and run ID from a GitHub Actions URL.""" run_id = extract_run_id_from_url(ci_run_url) owner_repo = ci_run_url.split("github.com/")[1].split("/actions/")[0] @@ -137,9 +139,13 @@ def extract_info_from_ci_url(ci_run_url): token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") if not token: - raise ValueError("No GitHub token found (GH_TOKEN or ER_SETUP_TOKEN)") + raise ValueError( + "No GitHub token found (GH_TOKEN or ER_SETUP_TOKEN)" + ) - api_url = f"https://api.github.com/repos/{owner_repo}/actions/runs/{run_id}" + api_url = ( + f"https://api.github.com/repos/{owner_repo}/actions/runs/{run_id}" + ) request = Request(api_url, headers={ "Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json", @@ -156,39 +162,11 @@ def extract_info_from_ci_url(ci_run_url): } -def select_fix_mode(): - """Let the user choose how Claude should fix CI failures. +def prompt_for_session_name(branch_hint=None): + """Ask user for a session name. Returns full container name (er_ci_). - Returns (ci_run_info_or_none, custom_prompt_or_none). + Exits if the container already exists. """ - mode = inquirer.select( - message="How should Claude fix CI?", - choices=FIX_MODE_CHOICES, - default="fix_from_log", - ).execute() - - if mode == "fix_from_log": - return None, None - - if mode == "compare_ci_run": - ci_run_url = inquirer.text( - message="GitHub Actions run URL:", - validate=lambda url: "/runs/" in url, - invalid_message="URL must contain /runs/ (e.g. https://github.com/org/repo/actions/runs/12345)", - ).execute() - return extract_info_from_ci_url(ci_run_url), None - - custom_prompt = inquirer.text(message="Enter your custom prompt for Claude:").execute() - return None, custom_prompt - - -def parse_fix_args(args): - """Parse fix-specific arguments, separating them from reproduce args.""" - return {"reproduce_args": list(args)} - - -def prompt_for_session_name(branch_hint=None): - """Ask the user for a session name. Returns full container name (er_ci_).""" default = sanitize_container_name(branch_hint) if branch_hint else "" name = inquirer.text( message="Session name (used for container naming):", @@ -209,11 +187,58 @@ def prompt_for_session_name(branch_hint=None): return container_name -def select_or_create_session(parsed): - """Let user resume an existing session or start a new one. +def _prompt_resume_session(container_name): + """Prompt user to resume or start fresh in an existing container. + + Returns a resume dict for gather_session_info(). + """ + if not container_is_running(container_name): + start_container(container_name) + + resume_session_id = None + state = read_container_state(container_name) + if state and state.get("session_id"): + session_id = state["session_id"] + phase = state.get("phase", "unknown") + attempt = state.get("attempt_count", 0) + console.print( + f" [dim]Previous session: {phase} " + f"(attempt {attempt}, id: {session_id})[/dim]" + ) + + resume_choice = inquirer.select( + message=( + "Resume previous Claude session or start fresh?" + ), + choices=[ + { + "name": f"Resume session ({phase})", + "value": "resume", + }, + { + "name": "Start fresh fix attempt", + "value": "fresh", + }, + ], + ).execute() + + if resume_choice == "resume": + resume_session_id = session_id + + return { + "mode": "resume", + "container_name": container_name, + "resume_session_id": resume_session_id, + } + - Returns (container_name, ci_run_info, needs_reproduce, resume_session_id). - Mutates parsed["reproduce_args"] if CI URL is provided. +def gather_session_info(): + """Collect all session information up front via interactive prompts. + + Returns a dict with 'mode' key: + - mode='new': container_name, repo_url, branch, only_needed_deps, + ci_run_info (or None) + - mode='resume': container_name, resume_session_id (or None) """ existing = list_ci_containers() @@ -221,7 +246,9 @@ def select_or_create_session(parsed): choices = [{"name": "Start new session", "value": "_new"}] for container in existing: choices.append({ - "name": f"Resume '{container['name']}' ({container['status']})", + "name": ( + f"Resume '{container['name']}' ({container['status']})" + ), "value": container["name"], }) @@ -231,33 +258,9 @@ def select_or_create_session(parsed): ).execute() if selection != "_new": - if not container_is_running(selection): - start_container(selection) - - state = read_container_state(selection) - if state and state.get("session_id"): - session_id = state["session_id"] - phase = state.get("phase", "unknown") - attempt = state.get("attempt_count", 0) - console.print( - f" [dim]Previous session: {phase} " - f"(attempt {attempt}, id: {session_id})[/dim]" - ) - - resume_choice = inquirer.select( - message="Resume previous Claude session or start fresh?", - choices=[ - {"name": f"Resume session ({phase})", "value": "resume"}, - {"name": "Start fresh fix attempt", "value": "fresh"}, - ], - ).execute() - - if resume_choice == "resume": - return selection, None, False, session_id - - return selection, None, False, None - - # New session: ask for optional CI URL + return _prompt_resume_session(selection) + + # New session: collect all info up front ci_run_info = None ci_run_url = inquirer.text( message="GitHub Actions run URL (leave blank to skip):", @@ -266,34 +269,85 @@ def select_or_create_session(parsed): if ci_run_url: ci_run_info = extract_info_from_ci_url(ci_run_url) - console.print(f" [green]Repo:[/green] {ci_run_info['repo_url']}") - console.print(f" [green]Branch:[/green] {ci_run_info['branch']}") + repo_url = ci_run_info["repo_url"] + branch = ci_run_info["branch"] + console.print(f" [green]Repo:[/green] {repo_url}") + console.print(f" [green]Branch:[/green] {branch}") console.print(f" [green]Run ID:[/green] {ci_run_info['run_id']}") - parsed["reproduce_args"] = [ - "-r", ci_run_info["repo_url"], - "-b", ci_run_info["branch"], - "--only-needed-deps", - ] + else: + repo_url = inquirer.text( + message="Repository URL:", + validate=lambda url: url.startswith("https://github.com/"), + invalid_message="Must be a GitHub URL (https://github.com/...)", + ).execute() + + branch = inquirer.text( + message="Branch name:", + validate=lambda b: len(b.strip()) > 0, + invalid_message="Branch name cannot be empty", + ).execute() + + only_needed_deps = not inquirer.confirm( + message="Build everything (slower, disable --only-needed-deps)?", + default=False, + ).execute() + + container_name = prompt_for_session_name(branch) + + return { + "mode": "new", + "container_name": container_name, + "repo_url": repo_url, + "branch": branch, + "only_needed_deps": only_needed_deps, + "ci_run_info": ci_run_info, + } + + +def select_fix_mode(): + """Let the user choose how Claude should fix CI failures. + + Returns (ci_run_info_or_none, custom_prompt_or_none). + """ + mode = inquirer.select( + message="How should Claude fix CI?", + choices=FIX_MODE_CHOICES, + default="fix_from_log", + ).execute() + + if mode == "fix_from_log": + return None, None + + if mode == "compare_ci_run": + ci_run_url = inquirer.text( + message="GitHub Actions run URL:", + validate=lambda url: "/runs/" in url, + invalid_message=( + "URL must contain /runs/ " + "(e.g. https://github.com/org/repo/actions/runs/12345)" + ), + ).execute() + return extract_info_from_ci_url(ci_run_url), None - branch_hint = ci_run_info["branch"] if ci_run_info else None - container_name = prompt_for_session_name(branch_hint) - return container_name, ci_run_info, True, None + custom_prompt = inquirer.text( + message="Enter your custom prompt for Claude:" + ).execute() + return None, custom_prompt def build_analysis_prompt(ci_run_info): """Build the analysis prompt, optionally including CI compare context.""" if ci_run_info: - extra_context = CI_COMPARE_EXTRA_CONTEXT_TEMPLATE.format(**ci_run_info) + extra_context = CI_COMPARE_EXTRA_CONTEXT_TEMPLATE.format( + **ci_run_info + ) else: extra_context = "" return ANALYSIS_PROMPT_TEMPLATE.format(extra_context=extra_context) -CLAUDE_STDERR_LOG = "/ros_ws/.claude_stderr.log" - - def run_claude_streamed(container_name, prompt): - """Run Claude non-interactively with stream-json output piped to ci_fix_display.""" + """Run Claude non-interactively with stream-json output.""" escaped_prompt = prompt.replace("'", "'\\''") claude_command = ( f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " @@ -308,16 +362,20 @@ def run_claude_resumed(container_name, session_id, prompt): escaped_prompt = prompt.replace("'", "'\\''") claude_command = ( f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " - f"--resume '{session_id}' -p '{escaped_prompt}' --verbose --output-format stream-json " + f"--resume '{session_id}' -p '{escaped_prompt}' " + f"--verbose --output-format stream-json " f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) docker_exec(container_name, claude_command, check=False) def prompt_user_for_feedback(): - """Ask the user to review Claude's analysis and provide corrections.""" + """Ask user to review Claude's analysis and provide corrections.""" feedback = inquirer.text( - message="Review the analysis above. Provide corrections or context (Enter to accept as-is):", + message=( + "Review the analysis above. " + "Provide corrections or context (Enter to accept as-is):" + ), default="", ).execute().strip() if not feedback: @@ -325,113 +383,169 @@ def prompt_user_for_feedback(): return feedback -def fix_ci(args): - """Main fix workflow: session select -> preflight -> reproduce -> Claude -> shell.""" - parsed = parse_fix_args(args) +def refresh_claude_config(container_name): + """Refresh Claude config in an existing container.""" + console.print( + "[green]Claude already installed — refreshing config...[/green]" + ) + copy_claude_credentials(container_name) + copy_ci_context(container_name) + copy_display_script(container_name) + inject_resume_function(container_name) + inject_rerun_tests_function(container_name) - console.print(Panel("[bold cyan]CI Fix with Claude[/bold cyan]", expand=False)) - # Step 0: Session selection - ci_run_info = None - if parsed["reproduce_args"]: - branch_hint = extract_branch_from_args(parsed["reproduce_args"]) - container_name = prompt_for_session_name(branch_hint) - needs_reproduce = True - resume_session_id = None +def run_claude_workflow(container_name, ci_run_info): + """Run the Claude analysis -> feedback -> fix workflow.""" + if ci_run_info: + custom_prompt = None else: - container_name, ci_run_info, needs_reproduce, resume_session_id = select_or_create_session(parsed) + ci_run_info, custom_prompt = select_fix_mode() - # Step 1: Preflight checks - repo_url = extract_repo_url_from_args(parsed["reproduce_args"]) - try: - run_all_preflight_checks(repo_url=repo_url) - except PreflightError as error: - console.print(f"\n[bold red]Preflight failed:[/bold red] {error}") - sys.exit(1) + if custom_prompt: + console.print( + "\n[bold cyan]Launching Claude Code (custom prompt)...[/bold cyan]" + ) + run_claude_streamed(container_name, custom_prompt) + else: + # Analysis phase + analysis_prompt = build_analysis_prompt(ci_run_info) + console.print( + "\n[bold cyan]Launching Claude Code " + "— analysis phase...[/bold cyan]" + ) + console.print( + "[dim]Claude will analyse failures before " + "attempting fixes[/dim]\n" + ) + run_claude_streamed(container_name, analysis_prompt) + + # User review + console.print() + user_feedback = prompt_user_for_feedback() + + # Fix phase (resume session) + state = read_container_state(container_name) + session_id = state["session_id"] if state else None + if session_id: + console.print( + "\n[bold cyan]Resuming Claude " + "— fix phase...[/bold cyan]" + ) + console.print( + "[dim]Claude will now fix the failures[/dim]\n" + ) + fix_prompt = FIX_PROMPT_TEMPLATE.format( + user_feedback=user_feedback + ) + run_claude_resumed(container_name, session_id, fix_prompt) + else: + console.print( + "\n[yellow]No session ID from analysis phase — " + "cannot resume. Dropping to shell.[/yellow]" + ) + + # Show outcome + state = read_container_state(container_name) + if state: + phase = state.get("phase", "unknown") + session_id = state.get("session_id") + attempt = state.get("attempt_count", 1) + console.print( + f"\n[bold]Claude finished — " + f"phase: {phase}, attempt: {attempt}[/bold]" + ) + if session_id: + console.print(f"[dim]Session ID: {session_id}[/dim]") + else: + console.print( + "\n[yellow]Could not read state file from container[/yellow]" + ) + + +def drop_to_shell(container_name): + """Drop user into an interactive container shell.""" + console.print("\n[bold green]Dropping into container shell.[/bold green]") + console.print("[cyan]Useful commands:[/cyan]") + console.print( + " [bold]rerun_tests[/bold] " + "— rebuild and re-run CI tests locally" + ) + console.print( + " [bold]resume_claude[/bold] " + "— resume the Claude session interactively" + ) + console.print(" [bold]git diff[/bold] — review changes") + console.print( + " [bold]git add && git commit[/bold] — commit fixes" + ) + console.print(" [dim]Repo is at /ros_ws/src/[/dim]\n") + docker_exec_interactive(container_name) + + +def fix_ci(_args): + """Main fix workflow: gather -> preflight -> reproduce -> Claude -> shell. + + _args is accepted for backward compat but ignored (interactive only). + """ + console.print( + Panel("[bold cyan]CI Fix with Claude[/bold cyan]", expand=False) + ) - # Step 2: Reproduce CI in container - if needs_reproduce: + # Step 1: Gather all session info up front + session = gather_session_info() + container_name = session["container_name"] + + if session["mode"] == "new": + # Step 2: Preflight checks + try: + gh_token = run_all_preflight_checks( + repo_url=session["repo_url"] + ) + except PreflightError as error: + console.print( + f"\n[bold red]Preflight failed:[/bold red] {error}" + ) + sys.exit(1) + + # Step 3: Reproduce CI in container if container_exists(container_name): remove_container(container_name) - reproduce_args = parsed["reproduce_args"] + ["--container-name", container_name] - reproduce_ci(reproduce_args, skip_preflight=True) + reproduce_ci( + repo_url=session["repo_url"], + branch=session["branch"], + container_name=container_name, + gh_token=gh_token, + only_needed_deps=session["only_needed_deps"], + ) save_package_list(container_name) - # Step 3: Setup Claude in container (idempotent — skips if already installed) + # Step 4: Setup Claude in container if is_claude_installed_in_container(container_name): - console.print("[green]Claude already installed — refreshing config...[/green]") - copy_claude_credentials(container_name) - copy_ci_context(container_name) - copy_display_script(container_name) - inject_resume_function(container_name) - inject_rerun_tests_function(container_name) + refresh_claude_config(container_name) else: setup_claude_in_container(container_name) - # Step 3.5: If resuming, launch interactive Claude + # Step 5: Run Claude + resume_session_id = session.get("resume_session_id") if resume_session_id: - console.print("\n[bold cyan]Resuming Claude session...[/bold cyan]") - console.print("[dim]You are now in an interactive Claude session[/dim]\n") + console.print( + "\n[bold cyan]Resuming Claude session...[/bold cyan]" + ) + console.print( + "[dim]You are now in an interactive Claude session[/dim]\n" + ) docker_exec( container_name, - f'cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions --resume "{resume_session_id}"', + "cd /ros_ws && IS_SANDBOX=1 claude " + "--dangerously-skip-permissions " + f'--resume "{resume_session_id}"', interactive=True, check=False, ) else: - # Step 4: Determine fix mode - if ci_run_info: - custom_prompt = None - else: - ci_run_info, custom_prompt = select_fix_mode() - - if custom_prompt: - # Custom prompt: single-shot, no analysis phase - console.print("\n[bold cyan]Launching Claude Code (custom prompt)...[/bold cyan]") - run_claude_streamed(container_name, custom_prompt) - else: - # Step 4a: Analysis phase - analysis_prompt = build_analysis_prompt(ci_run_info) - console.print("\n[bold cyan]Launching Claude Code — analysis phase...[/bold cyan]") - console.print("[dim]Claude will analyse failures before attempting fixes[/dim]\n") - run_claude_streamed(container_name, analysis_prompt) - - # Step 4b: User review - console.print() - user_feedback = prompt_user_for_feedback() - - # Step 4c: Fix phase (resume session) - state = read_container_state(container_name) - session_id = state["session_id"] if state else None - if session_id: - console.print("\n[bold cyan]Resuming Claude — fix phase...[/bold cyan]") - console.print("[dim]Claude will now fix the failures[/dim]\n") - fix_prompt = FIX_PROMPT_TEMPLATE.format(user_feedback=user_feedback) - run_claude_resumed(container_name, session_id, fix_prompt) - else: - console.print( - "\n[yellow]No session ID from analysis phase — " - "cannot resume. Dropping to shell.[/yellow]" - ) - - # Step 4.5: Show outcome - state = read_container_state(container_name) - if state: - phase = state.get("phase", "unknown") - session_id = state.get("session_id") - attempt = state.get("attempt_count", 1) - console.print(f"\n[bold]Claude finished — phase: {phase}, attempt: {attempt}[/bold]") - if session_id: - console.print(f"[dim]Session ID: {session_id}[/dim]") - else: - console.print("\n[yellow]Could not read state file from container[/yellow]") - - # Step 5: Drop into container shell (both paths converge here) - console.print("\n[bold green]Dropping into container shell.[/bold green]") - console.print("[cyan]Useful commands:[/cyan]") - console.print(" [bold]rerun_tests[/bold] — rebuild and re-run CI tests locally") - console.print(" [bold]resume_claude[/bold] — resume the Claude session interactively") - console.print(" [bold]git diff[/bold] — review changes") - console.print(" [bold]git add && git commit[/bold] — commit fixes") - console.print(" [dim]Repo is at /ros_ws/src/[/dim]\n") + run_claude_workflow( + container_name, session.get("ci_run_info") + ) - docker_exec_interactive(container_name) + # Step 6: Drop to shell + drop_to_shell(container_name) From a1128c266586f6d4c6cce1e11b278527c2829324 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 17:02:05 +0000 Subject: [PATCH 27/60] adapt cli.py reproduce handler for new reproduce_ci interface _handle_reproduce now prompts for repo/branch, handles container collision, runs preflight, then calls reproduce_ci with explicit params. Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/cli.py | 66 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/bin/ci_tool/cli.py b/bin/ci_tool/cli.py index f55dc3e..6148b12 100644 --- a/bin/ci_tool/cli.py +++ b/bin/ci_tool/cli.py @@ -59,9 +59,69 @@ def dispatch_subcommand(command, args): handler(args) -def _handle_reproduce(args): - from ci_tool.ci_reproduce import reproduce_ci - reproduce_ci(args) +def _handle_reproduce(_args): + import os + from ci_tool.ci_reproduce import reproduce_ci, prompt_for_reproduce_args + from ci_tool.containers import ( + DEFAULT_CONTAINER_NAME, + container_exists, + container_is_running, + remove_container, + ) + from ci_tool.preflight import ( + validate_docker_available, + validate_gh_token, + PreflightError, + ) + + try: + validate_docker_available() + except PreflightError as error: + console.print(f"\n[bold red]Preflight failed:[/bold red] {error}") + sys.exit(1) + + repo_url, branch, only_needed_deps = prompt_for_reproduce_args() + container_name = DEFAULT_CONTAINER_NAME + + if container_exists(container_name): + action = inquirer.select( + message=f"Container '{container_name}' already exists. What to do?", + choices=[ + {"name": "Remove and recreate", "value": "recreate"}, + {"name": "Keep existing (skip creation)", "value": "keep"}, + {"name": "Cancel", "value": "cancel"}, + ], + ).execute() + + if action == "cancel": + return + if action == "recreate": + remove_container(container_name) + if action == "keep": + if not container_is_running(container_name): + import subprocess + subprocess.run( + ["docker", "start", container_name], check=True + ) + console.print( + f"[green]Using existing container " + f"'{container_name}'[/green]" + ) + return + + try: + gh_token = validate_gh_token(repo_url=repo_url) + except PreflightError as error: + console.print(f"\n[bold red]Preflight failed:[/bold red] {error}") + sys.exit(1) + + reproduce_ci( + repo_url=repo_url, + branch=branch, + container_name=container_name, + gh_token=gh_token, + only_needed_deps=only_needed_deps, + ) def _handle_fix(args): From 55d23cada2409a482903f68f3fbbbf2e29e7a0cb Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 17:02:50 +0000 Subject: [PATCH 28/60] setup.sh: install ci_tool and hand off after setup Adds step 5 that downloads ci_tool Python package files, installs Python dependencies, then execs into ci_tool for immediate use. Co-Authored-By: Claude Opus 4.6 --- bin/setup.sh | 61 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/bin/setup.sh b/bin/setup.sh index 3499495..d32bf9a 100644 --- a/bin/setup.sh +++ b/bin/setup.sh @@ -27,7 +27,7 @@ echo -e "${Color_Off}" # --- Step 1: Install/update .helper_bash_functions --- -echo -e "${Bold}[1/4] Installing helper bash functions...${Color_Off}" +echo -e "${Bold}[1/5] Installing helper bash functions...${Color_Off}" if [ -f "${HELPER_PATH}" ]; then echo -e "${Yellow} Existing ~/.helper_bash_functions found — updating while preserving your variables...${Color_Off}" tmp_new=$(mktemp) @@ -45,7 +45,7 @@ fi # --- Step 2: GitHub token --- echo "" -echo -e "${Bold}[2/4] GitHub token${Color_Off}" +echo -e "${Bold}[2/5] GitHub token${Color_Off}" echo -e " ci_tool needs a GitHub token with ${Bold}repo${Color_Off} scope to access private repos." echo -e " Create one at: ${Cyan}https://github.com/settings/tokens${Color_Off}" echo "" @@ -77,7 +77,7 @@ fi # --- Step 3: Shell integration --- echo "" -echo -e "${Bold}[3/4] Shell integration${Color_Off}" +echo -e "${Bold}[3/5] Shell integration${Color_Off}" BASHRC="${HOME}/.bashrc" if [ -f "${BASHRC}" ] && grep -q 'source ~/.helper_bash_functions' "${BASHRC}"; then echo -e " ${Green}Already sourced in ~/.bashrc${Color_Off}" @@ -89,7 +89,7 @@ fi # --- Step 4: Claude Code authentication --- echo "" -echo -e "${Bold}[4/4] Claude Code authentication${Color_Off}" +echo -e "${Bold}[4/5] Claude Code authentication${Color_Off}" echo -e " ci_tool uses Claude Code to autonomously fix CI failures." echo -e " Claude must be installed and authenticated on your host machine." echo "" @@ -117,16 +117,55 @@ else echo -e " Then run ${Bold}claude${Color_Off} to authenticate." fi +# --- Step 5: Install ci_tool --- + +echo "" +echo -e "${Bold}[5/5] Installing ci_tool...${Color_Off}" + +CI_TOOL_DIR="${HOME}/.ci_tool" +CI_TOOL_URL="${BASE_URL}/bin/ci_tool" + +mkdir -p "${CI_TOOL_DIR}/ci_tool/ci_context" + +CI_TOOL_FILES=( + "__init__.py" + "__main__.py" + "cli.py" + "ci_fix.py" + "ci_reproduce.py" + "claude_setup.py" + "claude_session.py" + "containers.py" + "preflight.py" + "display_progress.py" + "requirements.txt" +) + +for file in "${CI_TOOL_FILES[@]}"; do + curl -fsSL "${CI_TOOL_URL}/${file}" -o "${CI_TOOL_DIR}/ci_tool/${file}" || { + echo -e " ${Red}Failed to download ${file}${Color_Off}" + exit 1 + } +done + +curl -fsSL "${CI_TOOL_URL}/ci_context/CLAUDE.md" \ + -o "${CI_TOOL_DIR}/ci_tool/ci_context/CLAUDE.md" 2>/dev/null || true + +pip3 install --user --quiet -r "${CI_TOOL_DIR}/ci_tool/requirements.txt" 2>/dev/null || { + echo -e " ${Yellow}Some dependencies may not have installed. ci_tool will retry on first run.${Color_Off}" +} + +echo -e " ${Green}ci_tool installed at ${CI_TOOL_DIR}${Color_Off}" + # --- Done --- echo "" echo -e "${Bold}${Green}Setup complete!${Color_Off}" echo "" -echo -e " Reload your shell or run:" -echo -e " ${Bold}source ~/.helper_bash_functions${Color_Off}" -echo "" -echo -e " Then start ci_tool:" -echo -e " ${Bold}ci_tool${Color_Off} Interactive menu" -echo -e " ${Bold}ci_fix${Color_Off} Fix CI failures with Claude" -echo -e " ${Bold}ci_tool reproduce${Color_Off} Reproduce CI locally" + +# Source helper functions so GH_TOKEN is available for ci_tool +source "${HELPER_PATH}" 2>/dev/null || true + +echo -e " ${Bold}${Cyan}Launching ci_tool...${Color_Off}" echo "" +exec python3 "${CI_TOOL_DIR}/ci_tool/__main__.py" From 209cad635a3629dad65b3dfa9f6aa935ce0d2513 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 17:08:41 +0000 Subject: [PATCH 29/60] lint: fix all pylint warnings across ci_tool modules - Remove unused imports from ci_reproduce.py (container collision code was moved to callers) - Extract prompt_for_repo_and_branch() to eliminate duplicate prompting code between ci_fix.py and ci_reproduce.py - Extract _handle_container_collision() in cli.py to reduce locals and improve readability - Fix line-too-long issues in ci_reproduce.py - Add pylint disable comments for intentional patterns (lazy imports, factory function args) Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_fix.py | 23 ++++--------- bin/ci_tool/ci_reproduce.py | 54 ++++++++++++------------------ bin/ci_tool/cli.py | 67 +++++++++++++++++++------------------ bin/ci_tool/containers.py | 2 +- 4 files changed, 64 insertions(+), 82 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 9946ecf..16a6cbf 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -32,7 +32,7 @@ sanitize_container_name, start_container, ) -from ci_tool.ci_reproduce import reproduce_ci +from ci_tool.ci_reproduce import prompt_for_reproduce_args, reproduce_ci from ci_tool.preflight import run_all_preflight_checks, PreflightError console = Console() @@ -274,23 +274,12 @@ def gather_session_info(): console.print(f" [green]Repo:[/green] {repo_url}") console.print(f" [green]Branch:[/green] {branch}") console.print(f" [green]Run ID:[/green] {ci_run_info['run_id']}") - else: - repo_url = inquirer.text( - message="Repository URL:", - validate=lambda url: url.startswith("https://github.com/"), - invalid_message="Must be a GitHub URL (https://github.com/...)", - ).execute() - - branch = inquirer.text( - message="Branch name:", - validate=lambda b: len(b.strip()) > 0, - invalid_message="Branch name cannot be empty", + only_needed_deps = not inquirer.confirm( + message="Build everything (slower, disable --only-needed-deps)?", + default=False, ).execute() - - only_needed_deps = not inquirer.confirm( - message="Build everything (slower, disable --only-needed-deps)?", - default=False, - ).execute() + else: + repo_url, branch, only_needed_deps = prompt_for_reproduce_args() container_name = prompt_for_session_name(branch) diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index ec3bad8..40e0bba 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -15,10 +15,7 @@ from ci_tool.containers import ( DEFAULT_CONTAINER_NAME, container_exists, - container_is_running, - remove_container, run_command, - start_container, ) console = Console() @@ -92,7 +89,9 @@ def _validate_deps_repos_reachable(org, repo_name, branch, gh_token, deps_file=" hints.insert(1, f"Branch/commit '{branch}' may not exist in '{org}/{repo_name}'") raise RuntimeError("\n ".join(hints)) from error except URLError as error: - raise RuntimeError(f"Cannot reach GitHub to validate {deps_file}: {error.reason}") from error + raise RuntimeError( + f"Cannot reach GitHub to validate {deps_file}: {error.reason}" + ) from error console.print(f" [green]\u2713[/green] Validation passed (HTTP {http_code})") @@ -102,7 +101,10 @@ def _fetch_internal_scripts(gh_token, scripts_branch): Returns (setup_script_path, retest_script_path) as temporary file paths on the host. """ - console.print(f" Fetching CI scripts from [cyan]{INTERNAL_REPO}[/cyan] branch [cyan]{scripts_branch}[/cyan]") + console.print( + f" Fetching CI scripts from [cyan]{INTERNAL_REPO}[/cyan] " + f"branch [cyan]{scripts_branch}[/cyan]" + ) setup_content = _fetch_github_raw_file( INTERNAL_REPO, "bin/ci_workspace_setup.sh", scripts_branch, gh_token, @@ -215,10 +217,10 @@ def _docker_exec_workspace_setup(container_name): ) -def prompt_for_reproduce_args(): - """Interactively ask user for the required reproduce arguments. +def prompt_for_repo_and_branch(): + """Ask user for repository URL and branch name interactively. - Returns (repo_url, branch, only_needed_deps) tuple. + Returns (repo_url, branch) tuple. """ repo_url = inquirer.text( message="Repository URL:", @@ -232,16 +234,25 @@ def prompt_for_reproduce_args(): invalid_message="Branch name cannot be empty", ).execute() - build_everything = inquirer.confirm( + return repo_url, branch + + +def prompt_for_reproduce_args(): + """Interactively ask user for the required reproduce arguments. + + Returns (repo_url, branch, only_needed_deps) tuple. + """ + repo_url, branch = prompt_for_repo_and_branch() + + only_needed_deps = not inquirer.confirm( message="Build everything (slower, disable --only-needed-deps)?", default=False, ).execute() - only_needed_deps = not build_everything return repo_url, branch, only_needed_deps -def reproduce_ci( +def reproduce_ci( # pylint: disable=too-many-arguments repo_url, branch, container_name=DEFAULT_CONTAINER_NAME, @@ -267,27 +278,6 @@ def reproduce_ci( console.print(Panel("[bold cyan]Reproduce CI[/bold cyan]", expand=False)) - # Handle existing container - if container_exists(container_name): - action = inquirer.select( - message=f"Container '{container_name}' already exists. What to do?", - choices=[ - {"name": "Remove and recreate", "value": "recreate"}, - {"name": "Keep existing (skip creation)", "value": "keep"}, - {"name": "Cancel", "value": "cancel"}, - ], - ).execute() - - if action == "cancel": - return - if action == "recreate": - remove_container(container_name) - if action == "keep": - if not container_is_running(container_name): - start_container(container_name) - console.print(f"[green]\u2713 Using existing container '{container_name}'[/green]") - return - # Parse repo URL org, repo_name, clean_repo_url = _parse_repo_url(repo_url) console.print(f" Organization: [cyan]{org}[/cyan]") diff --git a/bin/ci_tool/cli.py b/bin/ci_tool/cli.py index 6148b12..25c7781 100644 --- a/bin/ci_tool/cli.py +++ b/bin/ci_tool/cli.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Interactive CLI menu for CI tool.""" +# pylint: disable=import-outside-toplevel from __future__ import annotations import sys @@ -59,20 +60,44 @@ def dispatch_subcommand(command, args): handler(args) -def _handle_reproduce(_args): - import os - from ci_tool.ci_reproduce import reproduce_ci, prompt_for_reproduce_args +def _handle_container_collision(container_name): + """Handle an existing container: recreate, keep, or cancel. + + Returns True if reproduce should proceed, False to skip. + """ from ci_tool.containers import ( - DEFAULT_CONTAINER_NAME, - container_exists, container_is_running, remove_container, + start_container, ) - from ci_tool.preflight import ( - validate_docker_available, - validate_gh_token, - PreflightError, + + action = inquirer.select( + message=f"Container '{container_name}' already exists. What to do?", + choices=[ + {"name": "Remove and recreate", "value": "recreate"}, + {"name": "Keep existing (skip creation)", "value": "keep"}, + {"name": "Cancel", "value": "cancel"}, + ], + ).execute() + + if action == "cancel": + return False + if action == "recreate": + remove_container(container_name) + return True + # action == "keep" + if not container_is_running(container_name): + start_container(container_name) + console.print( + f"[green]Using existing container '{container_name}'[/green]" ) + return False + + +def _handle_reproduce(_args): + from ci_tool.ci_reproduce import reproduce_ci, prompt_for_reproduce_args + from ci_tool.containers import DEFAULT_CONTAINER_NAME, container_exists + from ci_tool.preflight import validate_docker_available, validate_gh_token, PreflightError try: validate_docker_available() @@ -84,29 +109,7 @@ def _handle_reproduce(_args): container_name = DEFAULT_CONTAINER_NAME if container_exists(container_name): - action = inquirer.select( - message=f"Container '{container_name}' already exists. What to do?", - choices=[ - {"name": "Remove and recreate", "value": "recreate"}, - {"name": "Keep existing (skip creation)", "value": "keep"}, - {"name": "Cancel", "value": "cancel"}, - ], - ).execute() - - if action == "cancel": - return - if action == "recreate": - remove_container(container_name) - if action == "keep": - if not container_is_running(container_name): - import subprocess - subprocess.run( - ["docker", "start", container_name], check=True - ) - console.print( - f"[green]Using existing container " - f"'{container_name}'[/green]" - ) + if not _handle_container_collision(container_name): return try: diff --git a/bin/ci_tool/containers.py b/bin/ci_tool/containers.py index 0e1c111..d723315 100644 --- a/bin/ci_tool/containers.py +++ b/bin/ci_tool/containers.py @@ -130,7 +130,7 @@ def retest_in_container(args): def clean_containers(_args): """Clean up CI containers.""" require_docker() - from InquirerPy import inquirer + from InquirerPy import inquirer # pylint: disable=import-outside-toplevel result = subprocess.run( ["docker", "ps", "-a", "--filter", "name=er_ci_", "--format", "{{.Names}}\t{{.Status}}"], From 40d0c9346e852aab5ba8bade62c783033b4cf625 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 17:15:25 +0000 Subject: [PATCH 30/60] fix critical review issues: Python 3.8 compat, scripts branch, error handling - Replace removesuffix/removeprefix with Python 3.8-compatible string ops in _parse_repo_url (regression from earlier rewrite) - Change DEFAULT_SCRIPTS_BRANCH from feature branch to "main" - Add URL validation and HTTP/URL error handling to extract_info_from_ci_url - Add duplicate-code pylint disables for shared import blocks Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_fix.py | 19 +++++++++++++++++-- bin/ci_tool/ci_reproduce.py | 10 +++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 16a6cbf..3782330 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """Fix CI test failures using Claude Code inside a container.""" +# pylint: disable=duplicate-code # shared imports with ci_reproduce.py from __future__ import annotations import json import os import subprocess import sys +from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from InquirerPy import inquirer @@ -134,6 +136,9 @@ def extract_info_from_ci_url(ci_run_url): """Extract repo URL, branch, and run ID from a GitHub Actions URL.""" run_id = extract_run_id_from_url(ci_run_url) + if "github.com/" not in ci_run_url or "/actions/" not in ci_run_url: + raise ValueError(f"Not a valid GitHub Actions URL: {ci_run_url}") + owner_repo = ci_run_url.split("github.com/")[1].split("/actions/")[0] repo_url = f"https://github.com/{owner_repo}" @@ -150,8 +155,18 @@ def extract_info_from_ci_url(ci_run_url): "Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json", }) - with urlopen(request) as response: - data = json.loads(response.read()) + try: + with urlopen(request, timeout=15) as response: + data = json.loads(response.read()) + except HTTPError as error: + raise RuntimeError( + f"Failed to fetch run info for {owner_repo} " + f"run {run_id} (HTTP {error.code})" + ) from error + except URLError as error: + raise RuntimeError( + f"Cannot reach GitHub API: {error.reason}" + ) from error return { "repo_url": repo_url, diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index 40e0bba..d9c2415 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Reproduce CI locally by creating a Docker container with Python Docker orchestration.""" +# pylint: disable=duplicate-code # shared imports with ci_fix.py from __future__ import annotations import os @@ -21,7 +22,7 @@ console = Console() DEFAULT_DOCKER_IMAGE = "rostooling/setup-ros-docker:ubuntu-focal-ros-noetic-desktop-latest" -DEFAULT_SCRIPTS_BRANCH = "ERD-1633_reproduce_ci_locally" +DEFAULT_SCRIPTS_BRANCH = "main" INTERNAL_REPO = "Extend-Robotics/er_build_tools_internal" CONTAINER_SETUP_SCRIPT_PATH = "/tmp/ci_workspace_setup.sh" CONTAINER_RETEST_SCRIPT_PATH = "/tmp/ci_repull_and_retest.sh" @@ -36,8 +37,11 @@ def _parse_repo_url(repo_url): raise ValueError( f"URL must start with https://github.com/, got: {repo_url}" ) - clean_url = repo_url.rstrip("/").removesuffix(".git") - repo_path = clean_url.removeprefix("https://github.com/") + clean_url = repo_url.rstrip("/") + if clean_url.endswith(".git"): + clean_url = clean_url[:-4] + github_prefix = "https://github.com/" + repo_path = clean_url[len(github_prefix):] parts = repo_path.split("/") if len(parts) != 2 or not all(parts): raise ValueError(f"Cannot parse org/repo from URL: {repo_url}") From 2672141d389038f5db7b608a3120f5b5a869d196 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 17:34:43 +0000 Subject: [PATCH 31/60] scripts branch: env var override and fix default to existing branch DEFAULT_SCRIPTS_BRANCH now reads CI_TOOL_SCRIPTS_BRANCH env var, falling back to ERD-1633_reproduce_ci_locally (where scripts exist). Change default to "main" once er_build_tools_internal scripts are merged. Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_reproduce.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index d9c2415..f30d2be 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -22,7 +22,9 @@ console = Console() DEFAULT_DOCKER_IMAGE = "rostooling/setup-ros-docker:ubuntu-focal-ros-noetic-desktop-latest" -DEFAULT_SCRIPTS_BRANCH = "main" +DEFAULT_SCRIPTS_BRANCH = os.environ.get( + "CI_TOOL_SCRIPTS_BRANCH", "ERD-1633_reproduce_ci_locally" +) INTERNAL_REPO = "Extend-Robotics/er_build_tools_internal" CONTAINER_SETUP_SCRIPT_PATH = "/tmp/ci_workspace_setup.sh" CONTAINER_RETEST_SCRIPT_PATH = "/tmp/ci_repull_and_retest.sh" From a805a9a5ad06044f56e7c37b1313ed9c6cd63f37 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 18:06:31 +0000 Subject: [PATCH 32/60] pass THIS_SCRIPT_BRANCH to ci_tool as CI_TOOL_SCRIPTS_BRANCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensures the internal scripts branch matches the helper_bash_functions branch — one knob controls everything. Co-Authored-By: Claude Opus 4.6 --- .helper_bash_functions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.helper_bash_functions b/.helper_bash_functions index 9fc1fbe..a8d3941 100644 --- a/.helper_bash_functions +++ b/.helper_bash_functions @@ -262,7 +262,7 @@ ci_tool() { fi echo -e "${Yellow}Warning: Failed to fetch latest ci_tool, using cached version${Color_Off}" fi - python3 "${CI_TOOL_CACHE_DIR}/ci_tool" "$@" + CI_TOOL_SCRIPTS_BRANCH="${THIS_SCRIPT_BRANCH}" python3 "${CI_TOOL_CACHE_DIR}/ci_tool" "$@" } ci_fix() { ci_tool fix "$@"; } From 37e798b48d13e080c210e9b23abb7bbbb1c35ea8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 18:11:15 +0000 Subject: [PATCH 33/60] show final container name after session name prompt Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_fix.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 3782330..f8b784a 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -191,6 +191,7 @@ def prompt_for_session_name(branch_hint=None): ).execute().strip() container_name = f"er_ci_{sanitize_container_name(name)}" + console.print(f" Container name: [cyan]{container_name}[/cyan]") if container_exists(container_name): console.print( From 334b42face4f782468eb63bba70ae8960b50aeda Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 18:11:44 +0000 Subject: [PATCH 34/60] show er_ci_ prefix in session name prompt message Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_fix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index f8b784a..aa10756 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -184,7 +184,7 @@ def prompt_for_session_name(branch_hint=None): """ default = sanitize_container_name(branch_hint) if branch_hint else "" name = inquirer.text( - message="Session name (used for container naming):", + message="Session name (container will be er_ci_):", default=default, validate=lambda n: len(n.strip()) > 0, invalid_message="Session name cannot be empty", From 1b0e68135dc770ed2709b9b03eb4e2dece20afcb Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 18:19:34 +0000 Subject: [PATCH 35/60] fix display_progress: force_terminal and remove Live context Rich's Live display doesn't work inside docker exec without a PTY. - Add force_terminal=True so Rich outputs ANSI codes regardless - Replace Live spinner with direct console.print for tool activity - Write text deltas directly to stderr (bypasses Rich buffering) - Extract handle_event and print_session_summary to fix lint warnings Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/display_progress.py | 153 +++++++++++++++----------------- 1 file changed, 73 insertions(+), 80 deletions(-) diff --git a/bin/ci_tool/display_progress.py b/bin/ci_tool/display_progress.py index 7117ff3..93a4f6b 100644 --- a/bin/ci_tool/display_progress.py +++ b/bin/ci_tool/display_progress.py @@ -2,10 +2,11 @@ """Display human-readable progress from Claude Code stream-json output. Reads newline-delimited JSON from stdin (Claude's --output-format stream-json), -shows a live spinner + assistant text + tool activity via rich, captures the -session_id, and writes a state file on exit. +shows assistant text + tool activity via rich, captures the session_id, and +writes a state file on exit. -Designed to run INSIDE a CI container. Requires: rich (from requirements.txt). +Designed to run INSIDE a CI container via docker exec (no TTY allocated). +Requires: rich (from requirements.txt). """ from __future__ import annotations @@ -16,14 +17,13 @@ from datetime import datetime, timezone from rich.console import Console -from rich.live import Live -from rich.spinner import Spinner -from rich.text import Text STATE_FILE = "/ros_ws/.ci_fix_state.json" CLAUDE_STDERR_LOG = "/ros_ws/.claude_stderr.log" -console = Console(stderr=True) +# force_terminal=True is required because docker exec without -t +# doesn't allocate a PTY, so Rich would otherwise suppress all ANSI output. +console = Console(stderr=True, force_terminal=True) def write_state(session_id, phase, attempt_count=1): @@ -57,95 +57,88 @@ def format_elapsed(start_time): return f"{seconds}s" +def handle_event(event, start_time): + """Handle a single stream-json event. Returns session_id if found, else None.""" + session_id = event.get("session_id") or None + + # Unwrap nested event wrapper if present + inner = event.get("event", event) + event_type = inner.get("type", "") + + if event_type == "content_block_start": + block = inner.get("content_block", {}) + if block.get("type") == "tool_use": + tool_name = block.get("name", "unknown") + console.print( + f" [dim]tool:[/dim] [bold]{tool_name}[/bold] " + f"[dim][{format_elapsed(start_time)}][/dim]" + ) + + elif event_type == "content_block_delta": + delta = inner.get("delta", {}) + if delta.get("type") == "text_delta": + sys.stderr.write(delta.get("text", "")) + sys.stderr.flush() + + return session_id + + +def print_session_summary(session_id, start_time): + """Print the final session summary after stream ends.""" + elapsed = format_elapsed(start_time) + if session_id: + console.print( + f"\n[green]Session saved ({session_id}). " + f"Elapsed: {elapsed}. Use 'resume_claude' to continue.[/green]" + ) + return + + console.print(f"\n[yellow]No session ID captured. Elapsed: {elapsed}.[/yellow]") + try: + with open(CLAUDE_STDERR_LOG, encoding="utf-8") as stderr_log: + stderr_content = stderr_log.read().strip() + if stderr_content: + console.print("[yellow]Claude stderr output:[/yellow]") + console.print(stderr_content) + except FileNotFoundError: + pass + + def main(): """Read stream-json from stdin, display progress, write state on exit.""" session_id = None attempt_count = read_existing_attempt_count() + 1 phase = "fixing" start_time = time.time() - current_activity = "Starting up" try: - with Live( - Spinner("dots", text=Text(f" {current_activity}...", style="cyan")), - console=console, - refresh_per_second=10, - transient=True, - ) as live: - for line in sys.stdin: - line = line.strip() - if not line: - continue - try: - event = json.loads(line) - except json.JSONDecodeError: - continue - - if "session_id" in event and event["session_id"]: - session_id = event["session_id"] - - inner = event.get("event", event) - event_type = inner.get("type", "") - - if event_type == "content_block_start": - block = inner.get("content_block", {}) - if block.get("type") == "tool_use": - tool_name = block.get("name", "unknown") - current_activity = f"Using {tool_name}" - live.update(Spinner( - "dots", - text=Text( - f" {current_activity} " - f"[{format_elapsed(start_time)}]", - style="cyan", - ), - )) - console.print( - f" [dim]tool:[/dim] [bold]{tool_name}[/bold]" - ) - - elif event_type == "content_block_delta": - delta = inner.get("delta", {}) - if delta.get("type") == "text_delta": - text = delta.get("text", "") - console.file.write(text) - console.file.flush() - current_activity = "Thinking" - live.update(Spinner( - "dots", - text=Text( - f" {current_activity} " - f"[{format_elapsed(start_time)}]", - style="cyan", - ), - )) + console.print("[cyan] Working...[/cyan]") + + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + + event_session_id = handle_event(event, start_time) + if event_session_id: + session_id = event_session_id phase = "completed" except KeyboardInterrupt: phase = "interrupted" - except Exception: - console.print(f"\n[red]Display processor error:[/red]\n{traceback.format_exc()}") + except Exception: # pylint: disable=broad-except + console.print( + f"\n[red]Display processor error:[/red]\n{traceback.format_exc()}" + ) phase = "stuck" write_state(session_id, phase, attempt_count) - - elapsed = format_elapsed(start_time) - if session_id: - console.print( - f"\n[green]Session saved ({session_id}). " - f"Elapsed: {elapsed}. Use 'resume_claude' to continue.[/green]" - ) - else: - console.print(f"\n[yellow]No session ID captured. Elapsed: {elapsed}.[/yellow]") - try: - with open(CLAUDE_STDERR_LOG, encoding="utf-8") as stderr_log: - stderr_content = stderr_log.read().strip() - if stderr_content: - console.print("[yellow]Claude stderr output:[/yellow]") - console.print(stderr_content) - except FileNotFoundError: - pass + print_session_summary(session_id, start_time) if __name__ == "__main__": From d3de9e5e7ab90a65338acb134bb14f5357fe481a Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 18:21:29 +0000 Subject: [PATCH 36/60] add tty flag to docker_exec, use for Claude streaming Allocates a PTY via docker exec -t so Rich spinners and ANSI output work properly inside the container without needing -i (interactive). Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_fix.py | 4 ++-- bin/ci_tool/containers.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index aa10756..68b332f 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -359,7 +359,7 @@ def run_claude_streamed(container_name, prompt): f"-p '{escaped_prompt}' --verbose --output-format stream-json " f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) - docker_exec(container_name, claude_command, check=False) + docker_exec(container_name, claude_command, tty=True, check=False) def run_claude_resumed(container_name, session_id, prompt): @@ -371,7 +371,7 @@ def run_claude_resumed(container_name, session_id, prompt): f"--verbose --output-format stream-json " f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) - docker_exec(container_name, claude_command, check=False) + docker_exec(container_name, claude_command, tty=True, check=False) def prompt_user_for_feedback(): diff --git a/bin/ci_tool/containers.py b/bin/ci_tool/containers.py index d723315..c10f0d4 100644 --- a/bin/ci_tool/containers.py +++ b/bin/ci_tool/containers.py @@ -82,11 +82,15 @@ def sanitize_container_name(name): return re.sub(r'[^a-zA-Z0-9_.-]', '_', name) -def docker_exec(container_name, command, interactive=False, check=True, quiet=False): +def docker_exec( # pylint: disable=too-many-arguments + container_name, command, interactive=False, tty=False, check=True, quiet=False, +): """Run a command inside a container.""" docker_command = ["docker", "exec"] if interactive: docker_command.extend(["-it"]) + elif tty: + docker_command.extend(["-t"]) docker_command.extend([container_name, "bash", "-c", command]) return run_command(docker_command, check=check, quiet=quiet) From e6a70b5b3dcf9360129400c77bba007732b71a21 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 18:23:16 +0000 Subject: [PATCH 37/60] fix scripts branch: hardcode correct internal branch, remove bash passthrough THIS_SCRIPT_BRANCH is the er_build_tools branch name, not the er_build_tools_internal branch name. These are different repos with different branch names. The default must match the internal repo. CI_TOOL_SCRIPTS_BRANCH env var override still works for manual testing. Co-Authored-By: Claude Opus 4.6 --- .helper_bash_functions | 7 +++++-- bin/ci_tool/ci_reproduce.py | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.helper_bash_functions b/.helper_bash_functions index a8d3941..1a3b31a 100644 --- a/.helper_bash_functions +++ b/.helper_bash_functions @@ -32,7 +32,10 @@ colcon_test_these_packages() { THIS_DIR=$(pwd) source install/setup.bash && \ colcon test --packages-select $1 for pkg in $1; do - colcon test-result --verbose --test-result-base "build/$pkg" + if ! colcon test-result --test-result-base "build/$pkg"; then + colcon test-result --verbose --test-result-base "build/$pkg" > "/tmp/colcon_test_verbose_${pkg}.log" 2>&1 + echo -e "${Red}Verbose output saved to: /tmp/colcon_test_verbose_${pkg}.log${Color_Off}" + fi done cd $THIS_DIR @@ -262,7 +265,7 @@ ci_tool() { fi echo -e "${Yellow}Warning: Failed to fetch latest ci_tool, using cached version${Color_Off}" fi - CI_TOOL_SCRIPTS_BRANCH="${THIS_SCRIPT_BRANCH}" python3 "${CI_TOOL_CACHE_DIR}/ci_tool" "$@" + python3 "${CI_TOOL_CACHE_DIR}/ci_tool" "$@" } ci_fix() { ci_tool fix "$@"; } diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index f30d2be..832f230 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -23,8 +23,9 @@ DEFAULT_DOCKER_IMAGE = "rostooling/setup-ros-docker:ubuntu-focal-ros-noetic-desktop-latest" DEFAULT_SCRIPTS_BRANCH = os.environ.get( - "CI_TOOL_SCRIPTS_BRANCH", "ERD-1633_reproduce_ci_locally" + "CI_TOOL_SCRIPTS_BRANCH", "ERD-1633_reproduce_ci_locally", ) + INTERNAL_REPO = "Extend-Robotics/er_build_tools_internal" CONTAINER_SETUP_SCRIPT_PATH = "/tmp/ci_workspace_setup.sh" CONTAINER_RETEST_SCRIPT_PATH = "/tmp/ci_repull_and_retest.sh" From 96ee5883a8d8412d63e2a6562eac82562807f1f0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 18:33:27 +0000 Subject: [PATCH 38/60] hide raw docker command, add event debug logging - Pass quiet=True to docker_exec for Claude streaming calls so the raw command with prompt text isn't displayed to the user - Log all raw stream-json events to /ros_ws/.ci_fix_events.jsonl for debugging display issues Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/ci_fix.py | 4 ++-- bin/ci_tool/display_progress.py | 30 ++++++++++++++++++------------ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 68b332f..c2d350d 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -359,7 +359,7 @@ def run_claude_streamed(container_name, prompt): f"-p '{escaped_prompt}' --verbose --output-format stream-json " f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) - docker_exec(container_name, claude_command, tty=True, check=False) + docker_exec(container_name, claude_command, tty=True, check=False, quiet=True) def run_claude_resumed(container_name, session_id, prompt): @@ -371,7 +371,7 @@ def run_claude_resumed(container_name, session_id, prompt): f"--verbose --output-format stream-json " f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) - docker_exec(container_name, claude_command, tty=True, check=False) + docker_exec(container_name, claude_command, tty=True, check=False, quiet=True) def prompt_user_for_feedback(): diff --git a/bin/ci_tool/display_progress.py b/bin/ci_tool/display_progress.py index 93a4f6b..9223e7e 100644 --- a/bin/ci_tool/display_progress.py +++ b/bin/ci_tool/display_progress.py @@ -20,6 +20,7 @@ STATE_FILE = "/ros_ws/.ci_fix_state.json" CLAUDE_STDERR_LOG = "/ros_ws/.claude_stderr.log" +EVENT_DEBUG_LOG = "/ros_ws/.ci_fix_events.jsonl" # force_terminal=True is required because docker exec without -t # doesn't allocate a PTY, so Rich would otherwise suppress all ANSI output. @@ -114,18 +115,23 @@ def main(): try: console.print("[cyan] Working...[/cyan]") - for line in sys.stdin: - line = line.strip() - if not line: - continue - try: - event = json.loads(line) - except json.JSONDecodeError: - continue - - event_session_id = handle_event(event, start_time) - if event_session_id: - session_id = event_session_id + with open(EVENT_DEBUG_LOG, "w", encoding="utf-8") as debug_log: + for line in sys.stdin: + line = line.strip() + if not line: + continue + + debug_log.write(line + "\n") + debug_log.flush() + + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + + event_session_id = handle_event(event, start_time) + if event_session_id: + session_id = event_session_id phase = "completed" From 97fd90924d9940d57c9625824be0752eaf25e7a1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 18:39:33 +0000 Subject: [PATCH 39/60] fix display to match Claude Code stream-json format Claude Code's stream-json uses {type:"assistant", message:{content:[...]}} not the Anthropic API format (content_block_start/content_block_delta). Rewrite event handler to match the actual format: - assistant events: extract text and tool_use from message.content[] - tool_result events: show completion - system/result events: extract session_id Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/display_progress.py | 56 ++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/bin/ci_tool/display_progress.py b/bin/ci_tool/display_progress.py index 9223e7e..cdcac07 100644 --- a/bin/ci_tool/display_progress.py +++ b/bin/ci_tool/display_progress.py @@ -1,11 +1,19 @@ #!/usr/bin/env python3 """Display human-readable progress from Claude Code stream-json output. -Reads newline-delimited JSON from stdin (Claude's --output-format stream-json), +Reads newline-delimited JSON from stdin (Claude Code's --output-format stream-json), shows assistant text + tool activity via rich, captures the session_id, and writes a state file on exit. -Designed to run INSIDE a CI container via docker exec (no TTY allocated). +Claude Code stream-json event types: + {"type":"system","subtype":"init","session_id":"..."} + {"type":"assistant","message":{"content":[{"type":"text","text":"..."}, + {"type":"tool_use","name":"..."}]}, + "session_id":"..."} + {"type":"tool_result","tool_use_id":"...","content":"...","session_id":"..."} + {"type":"result","subtype":"success","result":"...","session_id":"..."} + +Designed to run INSIDE a CI container via docker exec. Requires: rich (from requirements.txt). """ from __future__ import annotations @@ -22,8 +30,8 @@ CLAUDE_STDERR_LOG = "/ros_ws/.claude_stderr.log" EVENT_DEBUG_LOG = "/ros_ws/.ci_fix_events.jsonl" -# force_terminal=True is required because docker exec without -t -# doesn't allocate a PTY, so Rich would otherwise suppress all ANSI output. +# force_terminal=True is required because docker exec may not allocate a PTY, +# which would cause Rich to suppress all ANSI output. console = Console(stderr=True, force_terminal=True) @@ -58,28 +66,38 @@ def format_elapsed(start_time): return f"{seconds}s" -def handle_event(event, start_time): - """Handle a single stream-json event. Returns session_id if found, else None.""" - session_id = event.get("session_id") or None +def handle_assistant_event(message, start_time): + """Display content blocks from an assistant message event.""" + for block in message.get("content", []): + block_type = block.get("type", "") - # Unwrap nested event wrapper if present - inner = event.get("event", event) - event_type = inner.get("type", "") + if block_type == "text": + text = block.get("text", "") + if text: + sys.stderr.write(text) + sys.stderr.flush() - if event_type == "content_block_start": - block = inner.get("content_block", {}) - if block.get("type") == "tool_use": + elif block_type == "tool_use": tool_name = block.get("name", "unknown") console.print( - f" [dim]tool:[/dim] [bold]{tool_name}[/bold] " + f"\n [dim]tool:[/dim] [bold]{tool_name}[/bold] " f"[dim][{format_elapsed(start_time)}][/dim]" ) - elif event_type == "content_block_delta": - delta = inner.get("delta", {}) - if delta.get("type") == "text_delta": - sys.stderr.write(delta.get("text", "")) - sys.stderr.flush() + +def handle_event(event, start_time): + """Handle a single stream-json event. Returns session_id if found, else None.""" + session_id = event.get("session_id") or None + event_type = event.get("type", "") + + if event_type == "assistant": + message = event.get("message", {}) + handle_assistant_event(message, start_time) + + elif event_type == "tool_result": + console.print( + f" [dim]done[/dim] [{format_elapsed(start_time)}]" + ) return session_id From 9e56f67101338e32466a9f8d48c535af1e61c57a Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 18:47:42 +0000 Subject: [PATCH 40/60] fix resume_claude: add IS_SANDBOX=1 for root containers Without IS_SANDBOX=1, Claude refuses to run as root and shows the login screen instead of resuming the session. Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/claude_setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index 588909c..c9f593c 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -149,11 +149,11 @@ def copy_display_script(container_name): session_id=$(python3 -c "import json,sys; print(json.load(open('$state_file'))['session_id'])") if [ -z "$session_id" ] || [ "$session_id" = "None" ]; then echo "No session_id in state file. Starting fresh Claude session." - cd /ros_ws && claude --dangerously-skip-permissions + cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions return fi echo "Resuming Claude session ${session_id}..." - cd /ros_ws && claude --dangerously-skip-permissions --resume "$session_id" + cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions --resume "$session_id" } ''' From 72163869c759c8eb9bc6546fdbcfd13837a431b0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 18:49:21 +0000 Subject: [PATCH 41/60] add session handoff doc and TODO.md for ci_tool Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/TODO.md | 16 ++++ .../2026-02-18-ci-tool-session-handoff.md | 82 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 bin/ci_tool/TODO.md create mode 100644 docs/plans/2026-02-18-ci-tool-session-handoff.md diff --git a/bin/ci_tool/TODO.md b/bin/ci_tool/TODO.md new file mode 100644 index 0000000..009cf1b --- /dev/null +++ b/bin/ci_tool/TODO.md @@ -0,0 +1,16 @@ +# ci_tool TODO + +## Bug Fixes + +- [ ] If branch name is empty/blank, default to the repo's default branch instead of requiring input + +## Testing + +- [ ] Add unit tests for each module, leveraging the clean separation of concerns: + - **ci_reproduce.py**: `_parse_repo_url` (edge cases: trailing slashes, `.git` suffix, invalid URLs, non-GitHub URLs), `_fetch_github_raw_file` (HTTP errors, timeouts, bad tokens), `prompt_for_reproduce_args` / `prompt_for_repo_and_branch` (input validation) + - **ci_fix.py**: `extract_run_id_from_url` (valid/invalid/malformed URLs), `extract_info_from_ci_url` (API errors, missing fields, bad URLs), `gather_session_info` (all input combinations: with/without CI URL, new/resume, empty fields) + - **containers.py**: `sanitize_container_name`, `container_exists`/`container_is_running` (mock docker calls) + - **preflight.py**: each check in isolation (mock docker/gh/claude) + - **cli.py**: `dispatch_subcommand` routing, `_handle_container_collision` (all three choices) +- [ ] Input validation / boundary tests: verify weird input combinations at module boundaries (e.g. gather_session_info output dict is always valid input for reproduce_ci, prompt outputs satisfy reproduce_ci preconditions) +- [ ] Integration-style tests: mock Docker/GitHub and run full flows end-to-end (new session, resume session, reproduce-only) diff --git a/docs/plans/2026-02-18-ci-tool-session-handoff.md b/docs/plans/2026-02-18-ci-tool-session-handoff.md new file mode 100644 index 0000000..84ef082 --- /dev/null +++ b/docs/plans/2026-02-18-ci-tool-session-handoff.md @@ -0,0 +1,82 @@ +# CI Tool Rearchitect — Session Handoff + +**Branch:** `ERD-1633_reproduce_ci_locally_tool` in `/cortex/er_build_tools` +**Date:** 2026-02-18 + +## What Was Done + +Full rearchitect of the `bin/ci_tool` Python package: + +1. **Removed dead code** — `rename_container` from containers.py +2. **Rewrote ci_reproduce.py** — Python Docker orchestration replaces bash wrapper chain. `reproduce_ci()` is a pure function (no interactive prompts). Fetches scripts from `er_build_tools_internal` via urllib, creates Docker containers directly. +3. **Refactored ci_fix.py** — `gather_session_info()` consolidates all prompting up front. Fixes the original bug where skipping the CI URL didn't prompt for repo/branch. +4. **Adapted cli.py** — `_handle_container_collision()` extracted. Container collision handling is the caller's responsibility, not `reproduce_ci`'s. +5. **Updated setup.sh** — Installs ci_tool and hands off to Python. +6. **Fixed display_progress.py** — Rewrote event handler to match Claude Code's actual stream-json format (`{"type":"assistant","message":{"content":[...]}}` not Anthropic API format). Added `force_terminal=True`. +7. **Fixed claude_setup.py** — Added `IS_SANDBOX=1` to `resume_claude` function. +8. **All files lint clean** — pylint 10.00/10 across all changed modules. + +## Outstanding Issues to Debug on Test Machine + +### 1. Empty workspace after reproduce (HIGH PRIORITY) + +The `ci_workspace_setup.sh` runs inside the container but the workspace ends up empty (`/ros_ws/src/` has nothing). Need to: + +```bash +# Check env vars were passed correctly to the container +docker exec er_ci_main env | grep -E 'REPO_URL|REPO_NAME|ORG|BRANCH|GH_TOKEN|DEPS_FILE' + +# Check if the setup script is mounted +docker exec er_ci_main ls -la /tmp/ci_workspace_setup.sh + +# Re-run setup manually to see errors +docker exec er_ci_main bash /tmp/ci_workspace_setup.sh +``` + +The `_docker_exec_workspace_setup()` in ci_reproduce.py treats all non-zero exit codes as "expected if tests failed" but doesn't distinguish setup failures from test failures. + +### 2. Display progress — no spinners + +The display now shows text and tool names (format fix worked) but has no animated spinners. Rich's `Live` display was removed because it swallowed all output in docker exec. The `-t` flag is now passed to docker exec. Could try re-adding a spinner now that `-t` is set, or use a simpler periodic timer approach. + +### 3. resume_claude auth (just pushed fix) + +Added `IS_SANDBOX=1` to the `resume_claude` bash function. Without it, Claude shows the login screen instead of resuming. Needs testing. + +### 4. CDN caching + +`ci_tool()` in `.helper_bash_functions` fetches Python files from `raw.githubusercontent.com` which caches aggressively (sometimes minutes). For rapid iteration, either: +- Copy files directly: `cp /cortex/er_build_tools/bin/ci_tool/*.py ~/.ci_tool/ci_tool/` +- Or run locally: `cd /cortex/er_build_tools/bin && python3 -m ci_tool` + +### 5. Test repo + +Use `https://github.com/Extend-Robotics/er_ci_test_fixture` for integration testing (noted in TODO.md). + +## Key Files + +- `bin/ci_tool/ci_reproduce.py` — Docker orchestration, script fetching, prompting +- `bin/ci_tool/ci_fix.py` — Claude workflow, session management, prompting +- `bin/ci_tool/cli.py` — Menu routing, container collision handling +- `bin/ci_tool/containers.py` — Low-level Docker helpers +- `bin/ci_tool/display_progress.py` — Stream-json event display +- `bin/ci_tool/claude_setup.py` — Claude installation and config in containers +- `bin/ci_tool/TODO.md` — Future work items +- `.helper_bash_functions` — Bash wrapper that fetches and runs ci_tool + +## Key Design Decisions + +- `reproduce_ci()` is pure — callers handle container collisions and preflight +- `gather_session_info()` collects ALL user input before any work starts +- `prompt_for_repo_and_branch()` is shared between ci_fix.py and ci_reproduce.py +- `DEFAULT_SCRIPTS_BRANCH` reads `CI_TOOL_SCRIPTS_BRANCH` env var, defaults to `ERD-1633_reproduce_ci_locally` (change to `main` when internal scripts are merged) +- Graphical mode fails fast if DISPLAY not set (CLAUDE.md: no fallback behavior) +- `force_terminal=True` on Rich Console + `docker exec -t` for display + +## CLAUDE.md Rules + +- KISS, YAGNI, SOLID +- Self-documenting variable names, minimal comments +- Fail fast — no fallback behaviour or silent defaults +- Linters must pass (pylint 10.00/10) +- ROS1 Noetic, Python 3.8 compatibility required From 76f4c7c02a199195e2e7d0f7f7361badb4e2860c Mon Sep 17 00:00:00 2001 From: Build Tools Date: Wed, 18 Feb 2026 19:16:36 +0000 Subject: [PATCH 42/60] lots of silent failures --- bin/ci_tool/ci_fix.py | 28 ++++++++++++++++--- bin/ci_tool/ci_reproduce.py | 33 ++++++++++++++++++---- bin/ci_tool/claude_setup.py | 49 ++++++++++++++++++++++++--------- bin/ci_tool/containers.py | 6 ++++ bin/ci_tool/display_progress.py | 10 +++++-- 5 files changed, 101 insertions(+), 25 deletions(-) diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index c2d350d..e4f7e0a 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -113,6 +113,10 @@ def read_container_state(container_name): try: return json.loads(result.stdout) except json.JSONDecodeError: + console.print( + f"[yellow]State file exists but contains invalid JSON: " + f"{result.stdout[:200]}[/yellow]" + ) return None @@ -355,23 +359,35 @@ def run_claude_streamed(container_name, prompt): """Run Claude non-interactively with stream-json output.""" escaped_prompt = prompt.replace("'", "'\\''") claude_command = ( + f"set -o pipefail && " f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " f"-p '{escaped_prompt}' --verbose --output-format stream-json " f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) - docker_exec(container_name, claude_command, tty=True, check=False, quiet=True) + result = docker_exec(container_name, claude_command, tty=True, check=False, quiet=True) + if result.returncode != 0: + console.print( + f"[yellow]Claude exited with code {result.returncode} — " + f"check {CLAUDE_STDERR_LOG} inside the container for details[/yellow]" + ) def run_claude_resumed(container_name, session_id, prompt): """Resume a Claude session with a new prompt, streaming output.""" escaped_prompt = prompt.replace("'", "'\\''") claude_command = ( + f"set -o pipefail && " f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " f"--resume '{session_id}' -p '{escaped_prompt}' " f"--verbose --output-format stream-json " f"2>{CLAUDE_STDERR_LOG} | ci_fix_display" ) - docker_exec(container_name, claude_command, tty=True, check=False, quiet=True) + result = docker_exec(container_name, claude_command, tty=True, check=False, quiet=True) + if result.returncode != 0: + console.print( + f"[yellow]Claude exited with code {result.returncode} — " + f"check {CLAUDE_STDERR_LOG} inside the container for details[/yellow]" + ) def prompt_user_for_feedback(): @@ -431,7 +447,7 @@ def run_claude_workflow(container_name, ci_run_info): # Fix phase (resume session) state = read_container_state(container_name) - session_id = state["session_id"] if state else None + session_id = state.get("session_id") if state else None if session_id: console.print( "\n[bold cyan]Resuming Claude " @@ -540,13 +556,17 @@ def fix_ci(_args): console.print( "[dim]You are now in an interactive Claude session[/dim]\n" ) - docker_exec( + result = docker_exec( container_name, "cd /ros_ws && IS_SANDBOX=1 claude " "--dangerously-skip-permissions " f'--resume "{resume_session_id}"', interactive=True, check=False, ) + if result.returncode != 0: + console.print( + f"[yellow]Claude exited with code {result.returncode}[/yellow]" + ) else: run_claude_workflow( container_name, session.get("ci_run_info") diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index 832f230..68a2631 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -199,10 +199,20 @@ def _docker_create_and_start( console.print(f" [green]\u2713[/green] Container '{container_name}' started") +def _container_path_exists(container_name, path): + """Check if a path exists inside a container.""" + result = subprocess.run( + ["docker", "exec", container_name, "test", "-e", path], + check=False, + ) + return result.returncode == 0 + + def _docker_exec_workspace_setup(container_name): """Run ci_workspace_setup.sh inside the container. - Handles KeyboardInterrupt gracefully by letting the container keep running. + Raises RuntimeError if setup fails before the build completes. + Warns (but continues) if tests fail after a successful build. """ console.print("\n Running CI workspace setup inside container...") try: @@ -215,14 +225,27 @@ def _docker_exec_workspace_setup(container_name): "\n[yellow]Interrupted during workspace setup " "-- container is still running with partial setup[/yellow]" ) + raise + + if result.returncode == 0: return - if result.returncode != 0: - console.print( - f"\n[yellow]Workspace setup exited with code {result.returncode} " - f"(expected if tests failed)[/yellow]" + # Non-zero exit: distinguish setup failure from test failure by checking + # whether the build actually completed (/ros_ws/install only exists after + # a successful colcon build). + build_completed = _container_path_exists(container_name, "/ros_ws/install") + + if not build_completed: + raise RuntimeError( + f"Workspace setup failed (exit code {result.returncode}). " + f"The build did not complete — check the output above for errors." ) + console.print( + f"\n[yellow]Tests exited with code {result.returncode} " + f"(build succeeded, test failures are expected)[/yellow]" + ) + def prompt_for_repo_and_branch(): """Ask user for repository URL and branch name interactively. diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index c9f593c..6a2d3ab 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -34,9 +34,11 @@ def install_claude_in_container(container_name): def install_fzf_in_container(container_name): - """Install fzf in the container.""" + """Install fzf in the container (non-critical).""" console.print("[cyan]Installing fzf in container...[/cyan]") - docker_exec(container_name, "apt-get update && apt-get install -y fzf", check=False) + result = docker_exec(container_name, "apt-get update && apt-get install -y fzf", check=False) + if result.returncode != 0: + console.print("[yellow]fzf installation failed (non-critical) — continuing[/yellow]") def install_python_deps_in_container(container_name): @@ -187,7 +189,10 @@ def save_package_list(container_name): quiet=True, ) if result.returncode != 0: - console.print("[yellow]Could not save package list (colcon list failed)[/yellow]") + console.print( + "[yellow]Could not save package list (colcon list failed). " + "The 'rerun_tests' helper will not work.[/yellow]" + ) def inject_rerun_tests_function(container_name): @@ -267,7 +272,7 @@ def get_host_git_config(key): def configure_git_in_container(container_name): """Set up git identity, token-based auth, and gh CLI auth in the container.""" - gh_token = os.environ.get("GH_TOKEN", "") + gh_token = os.environ.get("GH_TOKEN") or os.environ.get("ER_SETUP_TOKEN") or "" git_user_name = get_host_git_config("user.name") git_user_email = get_host_git_config("user.email") @@ -276,20 +281,26 @@ def configure_git_in_container(container_name): docker_exec(container_name, f'git config --global user.name "{git_user_name}"') docker_exec(container_name, f'git config --global user.email "{git_user_email}"') - if gh_token: - docker_exec( - container_name, - f'git config --global url."https://{gh_token}@github.com/"' - f'.insteadOf "https://github.com/"', - quiet=True, + if not gh_token: + console.print( + "[yellow]No GH_TOKEN or ER_SETUP_TOKEN found — " + "git auth and gh CLI will not be configured in container[/yellow]" ) - install_and_auth_gh_cli(container_name, gh_token) + return + + docker_exec( + container_name, + f'git config --global url."https://{gh_token}@github.com/"' + f'.insteadOf "https://github.com/"', + quiet=True, + ) + install_and_auth_gh_cli(container_name, gh_token) def install_and_auth_gh_cli(container_name, gh_token): """Install gh CLI and authenticate with the provided token.""" console.print("[cyan]Installing gh CLI in container...[/cyan]") - docker_exec(container_name, ( + install_result = docker_exec(container_name, ( "type gh >/dev/null 2>&1 || (" "curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg " "| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg " @@ -300,13 +311,25 @@ def install_and_auth_gh_cli(container_name, gh_token): "| tee /etc/apt/sources.list.d/github-cli-stable.list > /dev/null " "&& apt-get update && apt-get install -y gh)" ), check=False) + if install_result.returncode != 0: + console.print( + "[yellow]gh CLI installation failed — " + "Claude will not be able to interact with GitHub from inside the container[/yellow]" + ) + return + console.print("[cyan]Authenticating gh CLI...[/cyan]") - docker_exec( + auth_result = docker_exec( container_name, f'echo "{gh_token}" | gh auth login --with-token', check=False, quiet=True, ) + if auth_result.returncode != 0: + console.print( + "[yellow]gh CLI authentication failed — " + "Claude will not be able to interact with GitHub from inside the container[/yellow]" + ) def is_claude_installed_in_container(container_name): diff --git a/bin/ci_tool/containers.py b/bin/ci_tool/containers.py index c10f0d4..cc923a0 100644 --- a/bin/ci_tool/containers.py +++ b/bin/ci_tool/containers.py @@ -35,6 +35,8 @@ def container_exists(container_name=DEFAULT_CONTAINER_NAME): ["docker", "ps", "-a", "--filter", f"name=^{container_name}$", "--format", "{{.Names}}"], capture_output=True, text=True, check=False, ) + if result.returncode != 0: + raise RuntimeError(f"docker ps failed: {result.stderr.strip()}") return container_name in result.stdout.strip() @@ -44,6 +46,8 @@ def container_is_running(container_name=DEFAULT_CONTAINER_NAME): ["docker", "ps", "--filter", f"name=^{container_name}$", "--format", "{{.Names}}"], capture_output=True, text=True, check=False, ) + if result.returncode != 0: + raise RuntimeError(f"docker ps failed: {result.stderr.strip()}") return container_name in result.stdout.strip() @@ -65,6 +69,8 @@ def list_ci_containers(): "--format", "{{.Names}}\t{{.Status}}"], capture_output=True, text=True, check=False, ) + if result.returncode != 0: + raise RuntimeError(f"docker ps failed: {result.stderr.strip()}") if not result.stdout.strip(): return [] containers = [] diff --git a/bin/ci_tool/display_progress.py b/bin/ci_tool/display_progress.py index cdcac07..3144aef 100644 --- a/bin/ci_tool/display_progress.py +++ b/bin/ci_tool/display_progress.py @@ -52,7 +52,10 @@ def read_existing_attempt_count(): try: with open(STATE_FILE, encoding="utf-8") as state_file: return json.load(state_file).get("attempt_count", 0) - except (FileNotFoundError, json.JSONDecodeError, KeyError): + except FileNotFoundError: + return 0 + except (json.JSONDecodeError, KeyError) as error: + console.print(f"[yellow]State file corrupt, resetting attempt count: {error}[/yellow]") return 0 @@ -120,7 +123,7 @@ def print_session_summary(session_id, start_time): console.print("[yellow]Claude stderr output:[/yellow]") console.print(stderr_content) except FileNotFoundError: - pass + console.print(f"[dim]No stderr log at {CLAUDE_STDERR_LOG}[/dim]") def main(): @@ -145,6 +148,7 @@ def main(): try: event = json.loads(line) except json.JSONDecodeError: + sys.stderr.write(f" {line}\n") continue event_session_id = handle_event(event, start_time) @@ -155,7 +159,7 @@ def main(): except KeyboardInterrupt: phase = "interrupted" - except Exception: # pylint: disable=broad-except + except (IOError, ValueError, UnicodeDecodeError): console.print( f"\n[red]Display processor error:[/red]\n{traceback.format_exc()}" ) From 177a366ccfb865ddd018def8b0721a1ec95add07 Mon Sep 17 00:00:00 2001 From: Build Tools Date: Wed, 18 Feb 2026 19:37:15 +0000 Subject: [PATCH 43/60] gh auth --- bin/ci_tool/claude_setup.py | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index 6a2d3ab..b84eb5f 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -294,11 +294,14 @@ def configure_git_in_container(container_name): f'.insteadOf "https://github.com/"', quiet=True, ) - install_and_auth_gh_cli(container_name, gh_token) + install_gh_cli(container_name) -def install_and_auth_gh_cli(container_name, gh_token): - """Install gh CLI and authenticate with the provided token.""" +def install_gh_cli(container_name): + """Install gh CLI in the container. + + Authentication is handled by the GH_TOKEN env var already set on the container. + """ console.print("[cyan]Installing gh CLI in container...[/cyan]") install_result = docker_exec(container_name, ( "type gh >/dev/null 2>&1 || (" @@ -316,20 +319,6 @@ def install_and_auth_gh_cli(container_name, gh_token): "[yellow]gh CLI installation failed — " "Claude will not be able to interact with GitHub from inside the container[/yellow]" ) - return - - console.print("[cyan]Authenticating gh CLI...[/cyan]") - auth_result = docker_exec( - container_name, - f'echo "{gh_token}" | gh auth login --with-token', - check=False, - quiet=True, - ) - if auth_result.returncode != 0: - console.print( - "[yellow]gh CLI authentication failed — " - "Claude will not be able to interact with GitHub from inside the container[/yellow]" - ) def is_claude_installed_in_container(container_name): From 3c333755b3c812234809c76d996eec0b2ccf64f4 Mon Sep 17 00:00:00 2001 From: Build Tools Date: Wed, 18 Feb 2026 19:39:51 +0000 Subject: [PATCH 44/60] reduce token usage: grep logs instead of reading whole files --- bin/ci_tool/ci_context/CLAUDE.md | 6 ++++++ bin/ci_tool/ci_fix.py | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/bin/ci_tool/ci_context/CLAUDE.md b/bin/ci_tool/ci_context/CLAUDE.md index c6abab8..9e22561 100644 --- a/bin/ci_tool/ci_context/CLAUDE.md +++ b/bin/ci_tool/ci_context/CLAUDE.md @@ -3,6 +3,12 @@ You are inside a CI reproduction container. The ROS workspace is at `/ros_ws/`. Source code is under `/ros_ws/src/`. Built packages install to `/ros_ws/install/`. +## Token Efficiency + +Use Grep to search log files for relevant errors — never read entire log files. +When examining test output, search for FAILURE, FAILED, ERROR, or assertion messages. +Pipe long command output through `tail -200` or `grep` to avoid dumping huge logs. + ## Environment Setup ```bash diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index e4f7e0a..70cc294 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -60,7 +60,8 @@ ANALYSIS_PROMPT_TEMPLATE = ( ROS_SOURCE_PREAMBLE + "The CI tests have already been run. Analyse the failures:\n" - "1. Examine the test output in /ros_ws/test_output.log\n" + "1. Use Grep to search /ros_ws/test_output.log for FAILURE, FAILED, ERROR, " + "and assertion messages. Do NOT read the entire file.\n" "2. For each failing test, report:\n" " - Package and test name\n" " - The error/assertion message\n" @@ -78,8 +79,8 @@ " --jq '.head_sha'`\n" " - If they differ, determine whether the missing/extra commits " "explain the failure\n" - "- Fetch CI logs: `gh run view {run_id} --log-failed` " - "(use `--log` for full output if needed)\n" + "- Fetch CI logs: `gh run view {run_id} --log-failed 2>&1 | tail -200` " + "(increase if needed, but avoid dumping full logs)\n" "- Compare CI failures with local test results\n" ) From 637b6b48a335a61f301baddcbdd43debf45b24a1 Mon Sep 17 00:00:00 2001 From: Build Tools Date: Wed, 18 Feb 2026 19:43:19 +0000 Subject: [PATCH 45/60] add persistent learnings file across ci_tool sessions --- bin/ci_tool/ci_context/CLAUDE.md | 13 +++++++++++ bin/ci_tool/ci_fix.py | 40 ++++++++++++++++++++++++++++++-- bin/ci_tool/claude_setup.py | 37 ++++++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/bin/ci_tool/ci_context/CLAUDE.md b/bin/ci_tool/ci_context/CLAUDE.md index 9e22561..64c3e04 100644 --- a/bin/ci_tool/ci_context/CLAUDE.md +++ b/bin/ci_tool/ci_context/CLAUDE.md @@ -100,6 +100,19 @@ ROS Noetic catkin workspace for multi-robot assemblies: Configuration pipeline: Assembly configs → robot configs → Jinja2 templates → URDF/SRDF/controllers. Generated files output to `/tmp/`. +## Learnings + +If `/ros_ws/.ci_learnings.md` exists, read it before starting — it contains lessons from +previous CI fix sessions for this repo. + +After fixing CI failures, update `/ros_ws/.ci_learnings.md` with any new insights: +- Root causes that were non-obvious +- Patterns that recur (e.g. "this repo often breaks because of X") +- Debugging techniques that saved time +- False leads to avoid next time + +Keep it concise. This file persists across sessions. + ## Design Principles - Explicit over implicit. Use named fields with clear values, not absence-of-key or empty-dict semantics. diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 70cc294..5d55e24 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -18,12 +18,19 @@ copy_ci_context, copy_claude_credentials, copy_display_script, + copy_learnings_from_container, + copy_learnings_to_container, inject_rerun_tests_function, inject_resume_function, is_claude_installed_in_container, save_package_list, setup_claude_in_container, ) +from ci_tool.ci_reproduce import ( + _parse_repo_url, + prompt_for_reproduce_args, + reproduce_ci, +) from ci_tool.containers import ( container_exists, container_is_running, @@ -34,7 +41,6 @@ sanitize_container_name, start_container, ) -from ci_tool.ci_reproduce import prompt_for_reproduce_args, reproduce_ci from ci_tool.preflight import run_all_preflight_checks, PreflightError console = Console() @@ -505,6 +511,27 @@ def drop_to_shell(container_name): docker_exec_interactive(container_name) +def _read_container_env(container_name, var_name): + """Read an environment variable from a running container.""" + result = subprocess.run( + ["docker", "exec", container_name, "printenv", var_name], + capture_output=True, text=True, check=False, + ) + return result.stdout.strip() if result.returncode == 0 else "" + + +def _resolve_org_repo(session, container_name): + """Resolve (org, repo_name) from session info or container env vars.""" + repo_url = session.get("repo_url") + if repo_url: + org, repo_name, _ = _parse_repo_url(repo_url) + return org, repo_name + + org = _read_container_env(container_name, "ORG") + repo_name = _read_container_env(container_name, "REPO_NAME") + return org, repo_name + + def fix_ci(_args): """Main fix workflow: gather -> preflight -> reproduce -> Claude -> shell. @@ -548,6 +575,11 @@ def fix_ci(_args): else: setup_claude_in_container(container_name) + # Step 4b: Copy learnings into container + org, repo_name = _resolve_org_repo(session, container_name) + if org and repo_name: + copy_learnings_to_container(container_name, org, repo_name) + # Step 5: Run Claude resume_session_id = session.get("resume_session_id") if resume_session_id: @@ -573,5 +605,9 @@ def fix_ci(_args): container_name, session.get("ci_run_info") ) - # Step 6: Drop to shell + # Step 6: Save learnings from container back to host + if org and repo_name: + copy_learnings_from_container(container_name, org, repo_name) + + # Step 7: Drop to shell drop_to_shell(container_name) diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index b84eb5f..f7f5100 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -10,10 +10,13 @@ from rich.console import Console -from ci_tool.containers import docker_exec, docker_cp_to_container +from ci_tool.containers import docker_exec, docker_cp_to_container, run_command console = Console() +LEARNINGS_HOST_DIR = Path.home() / ".ci_tool" / "learnings" +LEARNINGS_CONTAINER_PATH = "/ros_ws/.ci_learnings.md" + CLAUDE_HOME = Path.home() / ".claude" CI_CONTEXT_DIR = Path(__file__).parent / "ci_context" @@ -239,6 +242,38 @@ def copy_claude_memory(container_name): ) +def _learnings_host_path(org, repo_name): + """Return the host path for a repo's learnings file.""" + return LEARNINGS_HOST_DIR / f"{org}_{repo_name}.md" + + +def copy_learnings_to_container(container_name, org, repo_name): + """Copy repo-specific learnings file into the container (if it exists).""" + host_path = _learnings_host_path(org, repo_name) + if not host_path.exists(): + return + console.print("[cyan]Copying CI learnings into container...[/cyan]") + docker_cp_to_container(str(host_path), container_name, LEARNINGS_CONTAINER_PATH) + + +def copy_learnings_from_container(container_name, org, repo_name): + """Copy learnings file back from container to host (if Claude updated it).""" + result = subprocess.run( + ["docker", "exec", container_name, "test", "-s", LEARNINGS_CONTAINER_PATH], + check=False, + ) + if result.returncode != 0: + return + + host_path = _learnings_host_path(org, repo_name) + host_path.parent.mkdir(parents=True, exist_ok=True) + run_command( + ["docker", "cp", f"{container_name}:{LEARNINGS_CONTAINER_PATH}", str(host_path)], + quiet=True, + ) + console.print(f"[green]Learnings saved to {host_path}[/green]") + + def copy_helper_bash_functions(container_name): """Copy ~/.helper_bash_functions and source it in bashrc.""" helper_path = Path.home() / ".helper_bash_functions" From 21c9761b4c60d21db3303c950840f67d463cb9f7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 19:59:58 +0000 Subject: [PATCH 46/60] reduce test output verbosity for colcon test results Replace colcon test-result --verbose with compact failure reporting: - Skip CTest XMLs (contain entire roslaunch logs) by using test_results/ subdirectory as the test-result-base - Parse JUnit XMLs directly to show only failing test names, assertion messages, and tracebacks (first 20 lines) - All-pass runs show a single summary line Applies to both the helper bash functions (colcon_test_this_package) and the rerun_tests function injected into CI containers. --- .helper_bash_functions | 23 ++++++++++++++++++++--- bin/ci_tool/claude_setup.py | 20 +++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.helper_bash_functions b/.helper_bash_functions index 1a3b31a..a2d486a 100644 --- a/.helper_bash_functions +++ b/.helper_bash_functions @@ -26,15 +26,32 @@ colcon_build() { START_DIR=$(pwd) && \ } colcon_build_this() { colcon_build "$(find_cmake_project_names_from_dir .)"; } rosdep_install() { rosdep install --from-paths src --ignore-src -r -y ; } +_show_test_failures() { + python3 - "$@" <<'PYEOF' +import sys, xml.etree.ElementTree as ET +from pathlib import Path +for d in sys.argv[1:]: + for p in sorted(Path(d).rglob("*.xml")): + for tc in ET.parse(p).iter("testcase"): + for f in list(tc.iter("failure")) + list(tc.iter("error")): + tag = "FAIL" if f.tag == "failure" else "ERROR" + print(f"\n {tag}: {tc.get('classname', '')}.{tc.get('name', '')}") + if f.text: + lines = f.text.strip().splitlines() + for l in lines[:20]: + print(f" {l}") + if len(lines) > 20: + print(f" ... ({len(lines) - 20} more lines)") +PYEOF +} colcon_test_these_packages() { THIS_DIR=$(pwd) cd ${CATKIN_WS_PATH} colcon_build_no_deps $1 source install/setup.bash && \ colcon test --packages-select $1 for pkg in $1; do - if ! colcon test-result --test-result-base "build/$pkg"; then - colcon test-result --verbose --test-result-base "build/$pkg" > "/tmp/colcon_test_verbose_${pkg}.log" 2>&1 - echo -e "${Red}Verbose output saved to: /tmp/colcon_test_verbose_${pkg}.log${Color_Off}" + if ! colcon test-result --test-result-base "build/$pkg/test_results"; then + _show_test_failures "build/$pkg/test_results" fi done cd $THIS_DIR diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index f7f5100..bf22d61 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -138,7 +138,25 @@ def copy_display_script(container_name): colcon build --packages-select ${packages} --cmake-args -DSETUPTOOLS_DEB_LAYOUT=OFF source /ros_ws/install/setup.bash colcon test --packages-select ${packages} - colcon test-result --verbose + for pkg in ${packages}; do + if ! colcon test-result --test-result-base "build/$pkg/test_results"; then + python3 - "build/$pkg/test_results" <<'PYEOF' +import sys, xml.etree.ElementTree as ET +from pathlib import Path +for p in sorted(Path(sys.argv[1]).rglob("*.xml")): + for tc in ET.parse(p).iter("testcase"): + for f in list(tc.iter("failure")) + list(tc.iter("error")): + tag = "FAIL" if f.tag == "failure" else "ERROR" + print(f"\n {tag}: {tc.get('classname', '')}.{tc.get('name', '')}") + if f.text: + lines = f.text.strip().splitlines() + for l in lines[:20]: + print(f" {l}") + if len(lines) > 20: + print(f" ... ({len(lines) - 20} more lines)") +PYEOF + fi + done } ''' From 66f8acef0667b49bb8c9842db3e3adc85e48a8a0 Mon Sep 17 00:00:00 2001 From: Build Tools Date: Wed, 18 Feb 2026 20:19:10 +0000 Subject: [PATCH 47/60] render claude output as rich markdown (tables, headers, code) --- bin/ci_tool/TODO.md | 3 +++ bin/ci_tool/display_progress.py | 26 ++++++++++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/bin/ci_tool/TODO.md b/bin/ci_tool/TODO.md index 009cf1b..202a497 100644 --- a/bin/ci_tool/TODO.md +++ b/bin/ci_tool/TODO.md @@ -4,6 +4,9 @@ - [ ] If branch name is empty/blank, default to the repo's default branch instead of requiring input +## Style +- [x] ~~Render markdown in terminal~~ — display_progress.py now buffers text between tool calls and renders via `rich.markdown.Markdown` (tables, headers, code blocks, bold/italic) + ## Testing - [ ] Add unit tests for each module, leveraging the clean separation of concerns: diff --git a/bin/ci_tool/display_progress.py b/bin/ci_tool/display_progress.py index 3144aef..9f25709 100644 --- a/bin/ci_tool/display_progress.py +++ b/bin/ci_tool/display_progress.py @@ -25,6 +25,7 @@ from datetime import datetime, timezone from rich.console import Console +from rich.markdown import Markdown STATE_FILE = "/ros_ws/.ci_fix_state.json" CLAUDE_STDERR_LOG = "/ros_ws/.claude_stderr.log" @@ -69,7 +70,17 @@ def format_elapsed(start_time): return f"{seconds}s" -def handle_assistant_event(message, start_time): +def flush_text_buffer(text_buffer): + """Render accumulated text as rich markdown and clear the buffer.""" + if not text_buffer: + return + combined = "".join(text_buffer) + text_buffer.clear() + if combined.strip(): + console.print(Markdown(combined)) + + +def handle_assistant_event(message, start_time, text_buffer): """Display content blocks from an assistant message event.""" for block in message.get("content", []): block_type = block.get("type", "") @@ -77,10 +88,10 @@ def handle_assistant_event(message, start_time): if block_type == "text": text = block.get("text", "") if text: - sys.stderr.write(text) - sys.stderr.flush() + text_buffer.append(text) elif block_type == "tool_use": + flush_text_buffer(text_buffer) tool_name = block.get("name", "unknown") console.print( f"\n [dim]tool:[/dim] [bold]{tool_name}[/bold] " @@ -88,16 +99,17 @@ def handle_assistant_event(message, start_time): ) -def handle_event(event, start_time): +def handle_event(event, start_time, text_buffer): """Handle a single stream-json event. Returns session_id if found, else None.""" session_id = event.get("session_id") or None event_type = event.get("type", "") if event_type == "assistant": message = event.get("message", {}) - handle_assistant_event(message, start_time) + handle_assistant_event(message, start_time, text_buffer) elif event_type == "tool_result": + flush_text_buffer(text_buffer) console.print( f" [dim]done[/dim] [{format_elapsed(start_time)}]" ) @@ -132,6 +144,7 @@ def main(): attempt_count = read_existing_attempt_count() + 1 phase = "fixing" start_time = time.time() + text_buffer = [] try: console.print("[cyan] Working...[/cyan]") @@ -151,10 +164,11 @@ def main(): sys.stderr.write(f" {line}\n") continue - event_session_id = handle_event(event, start_time) + event_session_id = handle_event(event, start_time, text_buffer) if event_session_id: session_id = event_session_id + flush_text_buffer(text_buffer) phase = "completed" except KeyboardInterrupt: From 871a0e62772a71f3fec8662c1fed962b1d316505 Mon Sep 17 00:00:00 2001 From: Build Tools Date: Wed, 18 Feb 2026 20:32:08 +0000 Subject: [PATCH 48/60] sandbox --- bin/ci_tool/ci_reproduce.py | 1 + bin/ci_tool/claude_setup.py | 11 ----------- bin/ci_tool/containers.py | 4 ++-- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/bin/ci_tool/ci_reproduce.py b/bin/ci_tool/ci_reproduce.py index 68a2631..5975c70 100644 --- a/bin/ci_tool/ci_reproduce.py +++ b/bin/ci_tool/ci_reproduce.py @@ -337,6 +337,7 @@ def reproduce_ci( # pylint: disable=too-many-arguments "ONLY_NEEDED_DEPS": "true" if only_needed_deps else "false", "SKIP_TESTS": "false", "ADDITIONAL_COMMAND": "", + "IS_SANDBOX": "1", } # Volume mounts (scripts mounted read-only into the container) diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index bf22d61..793256c 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -383,16 +383,6 @@ def is_claude_installed_in_container(container_name): return result.returncode == 0 -def set_sandbox_env(container_name): - """Set IS_SANDBOX=1 so Claude allows --dangerously-skip-permissions as root.""" - docker_exec( - container_name, - "grep -q 'export IS_SANDBOX=1' /root/.bashrc " - "|| echo 'export IS_SANDBOX=1' >> /root/.bashrc", - quiet=True, - ) - - def setup_claude_in_container(container_name): """Full setup: install Claude Code and copy all config into container.""" console.print("\n[bold cyan]Setting up Claude in container...[/bold cyan]") @@ -407,7 +397,6 @@ def setup_claude_in_container(container_name): copy_display_script(container_name) inject_resume_function(container_name) inject_rerun_tests_function(container_name) - set_sandbox_env(container_name) copy_claude_memory(container_name) copy_helper_bash_functions(container_name) configure_git_in_container(container_name) diff --git a/bin/ci_tool/containers.py b/bin/ci_tool/containers.py index cc923a0..7e523f9 100644 --- a/bin/ci_tool/containers.py +++ b/bin/ci_tool/containers.py @@ -92,7 +92,7 @@ def docker_exec( # pylint: disable=too-many-arguments container_name, command, interactive=False, tty=False, check=True, quiet=False, ): """Run a command inside a container.""" - docker_command = ["docker", "exec"] + docker_command = ["docker", "exec", "-e", "IS_SANDBOX=1"] if interactive: docker_command.extend(["-it"]) elif tty: @@ -105,7 +105,7 @@ def docker_exec_interactive(container_name=DEFAULT_CONTAINER_NAME): """Drop user into an interactive shell inside the container.""" console.print(f"\n[bold cyan]Entering container '{container_name}'...[/bold cyan]") console.print("[dim]Type 'exit' to leave the container[/dim]\n") - os.execvp("docker", ["docker", "exec", "-it", container_name, "bash"]) + os.execvp("docker", ["docker", "exec", "-e", "IS_SANDBOX=1", "-it", container_name, "bash"]) def docker_cp_to_container(host_path, container_name, container_path): From 1b8dcbd6d5520524a1999635b44f8e6b9477a5d3 Mon Sep 17 00:00:00 2001 From: Build Tools Date: Wed, 18 Feb 2026 21:42:04 +0000 Subject: [PATCH 49/60] fixes --- bin/ci_tool/TODO.md | 8 +++++- bin/ci_tool/ci_fix.py | 51 ++++++++++++++++++++++--------------- bin/ci_tool/claude_setup.py | 33 +++++++++++++++++++++--- 3 files changed, 67 insertions(+), 25 deletions(-) diff --git a/bin/ci_tool/TODO.md b/bin/ci_tool/TODO.md index 202a497..638846d 100644 --- a/bin/ci_tool/TODO.md +++ b/bin/ci_tool/TODO.md @@ -4,8 +4,14 @@ - [ ] If branch name is empty/blank, default to the repo's default branch instead of requiring input -## Style +## Done - [x] ~~Render markdown in terminal~~ — display_progress.py now buffers text between tool calls and renders via `rich.markdown.Markdown` (tables, headers, code blocks, bold/italic) +- [x] ~~Empty workspace after reproduce~~ — `_docker_exec_workspace_setup()` distinguishes setup failures from test failures; `wstool scrape` fixed in internal repo +- [x] ~~Silent failures~~ — 21 issues audited and fixed across all modules +- [x] ~~resume_claude auth~~ — `IS_SANDBOX=1` passed via `docker exec -e` on all calls (`.bashrc` not sourced by non-interactive shells) +- [x] ~~gh CLI auth warning~~ — removed redundant `gh auth login` (GH_TOKEN env var handles auth) +- [x] ~~Token efficiency~~ — prompts updated to use grep instead of reading full logs +- [x] ~~Persistent learnings~~ — `~/.ci_tool/learnings/{org}_{repo}.md` persists between sessions ## Testing diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 5d55e24..610c675 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -24,6 +24,7 @@ inject_resume_function, is_claude_installed_in_container, save_package_list, + seed_claude_state, setup_claude_in_container, ) from ci_tool.ci_reproduce import ( @@ -421,6 +422,7 @@ def refresh_claude_config(container_name): copy_display_script(container_name) inject_resume_function(container_name) inject_rerun_tests_function(container_name) + seed_claude_state(container_name) def run_claude_workflow(container_name, ci_run_info): @@ -450,7 +452,11 @@ def run_claude_workflow(container_name, ci_run_info): # User review console.print() - user_feedback = prompt_user_for_feedback() + try: + user_feedback = prompt_user_for_feedback() + except KeyboardInterrupt: + console.print("\n[yellow]Interrupted — skipping fix phase.[/yellow]") + return # Fix phase (resume session) state = read_container_state(container_name) @@ -582,28 +588,31 @@ def fix_ci(_args): # Step 5: Run Claude resume_session_id = session.get("resume_session_id") - if resume_session_id: - console.print( - "\n[bold cyan]Resuming Claude session...[/bold cyan]" - ) - console.print( - "[dim]You are now in an interactive Claude session[/dim]\n" - ) - result = docker_exec( - container_name, - "cd /ros_ws && IS_SANDBOX=1 claude " - "--dangerously-skip-permissions " - f'--resume "{resume_session_id}"', - interactive=True, check=False, - ) - if result.returncode != 0: + try: + if resume_session_id: console.print( - f"[yellow]Claude exited with code {result.returncode}[/yellow]" + "\n[bold cyan]Resuming Claude session...[/bold cyan]" ) - else: - run_claude_workflow( - container_name, session.get("ci_run_info") - ) + console.print( + "[dim]You are now in an interactive Claude session[/dim]\n" + ) + result = docker_exec( + container_name, + "cd /ros_ws && IS_SANDBOX=1 claude " + "--dangerously-skip-permissions " + f'--resume "{resume_session_id}"', + interactive=True, check=False, + ) + if result.returncode != 0: + console.print( + f"[yellow]Claude exited with code {result.returncode}[/yellow]" + ) + else: + run_claude_workflow( + container_name, session.get("ci_run_info") + ) + except KeyboardInterrupt: + console.print("\n[yellow]Interrupted.[/yellow]") # Step 6: Save learnings from container back to host if org and repo_name: diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index 793256c..e55763c 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -358,14 +358,19 @@ def install_gh_cli(container_name): console.print("[cyan]Installing gh CLI in container...[/cyan]") install_result = docker_exec(container_name, ( "type gh >/dev/null 2>&1 || (" - "curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg " - "| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg " + "echo ' Downloading GPG key...' " + "&& curl -fSL --progress-bar -o /usr/share/keyrings/githubcli-archive-keyring.gpg " + "https://cli.github.com/packages/githubcli-archive-keyring.gpg " "&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg " + "&& echo ' Adding apt repository...' " '&& echo "deb [arch=$(dpkg --print-architecture) ' "signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] " 'https://cli.github.com/packages stable main" ' "| tee /etc/apt/sources.list.d/github-cli-stable.list > /dev/null " - "&& apt-get update && apt-get install -y gh)" + "&& echo ' Running apt-get update...' " + "&& apt-get update " + "&& echo ' Installing gh...' " + "&& apt-get install -y gh)" ), check=False) if install_result.returncode != 0: console.print( @@ -374,6 +379,27 @@ def install_gh_cli(container_name): ) +def seed_claude_state(container_name): + """Pre-seed /root/.claude.json so the onboarding wizard is skipped.""" + console.print("[cyan]Seeding Claude onboarding state...[/cyan]") + docker_exec( + container_name, + """python3 -c " +import json, os +path = '/root/.claude.json' +data = {} +if os.path.exists(path): + try: + data = json.load(open(path)) + except (json.JSONDecodeError, OSError): + pass +data['hasCompletedOnboarding'] = True +json.dump(data, open(path, 'w')) +" """, + quiet=True, + ) + + def is_claude_installed_in_container(container_name): """Check if Claude Code is already installed in the container.""" result = subprocess.run( @@ -389,6 +415,7 @@ def setup_claude_in_container(container_name): install_node_in_container(container_name) install_claude_in_container(container_name) + seed_claude_state(container_name) install_fzf_in_container(container_name) install_python_deps_in_container(container_name) copy_claude_credentials(container_name) From 40d5bfbcb627e36742e1ea2de3f02d3fa7f5748c Mon Sep 17 00:00:00 2001 From: Build Tools Date: Wed, 18 Feb 2026 21:56:25 +0000 Subject: [PATCH 50/60] permissions issue in claude settigns --- CLAUDE.md | 71 +++++++++++++++++++++++++++++++++++++ bin/ci_tool/claude_setup.py | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..977f42a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# er_build_tools + +Public build tools and CI utilities for Extend Robotics ROS1 repositories. The main component is `ci_tool`, an interactive CLI that reproduces CI failures locally in Docker and uses Claude Code to fix them. + +## Project Structure + +``` +bin/ + ci_tool/ # Main Python package + __main__.py # Entry point (auto-installs missing deps) + cli.py # Menu dispatch router + ci_fix.py # Core workflow: reproduce -> Claude analysis -> fix -> shell + ci_reproduce.py # Docker container setup for CI reproduction + claude_setup.py # Install Claude + copy credentials/config into container + claude_session.py # Interactive Claude session launcher + containers.py # Docker lifecycle (create, exec, cp, remove) + preflight.py # Auth/setup validation (fail-fast) + display_progress.py # Stream-json output processor for Claude + ci_context/ + CLAUDE.md # CI-specific instructions for Claude inside containers + setup.sh # User-facing setup script + reproduce_ci.sh # Public wrapper for CI reproduction +.helper_bash_functions # Sourced by users; provides colcon/rosdep helpers + ci_tool alias +pylintrc # Pylint config (strict: fail-under=10.0, max-line-length=140) +``` + +## Code Style + +- Python 3.6+, `from __future__ import annotations` in all modules +- snake_case everywhere; PascalCase for classes only +- 4-space indentation, max 140 char line length +- Pylint must pass at 10.0 (`pylintrc` at repo root) +- Use `# pylint: disable=...` pragmas only when essential, with justification +- Interactive prompts via `InquirerPy`; terminal UI via `rich` + +## Conventions + +- **Fail fast**: `PreflightError` for expected failures, `RuntimeError` for unexpected. No silent defaults, no fallback behaviour. +- **Minimal diffs**: Only change what's requested. No cosmetic cleanups, no "while I'm here" changes. +- **Self-documenting code**: Verbose variable names. Comments only for maths or external doc links. +- **Subprocess calls**: Use `docker_exec()` / `run_command()` from `containers.py`. Pass `check=False` when non-zero is expected; `quiet=True` to suppress echo. +- **State files**: `/ros_ws/.ci_fix_state.json` inside containers (session_id, phase, attempt_count) +- **Learnings persistence**: `~/.ci_tool/learnings/{org}_{repo}.md` on host, `/ros_ws/.ci_learnings.md` in container + +## Environment Variables + +- `GH_TOKEN` or `ER_SETUP_TOKEN` — GitHub token (checked in preflight) +- `CI_TOOL_SCRIPTS_BRANCH` — branch of er_build_tools_internal to fetch scripts from +- `IS_SANDBOX=1` — injected into all docker exec calls for Claude + +## Running + +```bash +# From host +source ~/.helper_bash_functions +ci_tool # interactive menu +ci_fix # shortcut for ci_tool fix + +# Lint +pylint --rcfile=pylintrc bin/ci_tool/ +``` + +## Testing + +No unit tests yet. Test manually by running `ci_tool` workflows end-to-end. + +## Common Pitfalls + +- Container Claude settings must use valid `defaultMode` values: `"acceptEdits"`, `"bypassPermissions"`, `"default"`, `"dontAsk"`, `"plan"`. The old `"dangerouslySkipPermissions"` is invalid. +- `docker_cp_to_container` requires the container to be running. +- Claude inside containers runs with `--dangerously-skip-permissions` flag (separate from the settings.json mode). diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index e55763c..c6ccf58 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -87,7 +87,7 @@ def copy_claude_config(container_name): with open(settings_path, encoding="utf-8") as settings_file: settings = json.load(settings_file) - settings.setdefault("permissions", {})["defaultMode"] = "dangerouslySkipPermissions" + settings.setdefault("permissions", {})["defaultMode"] = "bypassPermissions" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: json.dump(settings, tmp, indent=2) From 95244a61ea25dbde7f4d2140ee8b4ccd6eb9a186 Mon Sep 17 00:00:00 2001 From: Build Tools Date: Wed, 18 Feb 2026 22:26:14 +0000 Subject: [PATCH 51/60] Compact tool output with spinner in display_progress Replace per-tool-invocation lines with a single updating spinner line showing aggregated tool counts. Prints a one-line tool summary at session end. --- bin/ci_tool/display_progress.py | 86 +++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/bin/ci_tool/display_progress.py b/bin/ci_tool/display_progress.py index 9f25709..0449371 100644 --- a/bin/ci_tool/display_progress.py +++ b/bin/ci_tool/display_progress.py @@ -70,6 +70,30 @@ def format_elapsed(start_time): return f"{seconds}s" +def format_tool_status(tool_counts, start_time): + """Format spinner status text showing tool activity summary.""" + if not tool_counts: + return "[cyan]Working...[/cyan]" + parts = [] + for name, count in tool_counts.items(): + if count > 1: + parts.append(f"{name} x{count}") + else: + parts.append(name) + return f"[cyan]{', '.join(parts)}[/cyan] [dim][{format_elapsed(start_time)}][/dim]" + + +def format_tool_summary(tool_counts): + """Format a final one-line summary of all tools used.""" + parts = [] + for name, count in tool_counts.items(): + if count > 1: + parts.append(f"{name} x{count}") + else: + parts.append(name) + return ", ".join(parts) + + def flush_text_buffer(text_buffer): """Render accumulated text as rich markdown and clear the buffer.""" if not text_buffer: @@ -80,7 +104,7 @@ def flush_text_buffer(text_buffer): console.print(Markdown(combined)) -def handle_assistant_event(message, start_time, text_buffer): +def handle_assistant_event(message, start_time, text_buffer, tool_counts, status): """Display content blocks from an assistant message event.""" for block in message.get("content", []): block_type = block.get("type", "") @@ -93,33 +117,31 @@ def handle_assistant_event(message, start_time, text_buffer): elif block_type == "tool_use": flush_text_buffer(text_buffer) tool_name = block.get("name", "unknown") - console.print( - f"\n [dim]tool:[/dim] [bold]{tool_name}[/bold] " - f"[dim][{format_elapsed(start_time)}][/dim]" - ) + tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1 + status.update(format_tool_status(tool_counts, start_time)) -def handle_event(event, start_time, text_buffer): +def handle_event(event, start_time, text_buffer, tool_counts, status): """Handle a single stream-json event. Returns session_id if found, else None.""" session_id = event.get("session_id") or None event_type = event.get("type", "") if event_type == "assistant": message = event.get("message", {}) - handle_assistant_event(message, start_time, text_buffer) + handle_assistant_event(message, start_time, text_buffer, tool_counts, status) elif event_type == "tool_result": flush_text_buffer(text_buffer) - console.print( - f" [dim]done[/dim] [{format_elapsed(start_time)}]" - ) + status.update(format_tool_status(tool_counts, start_time)) return session_id -def print_session_summary(session_id, start_time): +def print_session_summary(session_id, start_time, tool_counts): """Print the final session summary after stream ends.""" elapsed = format_elapsed(start_time) + if tool_counts: + console.print(f" [dim]Tools: {format_tool_summary(tool_counts)}[/dim]") if session_id: console.print( f"\n[green]Session saved ({session_id}). " @@ -145,28 +167,28 @@ def main(): phase = "fixing" start_time = time.time() text_buffer = [] + tool_counts = {} try: - console.print("[cyan] Working...[/cyan]") - - with open(EVENT_DEBUG_LOG, "w", encoding="utf-8") as debug_log: - for line in sys.stdin: - line = line.strip() - if not line: - continue - - debug_log.write(line + "\n") - debug_log.flush() - - try: - event = json.loads(line) - except json.JSONDecodeError: - sys.stderr.write(f" {line}\n") - continue - - event_session_id = handle_event(event, start_time, text_buffer) - if event_session_id: - session_id = event_session_id + with console.status("[cyan]Working...[/cyan]", spinner="dots") as status: + with open(EVENT_DEBUG_LOG, "w", encoding="utf-8") as debug_log: + for line in sys.stdin: + line = line.strip() + if not line: + continue + + debug_log.write(line + "\n") + debug_log.flush() + + try: + event = json.loads(line) + except json.JSONDecodeError: + sys.stderr.write(f" {line}\n") + continue + + event_session_id = handle_event(event, start_time, text_buffer, tool_counts, status) + if event_session_id: + session_id = event_session_id flush_text_buffer(text_buffer) phase = "completed" @@ -180,7 +202,7 @@ def main(): phase = "stuck" write_state(session_id, phase, attempt_count) - print_session_summary(session_id, start_time) + print_session_summary(session_id, start_time, tool_counts) if __name__ == "__main__": From 4b268700fa98bca1d4b2989451f7a65032bbf6b8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 19 Feb 2026 01:53:39 +0000 Subject: [PATCH 52/60] docs grammar --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index aa87917..c2ab90c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Public build tools and utilities for Extend Robotics repositories. ## Quick Setup -Install helper bash functions, set your GitHub token, and authenticate Claude — all in one command: +Install helper bash functions, set your GitHub token, and authenticate Claude: ```bash bash <(curl -fsSL https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/refs/heads/main/bin/setup.sh) @@ -12,15 +12,15 @@ bash <(curl -fsSL https://raw.githubusercontent.com/Extend-Robotics/er_build_too This installs `~/.helper_bash_functions` which provides build helpers, git aliases, and `ci_tool`. -## ci_tool — Fix CI Failures with Claude +## ci_tool: Fix CI Failures with Claude -`ci_tool` is an interactive CLI that reproduces CI failures locally in Docker and uses Claude Code to autonomously fix them. +`ci_tool` is an interactive CLI that reproduces CI failures locally in Docker and uses Claude Code to fix them. ### Prerequisites - **Docker** installed and running -- **Claude Code** installed and authenticated — `npm install -g @anthropic-ai/claude-code && claude` -- **GitHub token** with `repo` scope — [create one](https://github.com/settings/tokens) +- **Claude Code** installed and authenticated: `npm install -g @anthropic-ai/claude-code && claude` +- **GitHub token** with `repo` scope: [create one](https://github.com/settings/tokens) ### Usage @@ -73,11 +73,11 @@ If you prefer not to use the setup script: --- -## reproduce_ci.sh — Reproduce CI Locally +## reproduce_ci.sh: Reproduce CI Locally When CI fails, debugging requires pushing commits and waiting for results. This script reproduces the exact CI environment locally in a persistent Docker container, so you can debug interactively. -It creates a Docker container using the same image as CI, clones your repo and its dependencies, builds everything, and optionally runs tests — mirroring the steps in `setup_and_build_ros_ws.yml`. +It creates a Docker container using the same image as CI, clones your repo and its dependencies, builds everything, and optionally runs tests, mirroring the steps in `setup_and_build_ros_ws.yml`. ### Quick Start @@ -169,8 +169,8 @@ docker rm -f er_ci_reproduced_testing_env ### Troubleshooting -**Container already exists** — Remove it first: `docker rm -f er_ci_reproduced_testing_env` +**Container already exists**: Remove it first: `docker rm -f er_ci_reproduced_testing_env` -**404 when fetching scripts** — Check that your `--gh-token` has access to `er_build_tools_internal`, and that the `--scripts-branch` exists. +**404 when fetching scripts**: Check that your `--gh-token` has access to `er_build_tools_internal`, and that the `--scripts-branch` exists. -**`DISPLAY` error with graphical forwarding** — Either set `DISPLAY` (e.g. via X11 forwarding) or pass `--graphical false`. +**`DISPLAY` error with graphical forwarding**: Either set `DISPLAY` (e.g. via X11 forwarding) or pass `--graphical false`. From 03ea8c298bed57041c029381e06b5073528adbf2 Mon Sep 17 00:00:00 2001 From: Build Tools Date: Thu, 19 Feb 2026 13:00:00 +0000 Subject: [PATCH 53/60] add -h | --help, and update readme --- README.md | 29 +++++++++++++++++++++-------- bin/ci_tool/cli.py | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index aa87917..0315248 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,27 @@ source ~/.helper_bash_functions ci_tool ``` -| Command | Description | -|---------|-------------| -| `ci_tool` | Interactive menu | -| `ci_fix` | Fix CI failures with Claude (shortcut for `ci_tool fix`) | -| `ci_tool reproduce` | Reproduce CI environment in Docker | -| `ci_tool shell` | Shell into an existing CI container | -| `ci_tool retest` | Re-run tests in a CI container | -| `ci_tool clean` | Remove CI containers | +This opens an interactive menu. You can also run subcommands directly — see `ci_tool --help`: + +``` +$ ci_tool --help +ci_tool — Fix CI failures with Claude + +Usage: ci_tool [command] + +Commands: + fix Fix CI failures with Claude + reproduce Reproduce CI environment in Docker + claude Interactive Claude session in container + shell Shell into an existing CI container + retest Re-run tests in a CI container + clean Remove CI containers + +Shortcuts: + ci_fix Alias for 'ci_tool fix' + +Run without arguments for interactive menu. +``` ### Fix Workflow diff --git a/bin/ci_tool/cli.py b/bin/ci_tool/cli.py index 25c7781..6d968dc 100644 --- a/bin/ci_tool/cli.py +++ b/bin/ci_tool/cli.py @@ -21,10 +21,36 @@ {"name": "Exit", "value": "exit"}, ] +HELP_TEXT = """\ +ci_tool — Fix CI failures with Claude + +Usage: ci_tool [command] + +Commands: + fix Fix CI failures with Claude + reproduce Reproduce CI environment in Docker + claude Interactive Claude session in container + shell Shell into an existing CI container + retest Re-run tests in a CI container + clean Remove CI containers + +Shortcuts: + ci_fix Alias for 'ci_tool fix' + +Run without arguments for interactive menu.""" + + +def print_help(): + """Print usage information.""" + console.print(HELP_TEXT) + def main(): """Entry point - show menu or dispatch subcommand.""" if len(sys.argv) > 1: + if sys.argv[1] in ("-h", "--help", "help"): + print_help() + return dispatch_subcommand(sys.argv[1], sys.argv[2:]) return From 1a261edaa2e46d25602e53ae5649d93fe5ccaf39 Mon Sep 17 00:00:00 2001 From: Build Tools Date: Thu, 19 Feb 2026 18:55:31 +0000 Subject: [PATCH 54/60] attempt to reduce token usage, untested --- bin/ci_tool/ci_context/CLAUDE.md | 2 + bin/ci_tool/ci_fix.py | 2 + bin/ci_tool/claude_setup.py | 95 +++++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/bin/ci_tool/ci_context/CLAUDE.md b/bin/ci_tool/ci_context/CLAUDE.md index 64c3e04..6eed6bf 100644 --- a/bin/ci_tool/ci_context/CLAUDE.md +++ b/bin/ci_tool/ci_context/CLAUDE.md @@ -9,6 +9,8 @@ Use Grep to search log files for relevant errors — never read entire log files When examining test output, search for FAILURE, FAILED, ERROR, or assertion messages. Pipe long command output through `tail -200` or `grep` to avoid dumping huge logs. +Always use the helper functions (`colcon_build`, `colcon_build_no_deps`, `colcon_test_this_package`) instead of raw `colcon` commands — they limit output to the last 50 lines and log full output to `/ros_ws/.colcon_build.log` and `/ros_ws/.colcon_test.log`. If you need more detail, Grep the log files. + ## Environment Setup ```bash diff --git a/bin/ci_tool/ci_fix.py b/bin/ci_tool/ci_fix.py index 610c675..6048355 100644 --- a/bin/ci_tool/ci_fix.py +++ b/bin/ci_tool/ci_fix.py @@ -20,6 +20,7 @@ copy_display_script, copy_learnings_from_container, copy_learnings_to_container, + inject_colcon_wrappers, inject_rerun_tests_function, inject_resume_function, is_claude_installed_in_container, @@ -422,6 +423,7 @@ def refresh_claude_config(container_name): copy_display_script(container_name) inject_resume_function(container_name) inject_rerun_tests_function(container_name) + inject_colcon_wrappers(container_name) seed_claude_state(container_name) diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index c6ccf58..557385a 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -135,9 +135,20 @@ def copy_display_script(container_name): packages=$(tr '\n' ' ' < "$packages_file") echo "Rebuilding and testing: ${packages}" cd /ros_ws - colcon build --packages-select ${packages} --cmake-args -DSETUPTOOLS_DEB_LAYOUT=OFF + colcon build --packages-select ${packages} --cmake-args -DSETUPTOOLS_DEB_LAYOUT=OFF \ + 2>&1 | tee /ros_ws/.colcon_build.log | tail -n 50 + local build_ret=${PIPESTATUS[0]} + local build_total=$(wc -l < /ros_ws/.colcon_build.log) + [ "$build_total" -gt 50 ] && echo "--- (last 50 of $build_total lines — full log: /ros_ws/.colcon_build.log) ---" + if [ "$build_ret" -ne 0 ]; then + echo "Build failed (exit code $build_ret)" + return $build_ret + fi source /ros_ws/install/setup.bash - colcon test --packages-select ${packages} + colcon test --packages-select ${packages} \ + 2>&1 | tee /ros_ws/.colcon_test.log | tail -n 50 + local test_total=$(wc -l < /ros_ws/.colcon_test.log) + [ "$test_total" -gt 50 ] && echo "--- (last 50 of $test_total lines — full log: /ros_ws/.colcon_test.log) ---" for pkg in ${packages}; do if ! colcon test-result --test-result-base "build/$pkg/test_results"; then python3 - "build/$pkg/test_results" <<'PYEOF' @@ -181,6 +192,59 @@ def copy_display_script(container_name): ''' +COLCON_WRAPPERS = r''' +colcon_build() { + cd /ros_ws && source /opt/ros/noetic/setup.bash + local cmd="colcon build --cmake-args -DSETUPTOOLS_DEB_LAYOUT=OFF" + [ -n "$1" ] && cmd="$cmd --packages-up-to $1" + echo "$cmd" + eval "$cmd" 2>&1 | tee /ros_ws/.colcon_build.log | tail -n 50 + local ret=${PIPESTATUS[0]} + local total=$(wc -l < /ros_ws/.colcon_build.log) + [ "$total" -gt 50 ] && echo "--- (last 50 of $total lines — full log: /ros_ws/.colcon_build.log) ---" + source /ros_ws/install/setup.bash 2>/dev/null + return $ret +} + +colcon_build_no_deps() { + cd /ros_ws && source /opt/ros/noetic/setup.bash + local cmd="colcon build --cmake-args -DSETUPTOOLS_DEB_LAYOUT=OFF" + [ -n "$1" ] && cmd="$cmd --packages-select $1" + echo "$cmd" + eval "$cmd" 2>&1 | tee /ros_ws/.colcon_build.log | tail -n 50 + local ret=${PIPESTATUS[0]} + local total=$(wc -l < /ros_ws/.colcon_build.log) + [ "$total" -gt 50 ] && echo "--- (last 50 of $total lines — full log: /ros_ws/.colcon_build.log) ---" + source /ros_ws/install/setup.bash 2>/dev/null + return $ret +} + +colcon_build_this() { + colcon_build "$(find_cmake_project_names_from_dir .)" +} + +colcon_test_these_packages() { + cd /ros_ws + colcon_build_no_deps "$1" + local build_ret=$? + [ "$build_ret" -ne 0 ] && return $build_ret + source /ros_ws/install/setup.bash + colcon test --packages-select $1 2>&1 | tee /ros_ws/.colcon_test.log | tail -n 50 + local total=$(wc -l < /ros_ws/.colcon_test.log) + [ "$total" -gt 50 ] && echo "--- (last 50 of $total lines — full log: /ros_ws/.colcon_test.log) ---" + for pkg in $1; do + if ! colcon test-result --test-result-base "build/$pkg/test_results"; then + type _show_test_failures &>/dev/null && _show_test_failures "build/$pkg/test_results" + fi + done +} + +colcon_test_this_package() { + colcon_test_these_packages "$@" +} +''' + + def inject_resume_function(container_name): """Add resume_claude bash function to the container's bashrc.""" console.print("[cyan]Injecting resume_claude function...[/cyan]") @@ -200,6 +264,32 @@ def inject_resume_function(container_name): ) +def inject_colcon_wrappers(container_name): + """Add output-limiting colcon wrapper functions to the container. + + Appends to /root/.helper_bash_functions so wrappers override originals. + Uses a marker comment for idempotent injection. + """ + console.print("[cyan]Injecting colcon output wrappers...[/cyan]") + target = "/root/.helper_bash_functions" + marker = "# ci_fix colcon_wrappers" + + docker_exec(container_name, f"touch {target}", quiet=True) + already_present = docker_exec( + container_name, f"grep -q '{marker}' {target}", + check=False, quiet=True, + ) + if already_present.returncode == 0: + return + + docker_exec( + container_name, + f"echo '{marker}' >> {target} && cat >> {target} << 'WRAPPERS_EOF'\n" + f"{COLCON_WRAPPERS}\nWRAPPERS_EOF", + quiet=True, + ) + + def save_package_list(container_name): """Run colcon list in the container and save package names to /ros_ws/.ci_packages.""" console.print("[cyan]Saving workspace package list...[/cyan]") @@ -426,6 +516,7 @@ def setup_claude_in_container(container_name): inject_rerun_tests_function(container_name) copy_claude_memory(container_name) copy_helper_bash_functions(container_name) + inject_colcon_wrappers(container_name) configure_git_in_container(container_name) console.print("[bold green]Claude Code is installed and configured in the container[/bold green]") From 48cbc32e70c05356bd922f35176d49fc568b9ac3 Mon Sep 17 00:00:00 2001 From: tomqext Date: Wed, 25 Feb 2026 21:54:02 +0000 Subject: [PATCH 55/60] Selective verbose test output via .verbose_tests marker Packages with a .verbose_tests file get live console output (console_direct+). All other packages run quietly, showing output only on failure. Applied to both host helper functions and container rerun_tests function. Co-Authored-By: Claude Opus 4.6 --- .helper_bash_functions | 25 ++++++++++++++++++++++--- bin/ci_tool/claude_setup.py | 23 +++++++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.helper_bash_functions b/.helper_bash_functions index a2d486a..7c92938 100644 --- a/.helper_bash_functions +++ b/.helper_bash_functions @@ -44,18 +44,37 @@ for d in sys.argv[1:]: print(f" ... ({len(lines) - 20} more lines)") PYEOF } +_find_verbose_test_packages() { + local requested_packages="$1" + local verbose_packages="" + while IFS=$'\t' read -r name path _; do + if echo " ${requested_packages} " | grep -q " ${name} "; then + if [ -f "${path}/.verbose_tests" ]; then + verbose_packages="${verbose_packages} ${name}" + fi + fi + done < <(colcon list 2>/dev/null) + echo "${verbose_packages## }" +} colcon_test_these_packages() { THIS_DIR=$(pwd) cd ${CATKIN_WS_PATH} colcon_build_no_deps $1 - source install/setup.bash && \ - colcon test --packages-select $1 + source install/setup.bash + local verbose_packages + verbose_packages=$(_find_verbose_test_packages "$1") + if [ -n "${verbose_packages}" ]; then + echo -e "${Yellow}Packages with verbose tests: ${verbose_packages}${Color_Off}" + colcon test --packages-select $1 --packages-skip ${verbose_packages} + colcon test --packages-select ${verbose_packages} --event-handlers console_direct+ + else + colcon test --packages-select $1 + fi for pkg in $1; do if ! colcon test-result --test-result-base "build/$pkg/test_results"; then _show_test_failures "build/$pkg/test_results" fi done cd $THIS_DIR - } colcon_test_this_package() { colcon_test_these_packages "$@"; } # Alias for backwards compatability find_cmake_project_names_from_dir() { if [[ -z $1 ]]; then DIR_TO_SEARCH="."; else DIR_TO_SEARCH=$1; fi; wget -qO- https://raw.githubusercontent.com/Extend-Robotics/er_build_tools/refs/heads/main/bin/find_cmake_project_names.py | python3 - $DIR_TO_SEARCH; } diff --git a/bin/ci_tool/claude_setup.py b/bin/ci_tool/claude_setup.py index 557385a..7811d9a 100644 --- a/bin/ci_tool/claude_setup.py +++ b/bin/ci_tool/claude_setup.py @@ -145,8 +145,27 @@ def copy_display_script(container_name): return $build_ret fi source /ros_ws/install/setup.bash - colcon test --packages-select ${packages} \ - 2>&1 | tee /ros_ws/.colcon_test.log | tail -n 50 + + local verbose_packages="" + while IFS=$'\t' read -r name path _; do + if echo " ${packages} " | grep -q " ${name} "; then + if [ -f "${path}/.verbose_tests" ]; then + verbose_packages="${verbose_packages} ${name}" + fi + fi + done < <(colcon list 2>/dev/null) + verbose_packages="${verbose_packages## }" + + if [ -n "${verbose_packages}" ]; then + echo "Packages with verbose tests: ${verbose_packages}" + colcon test --packages-select ${packages} --packages-skip ${verbose_packages} \ + 2>&1 | tee /ros_ws/.colcon_test.log | tail -n 50 + colcon test --packages-select ${verbose_packages} --event-handlers console_direct+ \ + 2>&1 | tee -a /ros_ws/.colcon_test.log + else + colcon test --packages-select ${packages} \ + 2>&1 | tee /ros_ws/.colcon_test.log | tail -n 50 + fi local test_total=$(wc -l < /ros_ws/.colcon_test.log) [ "$test_total" -gt 50 ] && echo "--- (last 50 of $test_total lines — full log: /ros_ws/.colcon_test.log) ---" for pkg in ${packages}; do From 6fd2462d2bda86bf29ae3718acc0f957279842b3 Mon Sep 17 00:00:00 2001 From: tomqext Date: Wed, 25 Feb 2026 22:19:08 +0000 Subject: [PATCH 56/60] Add TODOs for parallel CI analysis and branch extraction from URL Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/TODO.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bin/ci_tool/TODO.md b/bin/ci_tool/TODO.md index 638846d..c748184 100644 --- a/bin/ci_tool/TODO.md +++ b/bin/ci_tool/TODO.md @@ -1,8 +1,13 @@ # ci_tool TODO +## Features + +- [ ] **Parallel CI analysis during local reproduction**: When a GitHub Actions URL is provided, start the local build/test reproduction immediately and, in parallel, fetch the CI logs (`gh run view --log-failed`), digest the failures, and present a summary to the user while the container is still building. Currently `reproduce_ci()` blocks in `fix_ci()` (step 3) before any analysis happens. The idea: spawn the reproduction in the background, use the CI URL to pull logs and identify failures concurrently, then present the analysis to the user so they understand the problem before local tests even finish. This saves the entire reproduction wait time for understanding what went wrong. + ## Bug Fixes - [ ] If branch name is empty/blank, default to the repo's default branch instead of requiring input +- [ ] In "Reproduce CI (create container)" mode, extract the branch name from the GitHub Actions URL (like `extract_info_from_ci_url` already does in "Fix CI with Claude" mode) instead of requiring the user to enter it manually ## Done - [x] ~~Render markdown in terminal~~ — display_progress.py now buffers text between tool calls and renders via `rich.markdown.Markdown` (tables, headers, code blocks, bold/italic) From 62ac833b0c7a98795973bc7cde33522d720b0cc0 Mon Sep 17 00:00:00 2001 From: tomqext Date: Wed, 25 Feb 2026 22:39:10 +0000 Subject: [PATCH 57/60] Add TODO to simplify ci_tool main menu Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/TODO.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bin/ci_tool/TODO.md b/bin/ci_tool/TODO.md index c748184..7a72e77 100644 --- a/bin/ci_tool/TODO.md +++ b/bin/ci_tool/TODO.md @@ -4,6 +4,10 @@ - [ ] **Parallel CI analysis during local reproduction**: When a GitHub Actions URL is provided, start the local build/test reproduction immediately and, in parallel, fetch the CI logs (`gh run view --log-failed`), digest the failures, and present a summary to the user while the container is still building. Currently `reproduce_ci()` blocks in `fix_ci()` (step 3) before any analysis happens. The idea: spawn the reproduction in the background, use the CI URL to pull logs and identify failures concurrently, then present the analysis to the user so they understand the problem before local tests even finish. This saves the entire reproduction wait time for understanding what went wrong. +## UX + +- [ ] **Simplify the main menu**: Too many top-level options (reproduce, fix, claude, shell, retest, clean, exit). Several overlap — e.g. "Reproduce CI" is already a step within "Fix CI with Claude", and "Claude session" / "Shell into container" / "Re-run tests" are all post-reproduce actions on an existing container. Consolidate into fewer choices and push the rest into sub-menus or contextual prompts. + ## Bug Fixes - [ ] If branch name is empty/blank, default to the repo's default branch instead of requiring input From 6b0c4d989bcf2fd79cc118a98933ef3f19c4a766 Mon Sep 17 00:00:00 2001 From: tomqext Date: Wed, 25 Feb 2026 23:04:22 +0000 Subject: [PATCH 58/60] Add design doc for CI analyse mode Parallel remote log analysis + local reproduction with Rich Live split-panel display. Remote side filters GH Actions logs with regex then sends reduced context to Claude haiku. Local side uses existing reproduce_ci + Claude analysis in container. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-25-ci-analyse-mode-design.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/plans/2026-02-25-ci-analyse-mode-design.md diff --git a/docs/plans/2026-02-25-ci-analyse-mode-design.md b/docs/plans/2026-02-25-ci-analyse-mode-design.md new file mode 100644 index 0000000..9963130 --- /dev/null +++ b/docs/plans/2026-02-25-ci-analyse-mode-design.md @@ -0,0 +1,124 @@ +# CI Analyse Mode Design + +## Goal + +Add an "Analyse CI" mode to ci_tool that fetches and analyses GitHub Actions +failure logs in parallel with local Docker reproduction, displaying both in a +split Rich Live Layout, then offering to transition into fix mode. + +## User Flow + +1. User selects **"Analyse CI"** from main menu (or `ci_tool analyse`) +2. Provides a **GitHub Actions URL** (required) +3. Provides build options (only-needed-deps) and session name +4. Two parallel threads start with a Rich Live split-panel display: + - **Top panel ("Remote CI Logs"):** Fetches GH Actions logs, filters them + with Python regex, sends reduced context to Claude haiku for diagnosis + - **Bottom panel ("Local Reproduction"):** Runs `reproduce_ci()` in Docker, + then analyses local `test_output.log` with Claude inside the container +5. Once both complete, prints a combined summary +6. Asks: "Proceed to fix with Claude?" — if yes, transitions into existing + `run_claude_workflow()` fix phase (container already set up) + +## Architecture + +### New file: `bin/ci_tool/ci_analyse.py` + +Entry point `analyse_ci(args)`: +- Prompts for GH Actions URL (required for this mode) +- Prompts for build options + session name +- Runs preflight checks +- Launches `ParallelAnalyser` + +### Remote Analysis Pipeline (top panel) + +``` +gh run view {run_id} --log-failed + | + v + Python regex filter + - Extract ERROR/FAIL/assertion/[FAIL] blocks + - Keep ~5 lines context around each match + - Strip ANSI codes, timestamps, build noise + - Deduplicate (same error in summary + detail) + | + v + ~50-200 lines (vs thousands raw) + | + v + claude --model haiku -p "Analyse these CI failures..." + | + v + Structured diagnosis displayed in top panel +``` + +### Local Reproduction Pipeline (bottom panel) + +``` +reproduce_ci() (existing) + - Create Docker container + - Clone repo, install deps, build, run tests + | + v + setup_claude_in_container() (existing) + | + v + run_claude_streamed() with ANALYSIS_PROMPT_TEMPLATE (existing) + | + v + Analysis displayed in bottom panel +``` + +### ParallelAnalyser class + +- Uses `threading.Thread` for both pipelines +- `rich.live.Live` with `rich.layout.Layout` (two rows) +- Each panel wraps a `rich.text.Text` that gets appended to from its thread +- Thread-safe updates via Lock +- Both threads capture their results for the combined summary + +### Display Layout + +``` ++------------------------------------------+ +| Remote CI Logs | +| Package: my_pkg | +| Test: test_something | +| Error: AssertionError: expected 5, got 3 | +| Diagnosis: ... | ++------------------------------------------+ +| Local Reproduction | +| [Building workspace...] | +| [Running tests...] | +| [Analysing failures...] | ++------------------------------------------+ +``` + +## Changes to Existing Files + +- **`cli.py`**: Add `{"name": "Analyse CI", "value": "analyse"}` to + `MENU_CHOICES`. Add `"analyse": _handle_analyse` to `dispatch_subcommand`. + Add `_handle_analyse()` handler. Update `HELP_TEXT`. +- **No changes** to `ci_fix.py`, `ci_reproduce.py`, or other modules. + +## Error Handling + +- **GH logs unavailable:** Fail fast in top panel, local reproduction continues +- **One thread fails:** Show partial results with warning about failed side +- **Claude on host fails:** Fall back to displaying filtered logs raw +- **Container build fails:** Show build error in bottom panel, remote completes + +## Fix Transition + +After both panels complete: +1. Print combined report (findings from both remote and local analysis) +2. Prompt: "Proceed to fix with Claude?" + - Yes: transition into existing `run_claude_workflow()` with container ready + - No: offer shell access or exit + +## Dependencies + +- `rich` (already in requirements.txt) — Live, Layout, Panel, Text +- `gh` CLI (already required) — for `gh run view --log-failed` +- `claude` on host — for haiku analysis of filtered remote logs +- `threading` (stdlib) — parallel execution From 05ea6d79436d95d95798097a94494c86ecb10bb6 Mon Sep 17 00:00:00 2001 From: tomqext Date: Wed, 25 Feb 2026 23:13:27 +0000 Subject: [PATCH 59/60] Update design and plan for CI analyse mode Add remote-only (fast) sub-mode that skips Docker entirely. User picks analysis depth via sub-menu after providing GH Actions URL: remote-only fetches/filters/diagnoses logs with Claude haiku, full mode also reproduces locally in parallel with split Rich Live display. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-25-ci-analyse-mode-design.md | 86 +- docs/plans/2026-02-25-ci-analyse-mode-plan.md | 855 ++++++++++++++++++ 2 files changed, 906 insertions(+), 35 deletions(-) create mode 100644 docs/plans/2026-02-25-ci-analyse-mode-plan.md diff --git a/docs/plans/2026-02-25-ci-analyse-mode-design.md b/docs/plans/2026-02-25-ci-analyse-mode-design.md index 9963130..45d89c4 100644 --- a/docs/plans/2026-02-25-ci-analyse-mode-design.md +++ b/docs/plans/2026-02-25-ci-analyse-mode-design.md @@ -2,22 +2,33 @@ ## Goal -Add an "Analyse CI" mode to ci_tool that fetches and analyses GitHub Actions -failure logs in parallel with local Docker reproduction, displaying both in a -split Rich Live Layout, then offering to transition into fix mode. +Add an "Analyse CI" mode to ci_tool that diagnoses CI failures. Offers two +sub-modes: a fast remote-only analysis (fetch + filter GH Actions logs, diagnose +with Claude haiku), and a full parallel analysis that also reproduces locally in +Docker. ## User Flow 1. User selects **"Analyse CI"** from main menu (or `ci_tool analyse`) 2. Provides a **GitHub Actions URL** (required) -3. Provides build options (only-needed-deps) and session name -4. Two parallel threads start with a Rich Live split-panel display: - - **Top panel ("Remote CI Logs"):** Fetches GH Actions logs, filters them - with Python regex, sends reduced context to Claude haiku for diagnosis - - **Bottom panel ("Local Reproduction"):** Runs `reproduce_ci()` in Docker, - then analyses local `test_output.log` with Claude inside the container -5. Once both complete, prints a combined summary -6. Asks: "Proceed to fix with Claude?" — if yes, transitions into existing +3. Sub-menu: **"Remote only (fast)"** vs **"Remote + local reproduction"** + +### Remote only (fast) + +4. Fetches GH Actions logs, filters with Python regex, sends reduced context + to Claude haiku on host for diagnosis +5. Prints structured report +6. Done — no container, no Docker + +### Remote + local reproduction + +4. Provides build options (only-needed-deps) and session name +5. Two parallel threads start with a Rich Live split-panel display: + - **Top panel ("Remote CI Logs"):** Same as remote-only pipeline above + - **Bottom panel ("Local Reproduction"):** `reproduce_ci()` in Docker, + then Claude analyses local `test_output.log` inside the container +6. Once both complete, prints combined summary +7. Asks: "Proceed to fix with Claude?" — if yes, transitions into existing `run_claude_workflow()` fix phase (container already set up) ## Architecture @@ -25,22 +36,21 @@ split Rich Live Layout, then offering to transition into fix mode. ### New file: `bin/ci_tool/ci_analyse.py` Entry point `analyse_ci(args)`: -- Prompts for GH Actions URL (required for this mode) -- Prompts for build options + session name -- Runs preflight checks -- Launches `ParallelAnalyser` +- Prompts for GH Actions URL (required) +- Sub-menu for analysis depth +- Remote-only: runs remote pipeline, prints report, exits +- Full: runs preflight, launches parallel threads with split display -### Remote Analysis Pipeline (top panel) +### Remote Analysis Pipeline ``` gh run view {run_id} --log-failed | v - Python regex filter + Python regex filter (ci_log_filter.py) - Extract ERROR/FAIL/assertion/[FAIL] blocks - Keep ~5 lines context around each match - Strip ANSI codes, timestamps, build noise - - Deduplicate (same error in summary + detail) | v ~50-200 lines (vs thousands raw) @@ -49,10 +59,10 @@ gh run view {run_id} --log-failed claude --model haiku -p "Analyse these CI failures..." | v - Structured diagnosis displayed in top panel + Structured diagnosis ``` -### Local Reproduction Pipeline (bottom panel) +### Local Reproduction Pipeline (full mode only) ``` reproduce_ci() (existing) @@ -63,21 +73,26 @@ reproduce_ci() (existing) setup_claude_in_container() (existing) | v - run_claude_streamed() with ANALYSIS_PROMPT_TEMPLATE (existing) + Claude analysis with ANALYSIS_PROMPT_TEMPLATE (existing) | v - Analysis displayed in bottom panel + Local analysis results ``` -### ParallelAnalyser class +### New file: `bin/ci_tool/ci_log_filter.py` + +Python regex-based log filter. Extracts failure-relevant lines with surrounding +context, strips ANSI codes and timestamps. Reduces thousands of raw log lines +to ~50-200 lines for Claude haiku. + +### New file: `bin/ci_tool/ci_analyse_display.py` -- Uses `threading.Thread` for both pipelines +`SplitPanelDisplay` class for the full parallel mode: - `rich.live.Live` with `rich.layout.Layout` (two rows) -- Each panel wraps a `rich.text.Text` that gets appended to from its thread -- Thread-safe updates via Lock -- Both threads capture their results for the combined summary +- Thread-safe `append_remote()` / `append_local()` methods +- Auto-refreshes at 4 Hz -### Display Layout +### Display Layout (full mode only) ``` +------------------------------------------+ @@ -103,15 +118,16 @@ reproduce_ci() (existing) ## Error Handling -- **GH logs unavailable:** Fail fast in top panel, local reproduction continues -- **One thread fails:** Show partial results with warning about failed side -- **Claude on host fails:** Fall back to displaying filtered logs raw -- **Container build fails:** Show build error in bottom panel, remote completes +- **GH logs unavailable:** Fail fast with clear error message +- **One thread fails (full mode):** Show partial results with warning +- **Claude haiku on host fails:** Fall back to displaying filtered logs raw +- **Container build fails (full mode):** Show error in bottom panel, remote + analysis still completes -## Fix Transition +## Fix Transition (full mode only) After both panels complete: -1. Print combined report (findings from both remote and local analysis) +1. Print combined report 2. Prompt: "Proceed to fix with Claude?" - Yes: transition into existing `run_claude_workflow()` with container ready - No: offer shell access or exit @@ -121,4 +137,4 @@ After both panels complete: - `rich` (already in requirements.txt) — Live, Layout, Panel, Text - `gh` CLI (already required) — for `gh run view --log-failed` - `claude` on host — for haiku analysis of filtered remote logs -- `threading` (stdlib) — parallel execution +- `threading` (stdlib) — parallel execution (full mode only) diff --git a/docs/plans/2026-02-25-ci-analyse-mode-plan.md b/docs/plans/2026-02-25-ci-analyse-mode-plan.md new file mode 100644 index 0000000..cea7fea --- /dev/null +++ b/docs/plans/2026-02-25-ci-analyse-mode-plan.md @@ -0,0 +1,855 @@ +# CI Analyse Mode Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an "Analyse CI" menu item with two sub-modes: a fast remote-only analysis (fetch + filter GH Actions logs + Claude haiku diagnosis), and a full parallel mode that also reproduces locally in Docker with a split Rich Live display. + +**Architecture:** New `ci_analyse.py` module. Sub-menu after URL input: "Remote only (fast)" skips Docker entirely, just fetches/filters/diagnoses. "Remote + local reproduction" runs both pipelines in parallel with `SplitPanelDisplay`. Shared helpers for fetching/filtering/diagnosing reused by both paths. Existing modules untouched. + +**Tech Stack:** Python 3.6+, Rich (Live, Layout, Panel, Text), threading, subprocess, `gh` CLI, `claude` CLI on host + +--- + +### Task 1: Add log filtering module `ci_log_filter.py` + +Pre-processes raw GH Actions logs to extract only failure-relevant lines, reducing tokens before sending to Claude. + +**Files:** +- Create: `bin/ci_tool/ci_log_filter.py` + +**Step 1: Create the log filter module** + +```python +#!/usr/bin/env python3 +"""Filter CI logs to extract failure-relevant lines.""" +from __future__ import annotations + +import re + +# Patterns that indicate a failure or error in ROS/colcon CI output +FAILURE_PATTERNS = [ + re.compile(r'(?i)\bFAILURE\b'), + re.compile(r'(?i)\bFAILED\b'), + re.compile(r'(?i)\bERROR\b'), + re.compile(r'(?i)\b(?:Assertion|Assert)Error\b'), + re.compile(r'(?i)\bassert\b.*(?:!=|==|is not|not in)'), + re.compile(r'\[FAIL\]'), + re.compile(r'\[ERROR\]'), + re.compile(r'(?i)ERRORS?:?\s*\d+'), + re.compile(r'(?i)failures?:?\s*\d+'), + re.compile(r'(?i)Traceback \(most recent call last\)'), + re.compile(r'(?i)raise\s+\w+Error'), + re.compile(r'(?i)E\s+\w+Error:'), + re.compile(r'---\s*\>\s*'), +] + +ANSI_ESCAPE = re.compile(r'\x1b\[[0-9;]*m') + +CONTEXT_LINES_BEFORE = 3 +CONTEXT_LINES_AFTER = 5 +MAX_OUTPUT_LINES = 300 + + +def strip_ansi(text): + """Remove ANSI escape codes from text.""" + return ANSI_ESCAPE.sub('', text) + + +def strip_gh_log_timestamps(line): + """Strip GitHub Actions log timestamp prefixes like '2024-01-15T10:30:00.1234567Z '.""" + return re.sub(r'^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*', '', line) + + +def filter_ci_logs(raw_logs): + """Extract failure-relevant lines from raw CI logs with surrounding context. + + Returns a string of filtered lines ready for analysis, or empty string if + no failure patterns found. + """ + lines = raw_logs.split('\n') + cleaned_lines = [strip_gh_log_timestamps(strip_ansi(line)) for line in lines] + + matching_line_indices = set() + for line_index, line in enumerate(cleaned_lines): + for pattern in FAILURE_PATTERNS: + if pattern.search(line): + matching_line_indices.add(line_index) + break + + if not matching_line_indices: + return "" + + included_line_indices = set() + for match_index in sorted(matching_line_indices): + context_start = max(0, match_index - CONTEXT_LINES_BEFORE) + context_end = min(len(cleaned_lines), match_index + CONTEXT_LINES_AFTER + 1) + for context_index in range(context_start, context_end): + included_line_indices.add(context_index) + + result_lines = [] + previous_index = -2 + for line_index in sorted(included_line_indices): + if line_index > previous_index + 1: + result_lines.append("---") + result_lines.append(cleaned_lines[line_index]) + previous_index = line_index + + if len(result_lines) > MAX_OUTPUT_LINES: + result_lines = result_lines[:MAX_OUTPUT_LINES] + result_lines.append(f"\n... (truncated at {MAX_OUTPUT_LINES} lines)") + + return '\n'.join(result_lines) +``` + +**Step 2: Verify pylint passes** + +Run: `pylint --rcfile=pylintrc bin/ci_tool/ci_log_filter.py` +Expected: Score 10.0/10 + +**Step 3: Commit** + +```bash +git add bin/ci_tool/ci_log_filter.py +git commit -m "Add CI log filter module for pre-processing GH Actions logs" +``` + +--- + +### Task 2: Add split-panel display class `ci_analyse_display.py` + +Thread-safe Rich Live Layout with two panels. Only used by the "full" parallel mode. + +**Files:** +- Create: `bin/ci_tool/ci_analyse_display.py` + +**Step 1: Create the display module** + +```python +#!/usr/bin/env python3 +"""Split-panel Rich Live display for parallel CI analysis.""" +from __future__ import annotations + +import threading + +from rich.console import Console +from rich.layout import Layout +from rich.live import Live +from rich.panel import Panel +from rich.text import Text + + +class SplitPanelDisplay: + """Thread-safe split-panel display using Rich Live Layout. + + Top panel: remote CI log analysis + Bottom panel: local reproduction progress + """ + + def __init__(self): + self._console = Console() + self._lock = threading.Lock() + self._remote_lines = [] + self._local_lines = [] + self._remote_status = "Waiting..." + self._local_status = "Waiting..." + self._live = None + + def _build_panel_content(self, lines, status): + """Build panel content from accumulated lines and current status.""" + if not lines: + return Text(status, style="dim") + text = Text() + for line in lines[-30:]: + text.append(line + "\n") + text.append(f"\n[{status}]", style="dim") + return text + + def _build_layout(self): + """Build the full layout with both panels.""" + layout = Layout() + with self._lock: + remote_content = self._build_panel_content( + self._remote_lines, self._remote_status + ) + local_content = self._build_panel_content( + self._local_lines, self._local_status + ) + layout.split_column( + Layout( + Panel( + remote_content, + title="Remote CI Logs", + border_style="cyan", + ), + name="remote", + ), + Layout( + Panel( + local_content, + title="Local Reproduction", + border_style="green", + ), + name="local", + ), + ) + return layout + + def append_remote(self, line): + """Append a line to the remote panel (thread-safe).""" + with self._lock: + self._remote_lines.append(line) + + def append_local(self, line): + """Append a line to the local panel (thread-safe).""" + with self._lock: + self._local_lines.append(line) + + def set_remote_status(self, status): + """Update the remote panel status text (thread-safe).""" + with self._lock: + self._remote_status = status + + def set_local_status(self, status): + """Update the local panel status text (thread-safe).""" + with self._lock: + self._local_status = status + + def start(self): + """Start the live display. Returns the Live context for use with 'with'.""" + self._live = Live( + self._build_layout(), + console=self._console, + refresh_per_second=4, + ) + return self._live + + def refresh(self): + """Refresh the display with current state.""" + if self._live: + self._live.update(self._build_layout()) + + def get_remote_lines(self): + """Return a copy of all remote lines.""" + with self._lock: + return list(self._remote_lines) + + def get_local_lines(self): + """Return a copy of all local lines.""" + with self._lock: + return list(self._local_lines) +``` + +**Step 2: Verify pylint passes** + +Run: `pylint --rcfile=pylintrc bin/ci_tool/ci_analyse_display.py` +Expected: Score 10.0/10 + +**Step 3: Commit** + +```bash +git add bin/ci_tool/ci_analyse_display.py +git commit -m "Add split-panel display for parallel CI analysis" +``` + +--- + +### Task 3: Add main analyse module `ci_analyse.py` + +Core module with two code paths: remote-only (fast) and full parallel. Shared helpers for fetching, filtering, and diagnosing are used by both. + +**Files:** +- Create: `bin/ci_tool/ci_analyse.py` + +**Step 1: Create the analyse module** + +```python +#!/usr/bin/env python3 +"""Analyse CI failures from remote logs, optionally with local reproduction.""" +from __future__ import annotations + +import json +import subprocess +import sys +import threading +import time + +from InquirerPy import inquirer +from rich.console import Console +from rich.panel import Panel + +from ci_tool.ci_analyse_display import SplitPanelDisplay +from ci_tool.ci_fix import ( + build_analysis_prompt, + drop_to_shell, + extract_info_from_ci_url, + prompt_for_session_name, + refresh_claude_config, + run_claude_workflow, +) +from ci_tool.ci_log_filter import filter_ci_logs +from ci_tool.ci_reproduce import _parse_repo_url, reproduce_ci +from ci_tool.claude_setup import ( + copy_learnings_to_container, + is_claude_installed_in_container, + save_package_list, + setup_claude_in_container, +) +from ci_tool.containers import container_exists, remove_container +from ci_tool.preflight import run_all_preflight_checks, PreflightError + +console = Console() + +REMOTE_ANALYSIS_PROMPT = ( + "You are analysing CI failure logs from GitHub Actions. " + "The logs have been pre-filtered to show only failure-relevant lines.\n\n" + "For each failure, report:\n" + "- Package and test name\n" + "- The error/assertion message\n" + "- Your hypothesis for the root cause\n" + "- Suggested fix strategy\n\n" + "Be concise. Here are the filtered CI logs:\n\n{filtered_logs}" +) + +ANALYSE_DEPTH_CHOICES = [ + {"name": "Remote only (fast — no Docker)", "value": "remote_only"}, + {"name": "Remote + local reproduction (parallel)", "value": "full"}, +] + + +def _prompt_for_ci_url(): + """Ask user for the GitHub Actions run URL (required).""" + ci_run_url = inquirer.text( + message="GitHub Actions run URL:", + validate=lambda url: "/runs/" in url, + invalid_message=( + "URL must contain /runs/ " + "(e.g. https://github.com/org/repo/actions/runs/12345)" + ), + ).execute().strip() + + ci_run_info = extract_info_from_ci_url(ci_run_url) + console.print(f" [green]Repo:[/green] {ci_run_info['repo_url']}") + console.print(f" [green]Branch:[/green] {ci_run_info['branch']}") + console.print(f" [green]Run ID:[/green] {ci_run_info['run_id']}") + return ci_run_info + + +def _prompt_for_analyse_depth(): + """Ask user whether to do remote-only or full parallel analysis.""" + return inquirer.select( + message="Analysis depth:", + choices=ANALYSE_DEPTH_CHOICES, + default="remote_only", + ).execute() + + +# --------------------------------------------------------------------------- +# Shared helpers (used by both remote-only and full mode) +# --------------------------------------------------------------------------- + +def _fetch_failed_logs(run_id, owner_repo): + """Fetch failed job logs from GitHub Actions via gh CLI.""" + result = subprocess.run( + ["gh", "run", "view", run_id, "--log-failed", + "--repo", owner_repo], + capture_output=True, text=True, check=False, timeout=60, + ) + if result.returncode != 0: + raise RuntimeError( + f"Failed to fetch CI logs (exit {result.returncode}): " + f"{result.stderr.strip()[:300]}" + ) + if not result.stdout.strip(): + raise RuntimeError("GH Actions returned empty log output") + return result.stdout + + +def _run_claude_haiku_on_host(prompt): + """Run Claude haiku on the host to analyse filtered logs.""" + escaped_prompt = prompt.replace("'", "'\\''") + result = subprocess.run( + ["claude", "--model", "haiku", "-p", escaped_prompt, + "--max-turns", "1"], + capture_output=True, text=True, check=False, timeout=120, + ) + if result.returncode != 0: + raise RuntimeError( + f"Claude haiku failed (exit {result.returncode}): " + f"{result.stderr.strip()[:300]}" + ) + return result.stdout.strip() + + +def _fetch_filter_and_diagnose(ci_run_info): + """Fetch GH Actions logs, filter, and diagnose with Claude haiku. + + Returns (filtered_logs, diagnosis) tuple. + diagnosis is None if Claude haiku fails (filtered_logs still returned). + """ + run_id = ci_run_info["run_id"] + owner_repo = ci_run_info["owner_repo"] + + console.print("[cyan]Fetching CI logs...[/cyan]") + raw_logs = _fetch_failed_logs(run_id, owner_repo) + console.print(f" Fetched {len(raw_logs)} chars of log output") + + console.print("[cyan]Filtering logs...[/cyan]") + filtered_logs = filter_ci_logs(raw_logs) + if not filtered_logs: + console.print("[yellow]No failure patterns found in CI logs.[/yellow]") + return "", None + + filtered_line_count = len(filtered_logs.split('\n')) + console.print(f" Filtered to {filtered_line_count} lines") + + console.print("[cyan]Analysing with Claude haiku...[/cyan]") + try: + analysis_prompt = REMOTE_ANALYSIS_PROMPT.format( + filtered_logs=filtered_logs + ) + diagnosis = _run_claude_haiku_on_host(analysis_prompt) + except (RuntimeError, subprocess.TimeoutExpired) as error: + console.print( + f"[yellow]Claude haiku failed: {error}[/yellow]\n" + "[yellow]Showing filtered logs instead.[/yellow]" + ) + diagnosis = None + + return filtered_logs, diagnosis + + +# --------------------------------------------------------------------------- +# Remote-only mode +# --------------------------------------------------------------------------- + +def _run_remote_only(ci_run_info): + """Fast remote-only analysis: fetch, filter, diagnose, print report.""" + filtered_logs, diagnosis = _fetch_filter_and_diagnose(ci_run_info) + + console.print() + console.print(Panel("[bold cyan]Remote CI Analysis[/bold cyan]", expand=False)) + + if diagnosis: + console.print(diagnosis) + elif filtered_logs: + console.print(filtered_logs) + else: + console.print("[yellow]No failures found in CI logs.[/yellow]") + + +# --------------------------------------------------------------------------- +# Full parallel mode +# --------------------------------------------------------------------------- + +def _gather_full_mode_session_info(ci_run_info): + """Collect extra session info needed for local reproduction.""" + only_needed_deps = not inquirer.confirm( + message="Build everything (slower, disable --only-needed-deps)?", + default=False, + ).execute() + + container_name = prompt_for_session_name(ci_run_info["branch"]) + + return { + "ci_run_info": ci_run_info, + "repo_url": ci_run_info["repo_url"], + "branch": ci_run_info["branch"], + "only_needed_deps": only_needed_deps, + "container_name": container_name, + } + + +def _remote_analysis_thread(ci_run_info, display): + """Thread: fetch GH Actions logs, filter, analyse with Claude haiku.""" + run_id = ci_run_info["run_id"] + owner_repo = ci_run_info["owner_repo"] + + try: + display.set_remote_status("Fetching CI logs...") + display.refresh() + raw_logs = _fetch_failed_logs(run_id, owner_repo) + display.append_remote(f"Fetched {len(raw_logs)} chars of log output") + + display.set_remote_status("Filtering logs...") + display.refresh() + filtered_logs = filter_ci_logs(raw_logs) + if not filtered_logs: + display.append_remote("No failure patterns found in CI logs") + display.set_remote_status("Done (no failures found)") + display.refresh() + return + + filtered_line_count = len(filtered_logs.split('\n')) + display.append_remote(f"Filtered to {filtered_line_count} lines") + + display.set_remote_status("Analysing with Claude haiku...") + display.refresh() + analysis_prompt = REMOTE_ANALYSIS_PROMPT.format( + filtered_logs=filtered_logs + ) + diagnosis = _run_claude_haiku_on_host(analysis_prompt) + for line in diagnosis.split('\n'): + display.append_remote(line) + + display.set_remote_status("Done") + display.refresh() + + except (RuntimeError, subprocess.TimeoutExpired) as error: + display.append_remote(f"ERROR: {error}") + display.set_remote_status("Failed") + display.refresh() + + +def _local_reproduction_thread(session, gh_token, display): + """Thread: reproduce CI locally in Docker, then analyse.""" + container_name = session["container_name"] + + try: + display.set_local_status("Creating container & running CI...") + display.refresh() + + if container_exists(container_name): + remove_container(container_name) + reproduce_ci( + repo_url=session["repo_url"], + branch=session["branch"], + container_name=container_name, + gh_token=gh_token, + only_needed_deps=session["only_needed_deps"], + ) + save_package_list(container_name) + display.append_local("Container ready, build and tests complete") + + display.set_local_status("Setting up Claude in container...") + display.refresh() + if is_claude_installed_in_container(container_name): + refresh_claude_config(container_name) + else: + setup_claude_in_container(container_name) + + org, repo_name, _ = _parse_repo_url(session["repo_url"]) + if org and repo_name: + copy_learnings_to_container(container_name, org, repo_name) + + display.set_local_status("Analysing local test failures with Claude...") + display.refresh() + analysis_prompt = build_analysis_prompt(session["ci_run_info"]) + _run_container_analysis(container_name, analysis_prompt, display) + + display.set_local_status("Done") + display.refresh() + + except (RuntimeError, KeyboardInterrupt) as error: + display.append_local(f"ERROR: {error}") + display.set_local_status("Failed") + display.refresh() + + +def _run_container_analysis(container_name, prompt, display): + """Run Claude analysis inside container, capturing output to local panel.""" + escaped_prompt = prompt.replace("'", "'\\''") + claude_command = ( + f"cd /ros_ws && IS_SANDBOX=1 claude --dangerously-skip-permissions " + f"-p '{escaped_prompt}' --max-turns 10 --output-format stream-json " + f"2>/ros_ws/.claude_stderr.log" + ) + process = subprocess.Popen( + ["docker", "exec", "-e", "IS_SANDBOX=1", container_name, + "bash", "-c", claude_command], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + for raw_line in process.stdout: + raw_line = raw_line.strip() + if not raw_line: + continue + try: + event = json.loads(raw_line) + _handle_stream_event(event, display) + except json.JSONDecodeError: + pass + process.wait() + + +def _handle_stream_event(event, display): + """Handle a Claude stream-json event, appending text to the local panel.""" + if event.get("type") != "assistant": + return + for block in event.get("message", {}).get("content", []): + if block.get("type") == "text": + text = block.get("text", "") + if text.strip(): + for text_line in text.split('\n'): + display.append_local(text_line) + display.refresh() + + +def _print_combined_report(display): + """Print the combined analysis report after both threads complete.""" + console.print("\n") + console.print(Panel("[bold cyan]Combined Analysis Report[/bold cyan]", expand=False)) + + remote_lines = display.get_remote_lines() + local_lines = display.get_local_lines() + + if remote_lines: + console.print("\n[bold]Remote CI Analysis:[/bold]") + for line in remote_lines: + console.print(f" {line}") + + if local_lines: + console.print("\n[bold]Local Reproduction Analysis:[/bold]") + for line in local_lines: + console.print(f" {line}") + + if not remote_lines and not local_lines: + console.print("[yellow]No analysis results from either source.[/yellow]") + + +def _offer_fix_transition(container_name, ci_run_info): + """Ask user if they want to proceed to fix mode.""" + proceed = inquirer.confirm( + message="Proceed to fix with Claude?", + default=True, + ).execute() + + if proceed: + console.print("\n[bold cyan]Transitioning to fix mode...[/bold cyan]") + run_claude_workflow(container_name, ci_run_info) + drop_to_shell(container_name) + else: + shell_choice = inquirer.confirm( + message="Drop into container shell?", + default=True, + ).execute() + if shell_choice: + drop_to_shell(container_name) + + +def _run_full_parallel(ci_run_info, gh_token): + """Full parallel analysis with split display: remote + local.""" + session = _gather_full_mode_session_info(ci_run_info) + display = SplitPanelDisplay() + + remote_thread = threading.Thread( + target=_remote_analysis_thread, + args=(ci_run_info, display), + daemon=True, + ) + local_thread = threading.Thread( + target=_local_reproduction_thread, + args=(session, gh_token, display), + daemon=True, + ) + + try: + with display.start() as _live: + remote_thread.start() + local_thread.start() + while remote_thread.is_alive() or local_thread.is_alive(): + display.refresh() + time.sleep(0.25) + display.refresh() + except KeyboardInterrupt: + console.print("\n[yellow]Interrupted.[/yellow]") + return + + _print_combined_report(display) + _offer_fix_transition(session["container_name"], ci_run_info) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def analyse_ci(_args): + """Main analyse workflow: URL -> depth choice -> run analysis -> report.""" + console.print( + Panel("[bold cyan]Analyse CI[/bold cyan]", expand=False) + ) + + ci_run_info = _prompt_for_ci_url() + analyse_depth = _prompt_for_analyse_depth() + + if analyse_depth == "remote_only": + _run_remote_only(ci_run_info) + return + + # Full mode needs preflight checks (Docker, token, Claude credentials) + try: + gh_token = run_all_preflight_checks( + repo_url=ci_run_info["repo_url"] + ) + except PreflightError as error: + console.print( + f"\n[bold red]Preflight failed:[/bold red] {error}" + ) + sys.exit(1) + + _run_full_parallel(ci_run_info, gh_token) +``` + +**Step 2: Verify pylint passes** + +Run: `pylint --rcfile=pylintrc bin/ci_tool/ci_analyse.py` +Expected: Score 10.0/10 + +**Step 3: Commit** + +```bash +git add bin/ci_tool/ci_analyse.py +git commit -m "Add CI analysis module with remote-only and full parallel modes" +``` + +--- + +### Task 4: Wire up the menu in `cli.py` + +Add the "Analyse CI" menu item and dispatcher entry. + +**Files:** +- Modify: `bin/ci_tool/cli.py:14-22` (MENU_CHOICES) +- Modify: `bin/ci_tool/cli.py:24-40` (HELP_TEXT) +- Modify: `bin/ci_tool/cli.py:73-80` (dispatch_subcommand handlers) +- Add: `_handle_analyse()` function + +**Step 1: Add "Analyse CI" to MENU_CHOICES** + +In `bin/ci_tool/cli.py`, insert after "Reproduce CI" (line 15): + +```python +MENU_CHOICES = [ + {"name": "Reproduce CI (create container)", "value": "reproduce"}, + {"name": "Analyse CI", "value": "analyse"}, + {"name": "Fix CI with Claude", "value": "fix"}, + {"name": "Claude session (interactive)", "value": "claude"}, + {"name": "Shell into container", "value": "shell"}, + {"name": "Re-run tests in container", "value": "retest"}, + {"name": "Clean up containers", "value": "clean"}, + {"name": "Exit", "value": "exit"}, +] +``` + +**Step 2: Update HELP_TEXT** + +Add `analyse` to the commands list: + +``` +Commands: + analyse Analyse CI failures (remote-only or with local reproduction) + fix Fix CI failures with Claude + reproduce Reproduce CI environment in Docker + claude Interactive Claude session in container + shell Shell into an existing CI container + retest Re-run tests in a CI container + clean Remove CI containers +``` + +**Step 3: Add handler to dispatch_subcommand** + +Add `"analyse": _handle_analyse` to the handlers dict: + +```python +handlers = { + "reproduce": _handle_reproduce, + "analyse": _handle_analyse, + "fix": _handle_fix, + "claude": _handle_claude, + "shell": _handle_shell, + "retest": _handle_retest, + "clean": _handle_clean, +} +``` + +**Step 4: Add _handle_analyse function** + +Add alongside the other handler functions: + +```python +def _handle_analyse(args): + from ci_tool.ci_analyse import analyse_ci + analyse_ci(args) +``` + +**Step 5: Verify pylint passes** + +Run: `pylint --rcfile=pylintrc bin/ci_tool/cli.py` +Expected: Score 10.0/10 + +**Step 6: Commit** + +```bash +git add bin/ci_tool/cli.py +git commit -m "Add 'Analyse CI' to main menu and command dispatch" +``` + +--- + +### Task 5: Manual end-to-end test + +No unit tests yet — test the full workflow manually. + +**Step 1: Verify the module loads** + +Run: `cd /cortex/er_build_tools && python3 -c "from ci_tool.ci_analyse import analyse_ci; print('OK')"` +Expected: `OK` + +**Step 2: Verify menu shows new option** + +Run: `cd /cortex/er_build_tools && python3 -m ci_tool --help` +Expected: Output includes `analyse` in the commands list + +**Step 3: Run full pylint on the package** + +Run: `pylint --rcfile=pylintrc bin/ci_tool/` +Expected: Score 10.0/10 + +**Step 4: Test remote-only mode with a real GH Actions URL (if available)** + +Run: `cd /cortex/er_build_tools && python3 -m ci_tool analyse` +- Select a GH Actions URL +- Choose "Remote only (fast)" +- Expected: fetches logs, filters, sends to Claude haiku, prints diagnosis + +**Step 5: Commit any fixes needed** + +```bash +git add -u +git commit -m "Fix issues found during manual testing of analyse mode" +``` + +--- + +### Task 6: Update CLAUDE.md project docs + +**Files:** +- Modify: `CLAUDE.md` — add `analyse` to Running section and new files to Project Structure + +**Step 1: Add to Running section** + +``` +ci_tool # interactive menu +ci_tool analyse # analyse CI failures (remote-only or with local reproduction) +ci_fix # shortcut for ci_tool fix +``` + +**Step 2: Add to Project Structure** + +Under `bin/ci_tool/`, add: + +``` + ci_analyse.py # CI analysis: remote-only or parallel with local reproduction + ci_analyse_display.py # Split-panel Rich Live display for parallel analysis + ci_log_filter.py # Pre-filter GH Actions logs to reduce token usage +``` + +**Step 3: Verify pylint still passes** + +Run: `pylint --rcfile=pylintrc bin/ci_tool/` +Expected: Score 10.0/10 + +**Step 4: Commit** + +```bash +git add CLAUDE.md +git commit -m "Document new 'analyse' command in CLAUDE.md" +``` From 3a766b9f4ba0582a3924f01a033ff1819b5630c5 Mon Sep 17 00:00:00 2001 From: tomqext Date: Wed, 25 Feb 2026 23:14:39 +0000 Subject: [PATCH 60/60] Add TODO for Analyse CI mode, supersede parallel analysis item Links to docs/plans/2026-02-25-ci-analyse-mode-plan.md for full implementation details. Co-Authored-By: Claude Opus 4.6 --- bin/ci_tool/TODO.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/ci_tool/TODO.md b/bin/ci_tool/TODO.md index 7a72e77..a3593d8 100644 --- a/bin/ci_tool/TODO.md +++ b/bin/ci_tool/TODO.md @@ -2,7 +2,8 @@ ## Features -- [ ] **Parallel CI analysis during local reproduction**: When a GitHub Actions URL is provided, start the local build/test reproduction immediately and, in parallel, fetch the CI logs (`gh run view --log-failed`), digest the failures, and present a summary to the user while the container is still building. Currently `reproduce_ci()` blocks in `fix_ci()` (step 3) before any analysis happens. The idea: spawn the reproduction in the background, use the CI URL to pull logs and identify failures concurrently, then present the analysis to the user so they understand the problem before local tests even finish. This saves the entire reproduction wait time for understanding what went wrong. +- [ ] **Analyse CI mode**: Implement the plan in `docs/plans/2026-02-25-ci-analyse-mode-plan.md`. New "Analyse CI" menu item with two sub-modes: "Remote only (fast)" fetches GH Actions logs, filters with regex, diagnoses with Claude haiku on host — no Docker. "Remote + local reproduction" runs both in parallel with a Rich Live split-panel display, then offers to transition into fix mode. New files: `ci_analyse.py`, `ci_analyse_display.py`, `ci_log_filter.py`. +- [x] ~~**Parallel CI analysis during local reproduction**~~: Superseded by the Analyse CI mode above. ## UX