mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c987e5db0b | |||
| 62f6f7e209 | |||
| 717fc50115 | |||
| 1408b6d80c | |||
| 92f4a6a4be | |||
| af93edc60d |
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { FaListUl } from "react-icons/fa";
|
||||
import { FaListUl, FaRobot } from "react-icons/fa";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { NavLink, useLocation } from "react-router";
|
||||
@@ -105,6 +105,20 @@ export function Sidebar() {
|
||||
)}
|
||||
/>
|
||||
</TooltipButton>
|
||||
<NavLink
|
||||
to="/agents"
|
||||
className={({ isActive }) =>
|
||||
`${isActive ? "text-white" : "text-[#9099AC]"}`
|
||||
}
|
||||
>
|
||||
<TooltipButton
|
||||
testId="agents-page"
|
||||
tooltip={t(I18nKey.AGENTS$ACTIVE_AGENTS)}
|
||||
ariaLabel={t(I18nKey.AGENTS$ACTIVE_AGENTS)}
|
||||
>
|
||||
<FaRobot size={22} />
|
||||
</TooltipButton>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// this file generate by script, don't modify it manually!!!
|
||||
export enum I18nKey {
|
||||
AGENTS$ACTIVE_AGENTS = "AGENTS$ACTIVE_AGENTS",
|
||||
AGENTS$NO_ACTIVE_AGENTS = "AGENTS$NO_ACTIVE_AGENTS",
|
||||
HOME$CONNECT_PROVIDER_MESSAGE = "HOME$CONNECT_PROVIDER_MESSAGE",
|
||||
HOME$LETS_START_BUILDING = "HOME$LETS_START_BUILDING",
|
||||
HOME$OPENHANDS_DESCRIPTION = "HOME$OPENHANDS_DESCRIPTION",
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
{
|
||||
"AGENTS$ACTIVE_AGENTS": {
|
||||
"en": "Active Agents",
|
||||
"ja": "アクティブなエージェント",
|
||||
"zh-CN": "活跃的代理",
|
||||
"zh-TW": "活躍的代理",
|
||||
"ko-KR": "활성 에이전트",
|
||||
"no": "Aktive agenter",
|
||||
"it": "Agenti attivi",
|
||||
"pt": "Agentes ativos",
|
||||
"es": "Agentes activos",
|
||||
"ar": "العملاء النشطين",
|
||||
"fr": "Agents actifs",
|
||||
"tr": "Aktif Ajanlar",
|
||||
"de": "Aktive Agenten"
|
||||
},
|
||||
"AGENTS$NO_ACTIVE_AGENTS": {
|
||||
"en": "No active agents found",
|
||||
"ja": "アクティブなエージェントが見つかりません",
|
||||
"zh-CN": "未找到活跃的代理",
|
||||
"zh-TW": "未找到活躍的代理",
|
||||
"ko-KR": "활성 에이전트를 찾을 수 없습니다",
|
||||
"no": "Ingen aktive agenter funnet",
|
||||
"it": "Nessun agente attivo trovato",
|
||||
"pt": "Nenhum agente ativo encontrado",
|
||||
"es": "No se encontraron agentes activos",
|
||||
"ar": "لم يتم العثور على عملاء نشطين",
|
||||
"fr": "Aucun agent actif trouvé",
|
||||
"tr": "Aktif ajan bulunamadı",
|
||||
"de": "Keine aktiven Agenten gefunden"
|
||||
},
|
||||
"HOME$CONNECT_PROVIDER_MESSAGE": {
|
||||
"en": "To get started with suggested tasks, please connect your GitHub or GitLab account.",
|
||||
"ja": "提案されたタスクを始めるには、GitHubまたはGitLabアカウントを接続してください。",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
export default [
|
||||
layout("routes/root-layout.tsx", [
|
||||
index("routes/home.tsx"),
|
||||
route("agents", "routes/agents.tsx"),
|
||||
route("settings", "routes/settings.tsx", [
|
||||
index("routes/account-settings.tsx"),
|
||||
route("billing", "routes/billing.tsx"),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useUserConversations } from "#/hooks/query/use-user-conversations";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
|
||||
import { NavLink } from "react-router";
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: conversations, isFetching, error } = useUserConversations();
|
||||
|
||||
// Filter only active (RUNNING) conversations
|
||||
const activeConversations = conversations?.filter(
|
||||
(conversation) => conversation.status === "RUNNING"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">{t(I18nKey.AGENTS$ACTIVE_AGENTS)}</h1>
|
||||
|
||||
{isFetching && (
|
||||
<div className="w-full flex justify-center items-center py-12">
|
||||
<LoadingSpinner size="medium" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-danger">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeConversations?.length === 0 && !isFetching && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-neutral-400">
|
||||
<p>{t(I18nKey.AGENTS$NO_ACTIVE_AGENTS)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{activeConversations?.map((conversation) => (
|
||||
<NavLink
|
||||
key={conversation.conversation_id}
|
||||
to={`/conversations/${conversation.conversation_id}`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<ConversationCard
|
||||
isActive={isActive}
|
||||
title={conversation.title}
|
||||
selectedRepository={conversation.selected_repository}
|
||||
lastUpdatedAt={conversation.last_updated_at}
|
||||
createdAt={conversation.created_at}
|
||||
status={conversation.status}
|
||||
conversationId={conversation.conversation_id}
|
||||
showOptions={true}
|
||||
/>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,6 @@ from openhands.integrations.service_types import (
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
|
||||
class GitHubService(BaseGitService, GitService):
|
||||
@@ -159,13 +157,6 @@ class GitHubService(BaseGitService, GitService):
|
||||
|
||||
return repos[:max_repos] # Trim to max_repos if needed
|
||||
|
||||
|
||||
def parse_pushed_at_date(self, repo):
|
||||
ts = repo.get("pushed_at")
|
||||
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ") if ts else datetime.min
|
||||
|
||||
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitHub API
|
||||
@@ -192,11 +183,6 @@ class GitHubService(BaseGitService, GitService):
|
||||
# If we've already reached MAX_REPOS, no need to check other installations
|
||||
if len(all_repos) >= MAX_REPOS:
|
||||
break
|
||||
|
||||
if sort == "pushed":
|
||||
all_repos.sort(
|
||||
key=self.parse_pushed_at_date, reverse=True
|
||||
)
|
||||
else:
|
||||
# Original behavior for non-SaaS mode
|
||||
params = {'per_page': str(PER_PAGE), 'sort': sort}
|
||||
@@ -205,7 +191,6 @@ class GitHubService(BaseGitService, GitService):
|
||||
# Fetch user repositories
|
||||
all_repos = await self._fetch_paginated_repos(url, params, MAX_REPOS)
|
||||
|
||||
|
||||
# Convert to Repository objects
|
||||
return [
|
||||
Repository(
|
||||
|
||||
+101
-26
@@ -98,7 +98,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
initial_env_vars: dict[str, str]
|
||||
attach_to_existing: bool
|
||||
status_callback: Callable | None
|
||||
git_dir: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -113,11 +112,9 @@ class Runtime(FileEditRuntimeMixin):
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
# GitHandler will be initialized with an async function
|
||||
self.git_handler = GitHandler(
|
||||
execute_shell_fn=self._execute_shell_fn_git_handler
|
||||
)
|
||||
self.git_dir = None
|
||||
self.sid = sid
|
||||
self.event_stream = event_stream
|
||||
self.event_stream.subscribe(
|
||||
@@ -319,9 +316,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
selected_branch: str | None,
|
||||
repository_provider: ProviderType = ProviderType.GITHUB,
|
||||
) -> str:
|
||||
# Set the git_dir to the workspace mount path by default
|
||||
self.git_dir = self.config.workspace_mount_path_in_sandbox
|
||||
|
||||
if not selected_repository:
|
||||
# In SaaS mode (indicated by user_id being set), always run git init
|
||||
# In OSS mode, only run git init if workspace_base is not set
|
||||
@@ -333,7 +327,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
command='git init',
|
||||
)
|
||||
self.run_action(action)
|
||||
# git_dir is already set to workspace mount path
|
||||
else:
|
||||
logger.info(
|
||||
'In workspace mount mode, not initializing a new git repository.'
|
||||
@@ -402,13 +395,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
)
|
||||
self.log('info', f'Cloning repo: {selected_repository}')
|
||||
self.run_action(action)
|
||||
|
||||
# Update git_dir to point to the cloned repository directory
|
||||
self.git_dir = os.path.join(
|
||||
self.config.workspace_mount_path_in_sandbox, dir_name
|
||||
)
|
||||
self.git_handler.set_cwd(self.git_dir)
|
||||
|
||||
return dir_name
|
||||
|
||||
def maybe_run_setup_script(self):
|
||||
@@ -626,15 +612,13 @@ class Runtime(FileEditRuntimeMixin):
|
||||
# Git
|
||||
# ====================================================================
|
||||
|
||||
async def _execute_shell_fn_git_handler(
|
||||
def _execute_shell_fn_git_handler(
|
||||
self, command: str, cwd: str | None
|
||||
) -> CommandResult:
|
||||
"""
|
||||
This function is used by the GitHandler to execute shell commands.
|
||||
"""
|
||||
obs = await call_sync_from_async(
|
||||
self.run, CmdRunAction(command=command, is_static=True, cwd=cwd)
|
||||
)
|
||||
obs = self.run(CmdRunAction(command=command, is_static=True, cwd=cwd))
|
||||
exit_code = 0
|
||||
content = ''
|
||||
|
||||
@@ -645,15 +629,106 @@ class Runtime(FileEditRuntimeMixin):
|
||||
|
||||
return CommandResult(content=content, exit_code=exit_code)
|
||||
|
||||
async def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
if self.git_dir:
|
||||
self.git_handler.set_cwd(self.git_dir)
|
||||
return await call_sync_from_async(self.git_handler.get_git_changes)
|
||||
def get_git_changes(self, cwd: str) -> list[dict[str, str]] | None:
|
||||
self.git_handler.set_cwd(cwd)
|
||||
return self.git_handler.get_git_changes()
|
||||
|
||||
async def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
if self.git_dir:
|
||||
self.git_handler.set_cwd(self.git_dir)
|
||||
return await call_sync_from_async(self.git_handler.get_git_diff, file_path)
|
||||
def get_git_diff(self, file_path: str, cwd: str) -> dict[str, str]:
|
||||
self.git_handler.set_cwd(cwd)
|
||||
return self.git_handler.get_git_diff(file_path)
|
||||
|
||||
def get_git_branch(self, cwd: str) -> str | None:
|
||||
"""
|
||||
Get the current git branch for the repository.
|
||||
|
||||
Args:
|
||||
cwd (str): The current working directory.
|
||||
|
||||
Returns:
|
||||
str | None: The current branch name or None if not a git repository.
|
||||
"""
|
||||
self.git_handler.set_cwd(cwd)
|
||||
|
||||
# Check if it's a git repository
|
||||
if not self.git_handler._is_git_repo():
|
||||
return None
|
||||
|
||||
return self.git_handler._get_current_branch()
|
||||
|
||||
def get_git_repository(self, cwd: str) -> str | None:
|
||||
"""
|
||||
Get the remote repository URL for the git repository.
|
||||
|
||||
Args:
|
||||
cwd (str): The current working directory.
|
||||
|
||||
Returns:
|
||||
str | None: The repository name in the format "owner/repo" or None if not found.
|
||||
"""
|
||||
self.git_handler.set_cwd(cwd)
|
||||
|
||||
# Check if it's a git repository
|
||||
if not self.git_handler._is_git_repo():
|
||||
return None
|
||||
|
||||
# Get the remote URL
|
||||
cmd = 'git remote -v'
|
||||
output = self.git_handler.execute(cmd, self.git_handler.cwd)
|
||||
|
||||
if output.exit_code != 0 or not output.content.strip():
|
||||
return None
|
||||
|
||||
# Parse the remote URL to extract the repository
|
||||
import re
|
||||
match = re.search(r'(github\.com|gitlab\.com)[:/]([^/\s]+/[^/\s]+)(?:\.git|\s)', output.content)
|
||||
if match:
|
||||
return match.group(2)
|
||||
|
||||
return None
|
||||
|
||||
def get_git_commit_state(self, cwd: str) -> str | None:
|
||||
"""
|
||||
Get the commit state for the git repository.
|
||||
|
||||
Args:
|
||||
cwd (str): The current working directory.
|
||||
|
||||
Returns:
|
||||
str | None: "CLEAN" if no changes, "IN_PROGRESS" if there are uncommitted changes
|
||||
or unpushed commits, None if not a git repository.
|
||||
"""
|
||||
from openhands.storage.data_models.conversation_metadata import CommitState
|
||||
|
||||
self.git_handler.set_cwd(cwd)
|
||||
|
||||
# Check if it's a git repository
|
||||
if not self.git_handler._is_git_repo():
|
||||
return None
|
||||
|
||||
# Check for uncommitted changes
|
||||
cmd = 'git status --porcelain'
|
||||
output = self.git_handler.execute(cmd, self.git_handler.cwd)
|
||||
|
||||
if output.exit_code != 0:
|
||||
return None
|
||||
|
||||
# If there are uncommitted changes, return IN_PROGRESS
|
||||
if output.content.strip():
|
||||
return CommitState.IN_PROGRESS
|
||||
|
||||
# Get current branch
|
||||
current_branch = self.git_handler._get_current_branch()
|
||||
|
||||
# Check if there are commits not pushed to origin
|
||||
cmd = f'git rev-list HEAD...origin/{current_branch} --count 2>/dev/null || echo "0"'
|
||||
output = self.git_handler.execute(cmd, self.git_handler.cwd)
|
||||
|
||||
# If there are unpushed commits, return IN_PROGRESS
|
||||
if output.content.strip() != '0':
|
||||
return CommitState.IN_PROGRESS
|
||||
|
||||
# If we got here, everything is clean
|
||||
return CommitState.CLEAN
|
||||
|
||||
@property
|
||||
def additional_agent_instructions(self) -> str:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
from typing import Callable
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -23,7 +23,7 @@ class GitHandler:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
execute_shell_fn: Callable[[str, str | None], Awaitable[CommandResult]],
|
||||
execute_shell_fn: Callable[[str, str | None], CommandResult],
|
||||
):
|
||||
self.execute = execute_shell_fn
|
||||
self.cwd: str | None = None
|
||||
@@ -37,11 +37,7 @@ class GitHandler:
|
||||
"""
|
||||
self.cwd = cwd
|
||||
|
||||
async def _execute_async(self, cmd: str, cwd: str | None) -> CommandResult:
|
||||
"""Execute the command asynchronously."""
|
||||
return await self.execute(cmd, cwd)
|
||||
|
||||
async def _is_git_repo(self) -> bool:
|
||||
def _is_git_repo(self) -> bool:
|
||||
"""
|
||||
Checks if the current directory is a Git repository.
|
||||
|
||||
@@ -49,10 +45,10 @@ class GitHandler:
|
||||
bool: True if inside a Git repository, otherwise False.
|
||||
"""
|
||||
cmd = 'git rev-parse --is-inside-work-tree'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.strip() == 'true'
|
||||
|
||||
async def _get_current_file_content(self, file_path: str) -> str:
|
||||
def _get_current_file_content(self, file_path: str) -> str:
|
||||
"""
|
||||
Retrieves the current content of a given file.
|
||||
|
||||
@@ -62,10 +58,10 @@ class GitHandler:
|
||||
Returns:
|
||||
str: The file content.
|
||||
"""
|
||||
output = await self._execute_async(f'cat {file_path}', self.cwd)
|
||||
output = self.execute(f'cat {file_path}', self.cwd)
|
||||
return output.content
|
||||
|
||||
async def _verify_ref_exists(self, ref: str) -> bool:
|
||||
def _verify_ref_exists(self, ref: str) -> bool:
|
||||
"""
|
||||
Verifies whether a specific Git reference exists.
|
||||
|
||||
@@ -76,18 +72,18 @@ class GitHandler:
|
||||
bool: True if the reference exists, otherwise False.
|
||||
"""
|
||||
cmd = f'git rev-parse --verify {ref}'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.exit_code == 0
|
||||
|
||||
async def _get_valid_ref(self) -> str | None:
|
||||
def _get_valid_ref(self) -> str | None:
|
||||
"""
|
||||
Determines a valid Git reference for comparison.
|
||||
|
||||
Returns:
|
||||
str | None: A valid Git reference or None if no valid reference is found.
|
||||
"""
|
||||
current_branch = await self._get_current_branch()
|
||||
default_branch = await self._get_default_branch()
|
||||
current_branch = self._get_current_branch()
|
||||
default_branch = self._get_default_branch()
|
||||
|
||||
ref_current_branch = f'origin/{current_branch}'
|
||||
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
@@ -101,12 +97,12 @@ class GitHandler:
|
||||
ref_new_repo,
|
||||
]
|
||||
for ref in refs:
|
||||
if await self._verify_ref_exists(ref):
|
||||
if self._verify_ref_exists(ref):
|
||||
return ref
|
||||
|
||||
return None
|
||||
|
||||
async def _get_ref_content(self, file_path: str) -> str:
|
||||
def _get_ref_content(self, file_path: str) -> str:
|
||||
"""
|
||||
Retrieves the content of a file from a valid Git reference.
|
||||
|
||||
@@ -116,15 +112,15 @@ class GitHandler:
|
||||
Returns:
|
||||
str: The content of the file from the reference, or an empty string if unavailable.
|
||||
"""
|
||||
ref = await self._get_valid_ref()
|
||||
ref = self._get_valid_ref()
|
||||
if not ref:
|
||||
return ''
|
||||
|
||||
cmd = f'git show {ref}:{file_path}'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content if output.exit_code == 0 else ''
|
||||
|
||||
async def _get_default_branch(self) -> str:
|
||||
def _get_default_branch(self) -> str:
|
||||
"""
|
||||
Retrieves the primary Git branch name of the repository.
|
||||
|
||||
@@ -132,10 +128,10 @@ class GitHandler:
|
||||
str: The name of the primary branch.
|
||||
"""
|
||||
cmd = 'git remote show origin | grep "HEAD branch"'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.split()[-1].strip()
|
||||
|
||||
async def _get_current_branch(self) -> str:
|
||||
def _get_current_branch(self) -> str:
|
||||
"""
|
||||
Retrieves the currently selected Git branch.
|
||||
|
||||
@@ -143,25 +139,25 @@ class GitHandler:
|
||||
str: The name of the current branch.
|
||||
"""
|
||||
cmd = 'git rev-parse --abbrev-ref HEAD'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.strip()
|
||||
|
||||
async def _get_changed_files(self) -> list[str]:
|
||||
def _get_changed_files(self) -> list[str]:
|
||||
"""
|
||||
Retrieves a list of changed files compared to a valid Git reference.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of changed file paths.
|
||||
"""
|
||||
ref = await self._get_valid_ref()
|
||||
ref = self._get_valid_ref()
|
||||
if not ref:
|
||||
return []
|
||||
|
||||
diff_cmd = f'git diff --name-status {ref}'
|
||||
output = await self._execute_async(diff_cmd, self.cwd)
|
||||
output = self.execute(diff_cmd, self.cwd)
|
||||
return output.content.splitlines()
|
||||
|
||||
async def _get_untracked_files(self) -> list[dict[str, str]]:
|
||||
def _get_untracked_files(self) -> list[dict[str, str]]:
|
||||
"""
|
||||
Retrieves a list of untracked files in the repository. This is useful for detecting new files.
|
||||
|
||||
@@ -169,7 +165,7 @@ class GitHandler:
|
||||
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
|
||||
"""
|
||||
cmd = 'git ls-files --others --exclude-standard'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
obs_list = output.content.splitlines()
|
||||
return (
|
||||
[{'status': 'A', 'path': path} for path in obs_list]
|
||||
@@ -177,24 +173,24 @@ class GitHandler:
|
||||
else []
|
||||
)
|
||||
|
||||
async def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
"""
|
||||
Retrieves the list of changed files in the Git repository.
|
||||
|
||||
Returns:
|
||||
list[dict[str, str]] | None: A list of dictionaries containing file paths and statuses. None if not a git repository.
|
||||
"""
|
||||
if not await self._is_git_repo():
|
||||
if not self._is_git_repo():
|
||||
return None
|
||||
|
||||
changes_list = await self._get_changed_files()
|
||||
changes_list = self._get_changed_files()
|
||||
result = parse_git_changes(changes_list)
|
||||
|
||||
# join with any untracked files
|
||||
result += await self._get_untracked_files()
|
||||
result += self._get_untracked_files()
|
||||
return result
|
||||
|
||||
async def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
"""
|
||||
Retrieves the original and modified content of a file in the repository.
|
||||
|
||||
@@ -204,8 +200,8 @@ class GitHandler:
|
||||
Returns:
|
||||
dict[str, str]: A dictionary containing the original and modified content.
|
||||
"""
|
||||
modified = await self._get_current_file_content(file_path)
|
||||
original = await self._get_ref_content(file_path)
|
||||
modified = self._get_current_file_content(file_path)
|
||||
original = self._get_ref_content(file_path)
|
||||
|
||||
return {
|
||||
'modified': modified,
|
||||
|
||||
@@ -12,6 +12,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.events.observation import CmdOutputObservation
|
||||
from openhands.events.stream import EventStreamSubscriber, session_exists
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
@@ -463,6 +464,34 @@ class StandaloneConversationManager(ConversationManager):
|
||||
token_usage.prompt_tokens + token_usage.completion_tokens
|
||||
)
|
||||
|
||||
if isinstance(event, CmdOutputObservation) and 'git ' in event.command:
|
||||
print('GIT OBSERVATION')
|
||||
convo = await self.attach_to_conversation(conversation_id, user_id)
|
||||
if convo:
|
||||
runtime = convo.runtime
|
||||
cwd = '' # FIXME, use runtime.git_dir once it's implemented
|
||||
|
||||
branch = runtime.get_git_branch(cwd)
|
||||
if branch and branch != conversation.selected_branch:
|
||||
conversation.selected_branch = branch
|
||||
logger.info(
|
||||
f'Updated branch for conversation {conversation_id}: {branch}'
|
||||
)
|
||||
|
||||
repository = runtime.get_git_repository(cwd)
|
||||
if repository and repository != conversation.selected_repository:
|
||||
conversation.selected_repository = repository
|
||||
logger.info(
|
||||
f'Updated repository for conversation {conversation_id}: {repository}'
|
||||
)
|
||||
|
||||
commit_state = runtime.get_git_commit_state(cwd)
|
||||
if commit_state is not None:
|
||||
# conversation.commit_state = str(commit_state)
|
||||
logger.info(
|
||||
f'Updated commit state for conversation {conversation_id}: {commit_state}'
|
||||
)
|
||||
|
||||
await conversation_store.save_metadata(conversation)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
from openhands.storage.data_models.conversation_metadata import CommitState, ConversationTrigger
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
|
||||
|
||||
@@ -17,5 +17,7 @@ class ConversationInfo:
|
||||
last_updated_at: datetime | None = None
|
||||
status: ConversationStatus = ConversationStatus.STOPPED
|
||||
selected_repository: str | None = None
|
||||
selected_branch: str | None = None
|
||||
commit_state: CommitState | None = None
|
||||
trigger: ConversationTrigger | None = None
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
@@ -10,24 +14,41 @@ from fastapi import (
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pathspec import PathSpec
|
||||
from pathspec.patterns import GitWildMatchPattern
|
||||
from pydantic import BaseModel
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from openhands.core.exceptions import AgentRuntimeUnavailableError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
CmdRunAction,
|
||||
FileReadAction,
|
||||
)
|
||||
from openhands.events.observation import (
|
||||
CmdOutputObservation,
|
||||
ErrorObservation,
|
||||
FileReadObservation,
|
||||
)
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.file_config import (
|
||||
FILES_TO_IGNORE,
|
||||
)
|
||||
from openhands.server.shared import (
|
||||
ConversationStoreImpl,
|
||||
config,
|
||||
conversation_manager,
|
||||
)
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.utils import get_conversation_store
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
# Import CommitState from conversation_metadata
|
||||
from openhands.storage.data_models.conversation_metadata import CommitState
|
||||
|
||||
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
||||
|
||||
|
||||
@@ -185,10 +206,20 @@ async def git_changes(
|
||||
user_id: str = Depends(get_user_id),
|
||||
):
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
logger.info(f'Getting git changes in {runtime.git_dir}')
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config,
|
||||
user_id,
|
||||
)
|
||||
|
||||
cwd = await get_cwd(
|
||||
conversation_store,
|
||||
conversation_id,
|
||||
runtime.config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
logger.info(f'Getting git changes in {cwd}')
|
||||
|
||||
try:
|
||||
changes = await call_sync_from_async(runtime.get_git_changes)
|
||||
changes = await call_sync_from_async(runtime.get_git_changes, cwd)
|
||||
if changes is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
@@ -214,12 +245,18 @@ async def git_diff(
|
||||
request: Request,
|
||||
path: str,
|
||||
conversation_id: str,
|
||||
conversation_store = Depends(get_conversation_store),
|
||||
):
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
logger.info(f'Getting git diff for {path} in {runtime.git_dir}')
|
||||
|
||||
cwd = await get_cwd(
|
||||
conversation_store,
|
||||
conversation_id,
|
||||
runtime.config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
|
||||
try:
|
||||
diff = await call_sync_from_async(runtime.get_git_diff, path)
|
||||
diff = await call_sync_from_async(runtime.get_git_diff, path, cwd)
|
||||
return diff
|
||||
except AgentRuntimeUnavailableError as e:
|
||||
logger.error(f'Error getting diff: {e}')
|
||||
@@ -227,3 +264,85 @@ async def git_diff(
|
||||
status_code=500,
|
||||
content={'error': f'Error getting diff: {e}'},
|
||||
)
|
||||
|
||||
|
||||
@app.get('/git/info')
|
||||
async def get_git_info(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
):
|
||||
"""Get git information for the conversation's repository.
|
||||
|
||||
This endpoint returns the current branch, repository, and commit state
|
||||
from the conversation metadata.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing branch, repository, and commit state information.
|
||||
"""
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config,
|
||||
user_id,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get the conversation metadata
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
# Return the git information
|
||||
return {
|
||||
'branch': metadata.selected_branch,
|
||||
'repository': metadata.selected_repository,
|
||||
'commit_state': metadata.commit_state,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting git info: {e}')
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={'error': str(e)},
|
||||
)
|
||||
|
||||
|
||||
async def get_cwd(
|
||||
conversation_store: ConversationStore,
|
||||
conversation_id: str,
|
||||
workspace_mount_path_in_sandbox: str,
|
||||
):
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
is_running = await conversation_manager.is_agent_loop_running(conversation_id)
|
||||
conversation_info = await _get_conversation_info(metadata, is_running)
|
||||
|
||||
cwd = workspace_mount_path_in_sandbox
|
||||
if conversation_info and conversation_info.selected_repository:
|
||||
repo_dir = conversation_info.selected_repository.split('/')[-1]
|
||||
cwd = os.path.join(cwd, repo_dir)
|
||||
|
||||
return cwd
|
||||
|
||||
|
||||
async def _get_conversation_info(
|
||||
conversation: ConversationMetadata,
|
||||
is_running: bool,
|
||||
) -> ConversationInfo | None:
|
||||
try:
|
||||
title = conversation.title
|
||||
if not title:
|
||||
title = f'Conversation {conversation.conversation_id[:5]}'
|
||||
return ConversationInfo(
|
||||
conversation_id=conversation.conversation_id,
|
||||
title=title,
|
||||
last_updated_at=conversation.last_updated_at,
|
||||
created_at=conversation.created_at,
|
||||
selected_repository=conversation.selected_repository,
|
||||
selected_branch=conversation.selected_branch,
|
||||
commit_state=conversation.commit_state,
|
||||
status=ConversationStatus.RUNNING
|
||||
if is_running
|
||||
else ConversationStatus.STOPPED,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error loading conversation {conversation.conversation_id}: {str(e)}',
|
||||
extra={'session_id': conversation.conversation_id},
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -8,6 +8,12 @@ class ConversationTrigger(Enum):
|
||||
GUI = 'gui'
|
||||
|
||||
|
||||
class CommitState(str, Enum):
|
||||
"""Enum representing the state of git commits in a repository."""
|
||||
CLEAN = "CLEAN" # No changes, current commit matches origin commit for the same branch
|
||||
IN_PROGRESS = "IN_PROGRESS" # There are uncommitted changes or local commits not in origin
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationMetadata:
|
||||
conversation_id: str
|
||||
@@ -19,6 +25,8 @@ class ConversationMetadata:
|
||||
last_updated_at: datetime | None = None
|
||||
trigger: ConversationTrigger | None = None
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
# Git state
|
||||
commit_state: CommitState | None = None
|
||||
# Cost and token metrics
|
||||
accumulated_cost: float = 0.0
|
||||
prompt_tokens: int = 0
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
|
||||
|
||||
# Mark all test methods as asyncio tests
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestGitHandler(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -110,9 +104,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
# Push the feature branch to origin
|
||||
self._execute_command('git push -u origin feature-branch', self.local_dir)
|
||||
|
||||
async def test_is_git_repo(self):
|
||||
def test_is_git_repo(self):
|
||||
"""Test that _is_git_repo returns True for a git repository."""
|
||||
self.assertTrue(await self.git_handler._is_git_repo())
|
||||
self.assertTrue(self.git_handler._is_git_repo())
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
@@ -122,9 +116,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_default_branch(self):
|
||||
def test_get_default_branch(self):
|
||||
"""Test that _get_default_branch returns the correct branch name."""
|
||||
branch = await self.git_handler._get_default_branch()
|
||||
branch = self.git_handler._get_default_branch()
|
||||
self.assertEqual(branch, 'main')
|
||||
|
||||
# Verify the command was executed
|
||||
@@ -135,9 +129,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_current_branch(self):
|
||||
def test_get_current_branch(self):
|
||||
"""Test that _get_current_branch returns the correct branch name."""
|
||||
branch = await self.git_handler._get_current_branch()
|
||||
branch = self.git_handler._get_current_branch()
|
||||
self.assertEqual(branch, 'feature-branch')
|
||||
|
||||
# Verify the command was executed
|
||||
@@ -148,10 +142,10 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_valid_ref_with_origin_current_branch(self):
|
||||
def test_get_valid_ref_with_origin_current_branch(self):
|
||||
"""Test that _get_valid_ref returns the current branch in origin when it exists."""
|
||||
# This test uses the setup from setUp where the current branch exists in origin
|
||||
ref = await self.git_handler._get_valid_ref()
|
||||
ref = self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Check that the refs were checked in the correct order
|
||||
@@ -171,7 +165,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
async def test_get_valid_ref_without_origin_current_branch(self):
|
||||
def test_get_valid_ref_without_origin_current_branch(self):
|
||||
"""Test that _get_valid_ref falls back to default branch when current branch doesn't exist in origin."""
|
||||
# Create a new branch that doesn't exist in origin
|
||||
self._execute_command('git checkout -b new-local-branch', self.local_dir)
|
||||
@@ -179,7 +173,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
# Clear the executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
|
||||
ref = await self.git_handler._get_valid_ref()
|
||||
ref = self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Check that the refs were checked in the correct order
|
||||
@@ -202,7 +196,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
async def test_get_valid_ref_without_origin(self):
|
||||
def test_get_valid_ref_without_origin(self):
|
||||
"""Test that _get_valid_ref falls back to empty tree ref when there's no origin."""
|
||||
# Create a new directory with a git repo but no origin
|
||||
no_origin_dir = os.path.join(self.test_dir, 'no-origin')
|
||||
@@ -213,20 +207,18 @@ class TestGitHandler(unittest.TestCase):
|
||||
self._execute_command("git config user.email 'test@example.com'", no_origin_dir)
|
||||
self._execute_command("git config user.name 'Test User'", no_origin_dir)
|
||||
|
||||
# Create a file and commit it using subprocess
|
||||
file_path = os.path.join(no_origin_dir, 'file1.txt')
|
||||
self._execute_command(
|
||||
f'echo "Content in repo without origin" > {file_path}', no_origin_dir
|
||||
)
|
||||
# Create a file and commit it
|
||||
with open(os.path.join(no_origin_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Content in repo without origin')
|
||||
self._execute_command('git add file1.txt', no_origin_dir)
|
||||
self._execute_command("git commit -m 'Initial commit'", no_origin_dir)
|
||||
|
||||
# Create a custom GitHandler with a modified _get_default_branch method for this test
|
||||
class TestGitHandler(GitHandler):
|
||||
async def _get_default_branch(self) -> str:
|
||||
def _get_default_branch(self) -> str:
|
||||
# Override to handle repos without origin
|
||||
try:
|
||||
return await super()._get_default_branch()
|
||||
return super()._get_default_branch()
|
||||
except IndexError:
|
||||
return 'main' # Default fallback
|
||||
|
||||
@@ -237,7 +229,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
# Clear the executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
|
||||
ref = await no_origin_handler._get_valid_ref()
|
||||
ref = no_origin_handler._get_valid_ref()
|
||||
|
||||
# Verify that git commands were executed
|
||||
self.assertTrue(
|
||||
@@ -259,9 +251,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
async def test_get_ref_content(self):
|
||||
def test_get_ref_content(self):
|
||||
"""Test that _get_ref_content returns the content from a valid ref."""
|
||||
content = await self.git_handler._get_ref_content('file1.txt')
|
||||
content = self.git_handler._get_ref_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Modified content')
|
||||
|
||||
# Should have called _get_valid_ref and then git show
|
||||
@@ -270,9 +262,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
]
|
||||
self.assertTrue(any('file1.txt' in cmd for cmd in show_commands))
|
||||
|
||||
async def test_get_current_file_content(self):
|
||||
def test_get_current_file_content(self):
|
||||
"""Test that _get_current_file_content returns the current content of a file."""
|
||||
content = await self.git_handler._get_current_file_content('file1.txt')
|
||||
content = self.git_handler._get_current_file_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Modified content again')
|
||||
|
||||
# Verify the command was executed
|
||||
@@ -280,15 +272,14 @@ class TestGitHandler(unittest.TestCase):
|
||||
any(cmd == 'cat file1.txt' for cmd, _ in self.executed_commands)
|
||||
)
|
||||
|
||||
async def test_get_changed_files(self):
|
||||
def test_get_changed_files(self):
|
||||
"""Test that _get_changed_files returns the list of changed files."""
|
||||
# Let's create a new file to ensure it shows up in the diff
|
||||
# Use subprocess directly to create and add the file
|
||||
file_path = os.path.join(self.local_dir, 'new_file.txt')
|
||||
self._execute_command(f'echo "New file content" > {file_path}', self.local_dir)
|
||||
with open(os.path.join(self.local_dir, 'new_file.txt'), 'w') as f:
|
||||
f.write('New file content')
|
||||
self._execute_command('git add new_file.txt', self.local_dir)
|
||||
|
||||
files = await self.git_handler._get_changed_files()
|
||||
files = self.git_handler._get_changed_files()
|
||||
self.assertTrue(files)
|
||||
|
||||
# Should include file1.txt (modified) and file3.txt (deleted)
|
||||
@@ -304,15 +295,13 @@ class TestGitHandler(unittest.TestCase):
|
||||
]
|
||||
self.assertTrue(diff_commands)
|
||||
|
||||
async def test_get_untracked_files(self):
|
||||
def test_get_untracked_files(self):
|
||||
"""Test that _get_untracked_files returns the list of untracked files."""
|
||||
# Create an untracked file using subprocess
|
||||
file_path = os.path.join(self.local_dir, 'untracked.txt')
|
||||
self._execute_command(
|
||||
f'echo "Untracked file content" > {file_path}', self.local_dir
|
||||
)
|
||||
# Create an untracked file
|
||||
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
|
||||
f.write('Untracked file content')
|
||||
|
||||
files = await self.git_handler._get_untracked_files()
|
||||
files = self.git_handler._get_untracked_files()
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertEqual(files[0]['path'], 'untracked.txt')
|
||||
self.assertEqual(files[0]['status'], 'A')
|
||||
@@ -325,22 +314,18 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_git_changes(self):
|
||||
def test_get_git_changes(self):
|
||||
"""Test that get_git_changes returns the combined list of changed and untracked files."""
|
||||
# Create an untracked file using subprocess
|
||||
file_path = os.path.join(self.local_dir, 'untracked.txt')
|
||||
self._execute_command(
|
||||
f'echo "Untracked file content" > {file_path}', self.local_dir
|
||||
)
|
||||
# Create an untracked file
|
||||
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
|
||||
f.write('Untracked file content')
|
||||
|
||||
# Create a new file and stage it
|
||||
file_path2 = os.path.join(self.local_dir, 'new_file2.txt')
|
||||
self._execute_command(
|
||||
f'echo "New file 2 content" > {file_path2}', self.local_dir
|
||||
)
|
||||
with open(os.path.join(self.local_dir, 'new_file2.txt'), 'w') as f:
|
||||
f.write('New file 2 content')
|
||||
self._execute_command('git add new_file2.txt', self.local_dir)
|
||||
|
||||
changes = await self.git_handler.get_git_changes()
|
||||
changes = self.git_handler.get_git_changes()
|
||||
self.assertIsNotNone(changes)
|
||||
|
||||
# Should include file1.txt (modified), file3.txt (deleted), new_file2.txt (added), and untracked.txt (untracked)
|
||||
@@ -356,9 +341,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
self.assertIn('A', statuses) # Added
|
||||
self.assertIn('D', statuses) # Deleted
|
||||
|
||||
async def test_get_git_diff(self):
|
||||
def test_get_git_diff(self):
|
||||
"""Test that get_git_diff returns the original and modified content of a file."""
|
||||
diff = await self.git_handler.get_git_diff('file1.txt')
|
||||
diff = self.git_handler.get_git_diff('file1.txt')
|
||||
self.assertEqual(diff['modified'].strip(), 'Modified content again')
|
||||
self.assertEqual(diff['original'].strip(), 'Modified content')
|
||||
|
||||
@@ -375,6 +360,4 @@ class TestGitHandler(unittest.TestCase):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import asyncio
|
||||
|
||||
asyncio.run(unittest.main())
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user