mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
11 Commits
prd/org-co
...
feat/git-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
087cef523c | ||
|
|
596e5fad3e | ||
|
|
959a377334 | ||
|
|
a140b4f150 | ||
|
|
aacf7ceeeb | ||
|
|
f221566bdb | ||
|
|
b16df05d2f | ||
|
|
68bad1886b | ||
|
|
36a92c5a86 | ||
|
|
a11b22c318 | ||
|
|
9d4ba3e70d |
@@ -341,59 +341,9 @@ class ActionExecutor:
|
||||
)
|
||||
|
||||
async def _init_bash_commands(self):
|
||||
INIT_COMMANDS = []
|
||||
is_local_runtime = os.environ.get('LOCAL_RUNTIME_MODE') == '1'
|
||||
is_windows = sys.platform == 'win32'
|
||||
|
||||
# Determine git config commands based on platform and runtime mode
|
||||
if is_local_runtime:
|
||||
if is_windows:
|
||||
# Windows, local - split into separate commands
|
||||
INIT_COMMANDS.append(
|
||||
'git config --file ./.git_config user.name "openhands"'
|
||||
)
|
||||
INIT_COMMANDS.append(
|
||||
'git config --file ./.git_config user.email "openhands@all-hands.dev"'
|
||||
)
|
||||
INIT_COMMANDS.append(
|
||||
'$env:GIT_CONFIG = (Join-Path (Get-Location) ".git_config")'
|
||||
)
|
||||
else:
|
||||
# Linux/macOS, local
|
||||
base_git_config = (
|
||||
'git config --file ./.git_config user.name "openhands" && '
|
||||
'git config --file ./.git_config user.email "openhands@all-hands.dev" && '
|
||||
'export GIT_CONFIG=$(pwd)/.git_config'
|
||||
)
|
||||
INIT_COMMANDS.append(base_git_config)
|
||||
else:
|
||||
# Non-local (implies Linux/macOS)
|
||||
base_git_config = (
|
||||
'git config --global user.name "openhands" && '
|
||||
'git config --global user.email "openhands@all-hands.dev"'
|
||||
)
|
||||
INIT_COMMANDS.append(base_git_config)
|
||||
|
||||
# Determine no-pager command
|
||||
if is_windows:
|
||||
no_pager_cmd = 'function git { git.exe --no-pager $args }'
|
||||
else:
|
||||
no_pager_cmd = 'alias git="git --no-pager"'
|
||||
|
||||
INIT_COMMANDS.append(no_pager_cmd)
|
||||
|
||||
logger.info(f'Initializing by running {len(INIT_COMMANDS)} bash commands...')
|
||||
for command in INIT_COMMANDS:
|
||||
action = CmdRunAction(command=command)
|
||||
action.set_hard_timeout(300)
|
||||
logger.debug(f'Executing init command: {command}')
|
||||
obs = await self.run(action)
|
||||
assert isinstance(obs, CmdOutputObservation)
|
||||
logger.debug(
|
||||
f'Init command outputs (exit code: {obs.exit_code}): {obs.content}'
|
||||
)
|
||||
assert obs.exit_code == 0
|
||||
logger.debug('Bash init commands completed')
|
||||
# Git configuration is now handled by the runtime base class in setup_git_config()
|
||||
# This method is kept for any future bash initialization commands that may be needed
|
||||
logger.debug('Bash init commands completed (git config handled by runtime)')
|
||||
|
||||
async def run_action(self, action) -> Observation:
|
||||
async with self.lock:
|
||||
@@ -993,6 +943,15 @@ if __name__ == '__main__':
|
||||
return {'status': 'not initialized'}
|
||||
return {'status': 'ok'}
|
||||
|
||||
@app.get('/platform_info')
|
||||
async def platform_info():
|
||||
"""Get platform information including whether the system is Windows."""
|
||||
return {
|
||||
'platform': sys.platform,
|
||||
'is_windows': sys.platform == 'win32',
|
||||
'is_local_runtime': os.environ.get('LOCAL_RUNTIME_MODE') == '1',
|
||||
}
|
||||
|
||||
# ================================
|
||||
# VSCode-specific operations
|
||||
# ================================
|
||||
|
||||
@@ -131,6 +131,8 @@ class Runtime(FileEditRuntimeMixin):
|
||||
headless_mode: bool = False,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
user_email: str | None = None,
|
||||
user_name: str | None = None,
|
||||
):
|
||||
self.git_handler = GitHandler(
|
||||
execute_shell_fn=self._execute_shell_fn_git_handler
|
||||
@@ -180,6 +182,8 @@ class Runtime(FileEditRuntimeMixin):
|
||||
|
||||
self.user_id = user_id
|
||||
self.git_provider_tokens = git_provider_tokens
|
||||
self.user_email = user_email
|
||||
self.user_name = user_name
|
||||
self.runtime_status = None
|
||||
|
||||
@property
|
||||
@@ -194,6 +198,9 @@ class Runtime(FileEditRuntimeMixin):
|
||||
if self.config.sandbox.runtime_startup_env_vars:
|
||||
self.add_env_vars(self.config.sandbox.runtime_startup_env_vars)
|
||||
|
||||
# Set up git configuration with user coauthor information
|
||||
self.setup_git_config()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
This should only be called by conversation manager or closing the session.
|
||||
@@ -989,6 +996,12 @@ fi
|
||||
def vscode_url(self) -> str | None:
|
||||
raise NotImplementedError('This method is not implemented in the base class.')
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def action_execution_server_url(self) -> str:
|
||||
"""Get the URL of the action execution server."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def web_hosts(self) -> dict[str, int]:
|
||||
return {}
|
||||
@@ -1026,6 +1039,179 @@ fi
|
||||
self.git_handler.set_cwd(cwd)
|
||||
return self.git_handler.get_git_diff(file_path)
|
||||
|
||||
def _get_platform_info(self) -> dict[str, str | bool]:
|
||||
"""Get platform information from the action execution server."""
|
||||
try:
|
||||
# Try to get platform info from the action execution server
|
||||
server_url = self.action_execution_server_url
|
||||
action = CmdRunAction(command=f'curl -s {server_url}/platform_info')
|
||||
action.set_hard_timeout(10)
|
||||
obs = self.run(action)
|
||||
if isinstance(obs, CmdOutputObservation) and obs.exit_code == 0:
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.loads(obs.content)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
'Failed to parse platform info response, using fallback'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'Failed to get platform info from server, using fallback'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error getting platform info: {e}, using fallback')
|
||||
|
||||
# Fallback to local detection (though this may not be accurate in containerized environments)
|
||||
import os
|
||||
import sys
|
||||
|
||||
return {
|
||||
'platform': sys.platform,
|
||||
'is_windows': sys.platform == 'win32',
|
||||
'is_local_runtime': os.environ.get('LOCAL_RUNTIME_MODE') == '1',
|
||||
}
|
||||
|
||||
def setup_git_config(self) -> None:
|
||||
"""Set up git configuration with OpenHands defaults and user coauthor information."""
|
||||
INIT_COMMANDS = []
|
||||
|
||||
# Get platform information from the action execution server
|
||||
platform_info = self._get_platform_info()
|
||||
is_local_runtime = platform_info.get('is_local_runtime', False)
|
||||
is_windows = platform_info.get('is_windows', False)
|
||||
|
||||
# Determine git config commands based on platform and runtime mode
|
||||
if is_local_runtime:
|
||||
if is_windows:
|
||||
# Windows, local - split into separate commands
|
||||
INIT_COMMANDS.append(
|
||||
'git config --file ./.git_config user.name "openhands"'
|
||||
)
|
||||
INIT_COMMANDS.append(
|
||||
'git config --file ./.git_config user.email "openhands@all-hands.dev"'
|
||||
)
|
||||
INIT_COMMANDS.append(
|
||||
'$env:GIT_CONFIG = (Join-Path (Get-Location) ".git_config")'
|
||||
)
|
||||
else:
|
||||
# Linux/macOS, local
|
||||
base_git_config = (
|
||||
'git config --file ./.git_config user.name "openhands" && '
|
||||
'git config --file ./.git_config user.email "openhands@all-hands.dev" && '
|
||||
'export GIT_CONFIG=$(pwd)/.git_config'
|
||||
)
|
||||
INIT_COMMANDS.append(base_git_config)
|
||||
else:
|
||||
# Non-local (implies Linux/macOS)
|
||||
base_git_config = (
|
||||
'git config --global user.name "openhands" && '
|
||||
'git config --global user.email "openhands@all-hands.dev"'
|
||||
)
|
||||
INIT_COMMANDS.append(base_git_config)
|
||||
|
||||
# Set up coauthor information if user credentials are available
|
||||
if self.user_email and self.user_name:
|
||||
coauthor_value = f'{self.user_name} <{self.user_email}>'
|
||||
|
||||
# Set up git config for coauthor
|
||||
if is_local_runtime:
|
||||
if is_windows:
|
||||
# Windows, local
|
||||
INIT_COMMANDS.append(
|
||||
f'git config --file ./.git_config user.coauthor "{coauthor_value}"'
|
||||
)
|
||||
else:
|
||||
# Linux/macOS, local
|
||||
INIT_COMMANDS.append(
|
||||
f'git config --file ./.git_config user.coauthor "{coauthor_value}"'
|
||||
)
|
||||
else:
|
||||
# Non-local (implies Linux/macOS)
|
||||
INIT_COMMANDS.append(
|
||||
f'git config --global user.coauthor "{coauthor_value}"'
|
||||
)
|
||||
|
||||
# Create prepare-commit-msg hook content
|
||||
prepare_commit_msg_content = """#!/bin/sh
|
||||
#
|
||||
# Automatically add Co-authored-by line to commit messages
|
||||
#
|
||||
# This hook adds a Co-authored-by line to the commit message
|
||||
# if the user has configured a co-author in their git config.
|
||||
|
||||
COMMIT_MSG_FILE=$1
|
||||
COMMIT_SOURCE=$2
|
||||
SHA1=$3
|
||||
|
||||
# Check if a co-author is configured
|
||||
CO_AUTHOR=$(git config --get user.coauthor)
|
||||
|
||||
if [ -n "$CO_AUTHOR" ]; then
|
||||
# Check if the message already contains a Co-authored-by line
|
||||
if ! grep -q "Co-authored-by:" "$COMMIT_MSG_FILE"; then
|
||||
# Add an empty line and the Co-authored-by line
|
||||
echo "" >> "$COMMIT_MSG_FILE"
|
||||
echo "Co-authored-by: $CO_AUTHOR" >> "$COMMIT_MSG_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
"""
|
||||
|
||||
# Create the hooks directory if it doesn't exist
|
||||
INIT_COMMANDS.append('mkdir -p .git/hooks')
|
||||
|
||||
# Write the prepare-commit-msg hook to a file
|
||||
hook_cmd = f'echo {json.dumps(prepare_commit_msg_content)} > .git/hooks/prepare-commit-msg'
|
||||
INIT_COMMANDS.append(hook_cmd)
|
||||
|
||||
# Make the hook executable
|
||||
INIT_COMMANDS.append('chmod +x .git/hooks/prepare-commit-msg')
|
||||
|
||||
# Log that we're setting up the hook
|
||||
logger.info(
|
||||
'Setting up prepare-commit-msg hook for automatic co-author attribution'
|
||||
)
|
||||
|
||||
# Determine no-pager command
|
||||
if is_windows:
|
||||
no_pager_cmd = 'function git { git.exe --no-pager $args }'
|
||||
else:
|
||||
no_pager_cmd = 'alias git="git --no-pager"'
|
||||
|
||||
INIT_COMMANDS.append(no_pager_cmd)
|
||||
|
||||
logger.info(
|
||||
f'Setting up git configuration with {len(INIT_COMMANDS)} commands...'
|
||||
)
|
||||
for command in INIT_COMMANDS:
|
||||
action = CmdRunAction(command=command)
|
||||
action.set_hard_timeout(300)
|
||||
logger.debug(f'Executing git config command: {command}')
|
||||
obs = self.run(action)
|
||||
if isinstance(obs, CmdOutputObservation):
|
||||
logger.debug(
|
||||
f'Git config command outputs (exit code: {obs.exit_code}): {obs.content}'
|
||||
)
|
||||
if obs.exit_code != 0:
|
||||
logger.warning(
|
||||
f'Git config command failed with exit code {obs.exit_code}: {obs.content}'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'Unexpected observation type from git config command: {type(obs)}'
|
||||
)
|
||||
|
||||
if self.user_email and self.user_name:
|
||||
logger.info(
|
||||
f'Git configuration completed with coauthor: {self.user_name} <{self.user_email}>'
|
||||
)
|
||||
else:
|
||||
logger.info('Git configuration completed')
|
||||
logger.debug('Git configuration setup completed')
|
||||
|
||||
@property
|
||||
def additional_agent_instructions(self) -> str:
|
||||
return ''
|
||||
|
||||
@@ -76,6 +76,8 @@ class ActionExecutionClient(Runtime):
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
user_email: str | None = None,
|
||||
user_name: str | None = None,
|
||||
):
|
||||
self.session = HttpSession()
|
||||
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
|
||||
@@ -93,6 +95,8 @@ class ActionExecutionClient(Runtime):
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
user_email,
|
||||
user_name,
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -976,3 +976,11 @@ class CLIRuntime(Runtime):
|
||||
"""
|
||||
self._shell_stream_callback = callback
|
||||
return True
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self) -> str:
|
||||
"""Get the URL of the action execution server.
|
||||
|
||||
For CLI runtime, this is not applicable as commands run locally.
|
||||
"""
|
||||
return 'http://localhost:8000'
|
||||
|
||||
@@ -90,6 +90,8 @@ class DockerRuntime(ActionExecutionClient):
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
user_email: str | None = None,
|
||||
user_name: str | None = None,
|
||||
main_module: str = DEFAULT_MAIN_MODULE,
|
||||
):
|
||||
if not DockerRuntime._shutdown_listener_id:
|
||||
@@ -143,6 +145,8 @@ class DockerRuntime(ActionExecutionClient):
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
user_email,
|
||||
user_name,
|
||||
)
|
||||
|
||||
# Log runtime_extra_deps after base class initialization so self.sid is available
|
||||
|
||||
@@ -343,6 +343,15 @@ class BashSession:
|
||||
num_lines = len(raw_command_output.splitlines())
|
||||
metadata.prefix = f'[Previous command outputs are truncated. Showing the last {num_lines} lines of the output below.]\n'
|
||||
|
||||
# Check for "command not found" error and ensure exit code is 127
|
||||
if (
|
||||
'command not found' in raw_command_output
|
||||
and command != ''
|
||||
and not is_special_key
|
||||
):
|
||||
metadata.exit_code = 127
|
||||
logger.debug(f'Setting exit code to 127 for command not found: {command}')
|
||||
|
||||
metadata.suffix = (
|
||||
f'\n[The command completed with exit code {metadata.exit_code}.]'
|
||||
if not is_special_key
|
||||
|
||||
@@ -98,6 +98,8 @@ class AgentSession:
|
||||
initial_message: MessageAction | None = None,
|
||||
conversation_instructions: str | None = None,
|
||||
replay_json: str | None = None,
|
||||
user_email: str | None = None,
|
||||
user_name: str | None = None,
|
||||
) -> None:
|
||||
"""Starts the Agent session
|
||||
Parameters:
|
||||
@@ -136,6 +138,8 @@ class AgentSession:
|
||||
custom_secrets=custom_secrets,
|
||||
selected_repository=selected_repository,
|
||||
selected_branch=selected_branch,
|
||||
user_email=user_email,
|
||||
user_name=user_name,
|
||||
)
|
||||
|
||||
repo_directory = None
|
||||
@@ -313,6 +317,8 @@ class AgentSession:
|
||||
custom_secrets: CUSTOM_SECRETS_TYPE | None = None,
|
||||
selected_repository: str | None = None,
|
||||
selected_branch: str | None = None,
|
||||
user_email: str | None = None,
|
||||
user_name: str | None = None,
|
||||
) -> bool:
|
||||
"""Creates a runtime instance
|
||||
|
||||
@@ -351,6 +357,8 @@ class AgentSession:
|
||||
git_provider_tokens=overrided_tokens,
|
||||
env_vars=env_vars,
|
||||
user_id=self.user_id,
|
||||
user_email=user_email,
|
||||
user_name=user_name,
|
||||
)
|
||||
else:
|
||||
provider_handler = ProviderHandler(
|
||||
@@ -370,6 +378,9 @@ class AgentSession:
|
||||
attach_to_existing=False,
|
||||
env_vars=env_vars,
|
||||
git_provider_tokens=git_provider_tokens,
|
||||
user_id=self.user_id,
|
||||
user_email=user_email,
|
||||
user_name=user_name,
|
||||
)
|
||||
|
||||
# FIXME: this sleep is a terrible hack.
|
||||
|
||||
@@ -192,6 +192,8 @@ class Session:
|
||||
selected_branch = None
|
||||
custom_secrets = None
|
||||
conversation_instructions = None
|
||||
user_email = settings.email
|
||||
user_name = settings.name
|
||||
if isinstance(settings, ConversationInitData):
|
||||
git_provider_tokens = settings.git_provider_tokens
|
||||
selected_repository = settings.selected_repository
|
||||
@@ -215,6 +217,8 @@ class Session:
|
||||
initial_message=initial_message,
|
||||
conversation_instructions=conversation_instructions,
|
||||
replay_json=replay_json,
|
||||
user_email=user_email,
|
||||
user_name=user_name,
|
||||
)
|
||||
except MicroagentValidationError as e:
|
||||
self.logger.exception(f'Error creating agent_session: {e}')
|
||||
|
||||
@@ -45,6 +45,7 @@ class Settings(BaseModel):
|
||||
max_budget_per_task: float | None = None
|
||||
email: str | None = None
|
||||
email_verified: bool | None = None
|
||||
name: str | None = None
|
||||
|
||||
model_config = ConfigDict(
|
||||
validate_assignment=True,
|
||||
|
||||
@@ -156,3 +156,46 @@ class TestGitHooks:
|
||||
assert mock_runtime.log.call_args_list[-1] == call(
|
||||
'info', 'Git pre-commit hook installed successfully'
|
||||
)
|
||||
|
||||
def test_prepare_commit_msg_hook_setup(self):
|
||||
# Test that the prepare-commit-msg hook is set up correctly when user info is available
|
||||
# Create a mock runtime with user info
|
||||
mock_runtime = MagicMock(spec=Runtime)
|
||||
mock_runtime.user_name = 'Test User'
|
||||
mock_runtime.user_email = 'test@example.com'
|
||||
|
||||
# Mock the run method to return success
|
||||
mock_runtime.run.return_value = CmdOutputObservation(
|
||||
content='', exit_code=0, command='test command'
|
||||
)
|
||||
|
||||
# Get the setup_git_config method from the Runtime class
|
||||
setup_git_config_method = Runtime.setup_git_config
|
||||
|
||||
# Call the method with our mock runtime
|
||||
setup_git_config_method(mock_runtime)
|
||||
|
||||
# Get all the commands that were run
|
||||
run_calls = [
|
||||
call[0][0].command
|
||||
for call in mock_runtime.run.call_args_list
|
||||
if isinstance(call[0][0], CmdRunAction)
|
||||
]
|
||||
|
||||
# Check that the coauthor config was set
|
||||
coauthor_calls = [cmd for cmd in run_calls if 'user.coauthor' in cmd]
|
||||
assert len(coauthor_calls) > 0, 'No user.coauthor configuration was set'
|
||||
|
||||
# Check that the prepare-commit-msg hook was created
|
||||
hook_creation_calls = [
|
||||
cmd for cmd in run_calls if '.git/hooks/prepare-commit-msg' in cmd
|
||||
]
|
||||
assert len(hook_creation_calls) > 0, 'No prepare-commit-msg hook was created'
|
||||
|
||||
# Check that the hook was made executable
|
||||
chmod_calls = [
|
||||
cmd
|
||||
for cmd in run_calls
|
||||
if 'chmod +x' in cmd and 'prepare-commit-msg' in cmd
|
||||
]
|
||||
assert len(chmod_calls) > 0, 'prepare-commit-msg hook was not made executable'
|
||||
|
||||
@@ -74,6 +74,11 @@ class TestRuntime(Runtime):
|
||||
):
|
||||
return MCPConfig()
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self) -> str:
|
||||
"""Return a mock action execution server URL."""
|
||||
return 'http://localhost:8000'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(tmp_path_factory: pytest.TempPathFactory) -> str:
|
||||
|
||||
@@ -135,6 +135,11 @@ class MockRuntime(Runtime):
|
||||
|
||||
return MCPObservation(content='', tool='', result='')
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self) -> str:
|
||||
"""Return a mock action execution server URL."""
|
||||
return 'http://localhost:8000'
|
||||
|
||||
|
||||
def create_test_microagents(base_dir: Path, config_dir_name: str = '.openhands'):
|
||||
"""Create test microagent files in the specified directory."""
|
||||
|
||||
Reference in New Issue
Block a user