Source code for roller.executor

"""
Main executor for coordinating patch series operations.
"""

import concurrent.futures
from pathlib import Path

from .config import SeriesConfig
from .reporter import Reporter, Status
from .repository import Repository, RepositoryError
from .workspace import Workspace


[docs] class PatchExecutor: """Orchestrates patch series execution across multiple repositories.""" def __init__( self, config: SeriesConfig, reporter: Reporter, exit_on_error: bool = False ):
[docs] self.config = config
[docs] self.reporter = reporter
[docs] self.exit_on_error = exit_on_error
[docs] self.workspace = Workspace()
[docs] def execute(self) -> bool: """ Execute the patch series across all repositories. Returns True if all operations succeeded. """ # Create workspace workspace_path = self.workspace.create() self.reporter.print_header(self.config.topic, str(workspace_path)) # Validate patch script exists script_path = Path(self.config.commands) if not script_path.exists(): print(f"Error: Patch script not found: {script_path}") return False if not script_path.is_file(): print(f"Error: Patch script is not a file: {script_path}") return False # Make script executable script_path.chmod(0o755) # Process repositories in parallel total = len(self.config.projects) all_success = True with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: futures = { executor.submit( self._process_repository, url, idx + 1, total, script_path ): url for idx, url in enumerate(self.config.projects) } for future in concurrent.futures.as_completed(futures): url = futures[future] try: success = future.result() if not success: all_success = False if self.exit_on_error: print("\nExiting due to error (--exit-on-error flag)") # Cancel remaining futures for f in futures: f.cancel() break except Exception as e: print(f"\nUnexpected error processing {url}: {e}") all_success = False if self.exit_on_error: break # Print summary self.reporter.print_summary() return all_success
def _process_repository( self, url: str, index: int, total: int, script_path: Path ) -> bool: """Process a single repository. Returns True on success.""" assert self.workspace.path is not None, "Workspace must be created first" repo = Repository(url, self.workspace.path) self.reporter.print_repo_start(index, total, repo.name) original_branch: str | None = None try: # Clone repository if self.config.dry_run: self.reporter.print_step( Status.INFO, "[DRY RUN] Would clone repository" ) else: repo.clone() self.reporter.print_step(Status.SUCCESS, "Cloned repository") # Setup git-review for Gerrit if not self.config.dry_run: repo.setup_review() self.reporter.print_step(Status.SUCCESS, "Setup git-review") # Handle branch switching if self.config.branch: if self.config.dry_run: self.reporter.print_step( Status.INFO, f"[DRY RUN] Would switch to branch '{self.config.branch}'", ) else: original_branch = repo.get_current_branch() if original_branch: self.reporter.print_step( Status.INFO, f"Current branch: {original_branch}" ) # Check for uncommitted changes if repo.has_uncommitted_changes(): raise RepositoryError( "Repository has uncommitted changes. " "Please commit or stash them before switching branches." ) # Check if target branch exists if repo.branch_exists(self.config.branch): repo.checkout_branch(self.config.branch) self.reporter.print_step( Status.SUCCESS, f"Switched to branch '{self.config.branch}'" ) elif self.config.create_branch: repo.create_branch(self.config.branch) self.reporter.print_step( Status.SUCCESS, f"Created and switched to branch '{self.config.branch}'", ) else: raise RepositoryError( f"Branch '{self.config.branch}' does not exist. " "Use --create-branch to create it." ) # Execute pre-commands if self.config.pre_commands: for cmd in self.config.pre_commands: if self.config.dry_run: self.reporter.print_step( Status.INFO, f"[DRY RUN] Would run pre-command: {cmd}" ) else: if ( not self._execute_command(repo, cmd, "pre-command") and not self.config.continue_on_error ): self.reporter.add_result( repo.name, "failed", f"Pre-command failed: {cmd}" ) return False # Execute patch script if self.config.dry_run: self.reporter.print_step( Status.INFO, f"[DRY RUN] Would execute patch script: {script_path}" ) else: success, _stdout, stderr = repo.run_command(str(script_path.absolute())) if not success: self.reporter.print_step( Status.FAILED, "Patch script failed (exit code non-zero)" ) if stderr: self.reporter.print_step( Status.INFO, f"Error: {stderr.strip()}", indent=4 ) self.reporter.add_result(repo.name, "failed", "Patch script failed") return False self.reporter.print_step(Status.SUCCESS, "Executed patch script") # Check for changes if not self.config.dry_run and not repo.has_changes(): self.reporter.print_step(Status.INFO, "No changes detected, skipping") self.reporter.add_result(repo.name, "skipped", "No changes") return True # Run tests if configured if self.config.run_tests: if self.config.dry_run: self.reporter.print_step( Status.INFO, f"[DRY RUN] Would run tests: {self.config.test_command}", ) else: self.reporter.print_step( Status.RUNNING, f"Running tests ({self.config.test_command})..." ) test_success, _test_stdout, _test_stderr = repo.run_command( self.config.test_command ) if test_success: self.reporter.print_step(Status.SUCCESS, "Tests PASSED") else: self.reporter.print_step(Status.FAILED, "Tests FAILED") if self.config.tests_blocking: self.reporter.add_result( repo.name, "failed", "Tests failed (blocking)" ) return False else: self.reporter.print_step( Status.INFO, "Continuing despite test failure (non-blocking)", indent=4, ) # Commit changes if enabled if self.config.commit: if self.config.dry_run: self.reporter.print_step( Status.INFO, "[DRY RUN] Would commit changes" ) else: repo.stage_all() commit_msg = self._render_commit_message(repo.name) commit_hash = repo.commit(commit_msg) self.reporter.print_step( Status.SUCCESS, f"Committed changes ({commit_hash})" ) # Submit for review if enabled if self.config.review: if self.config.dry_run: self.reporter.print_step( Status.INFO, "[DRY RUN] Would submit for review" ) else: topic = self.config.topic if self.config.topic else None repo.submit_review(topic) self.reporter.print_step(Status.SUCCESS, "Submitted for review") # Execute post-commands if self.config.post_commands: for cmd in self.config.post_commands: if self.config.dry_run: self.reporter.print_step( Status.INFO, f"[DRY RUN] Would run post-command: {cmd}" ) else: if ( not self._execute_command(repo, cmd, "post-command") and not self.config.continue_on_error ): self.reporter.add_result( repo.name, "failed", f"Post-command failed: {cmd}" ) return False # Return to original branch if needed if ( self.config.branch and original_branch and not self.config.stay_on_branch ): if self.config.dry_run: self.reporter.print_step( Status.INFO, f"[DRY RUN] Would return to branch '{original_branch}'", ) else: repo.checkout_branch(original_branch) self.reporter.print_step( Status.SUCCESS, f"Returned to branch '{original_branch}'" ) self.reporter.add_result(repo.name, "succeeded") return True except RepositoryError as e: self.reporter.print_step(Status.FAILED, str(e)) self.reporter.add_result(repo.name, "failed", str(e)) return False except Exception as e: self.reporter.print_step(Status.FAILED, f"Unexpected error: {e}") self.reporter.add_result(repo.name, "failed", str(e)) return False def _render_commit_message(self, repo_name: str) -> str: """Render commit message template with variables.""" message = self.config.commit_msg # Replace template variables message = message.replace("{{ project_name }}", repo_name) message = message.replace("{{project_name}}", repo_name) return message def _execute_command( self, repo: Repository, command: str, command_type: str ) -> bool: """ Execute a command and report results. Returns True on success, False on failure. """ self.reporter.print_step(Status.RUNNING, f"Running {command_type}: {command}") success, stdout, stderr = repo.run_command(command) if success: self.reporter.print_step( Status.SUCCESS, f"{command_type.capitalize()} succeeded" ) if stdout.strip(): for line in stdout.strip().split("\n")[:10]: # Show first 10 lines self.reporter.print_step(Status.INFO, line, indent=4) else: self.reporter.print_step( Status.FAILED, f"{command_type.capitalize()} failed" ) if stderr.strip(): for line in stderr.strip().split("\n")[:10]: # Show first 10 lines self.reporter.print_step(Status.INFO, f"Error: {line}", indent=4) if self.config.continue_on_error: self.reporter.print_step( Status.INFO, "Continuing despite command failure (--continue-on-error)", indent=4, ) return success