Compare commits

..

2 Commits

Author SHA1 Message Date
openhands
5444193d9b fix(frontend): Make terminal read-only 2025-07-18 19:16:52 +00:00
openhands
16a508c15d fix(frontend): Ensure terminal is always interactive when visible 2025-07-18 19:11:44 +00:00
39 changed files with 327 additions and 913 deletions

View File

@@ -51,7 +51,8 @@ Giving GitHub repository access to OpenHands also allows you to work on GitHub i
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
On your repository, label an issue with `openhands` or add a message starting with
`@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it.
- You can click on the link to track the progress on OpenHands Cloud.
2. Open a pull request if it determines that the issue has been successfully resolved.

View File

@@ -1,7 +1,7 @@
---
title: GitLab Integration
description: This guide walks you through the process of installing OpenHands Cloud for your GitLab repositories. Once
set up, it will allow OpenHands to work with your GitLab repository through the Cloud UI or straight from GitLab!.
set up, it will allow OpenHands to work with your GitLab repository.
---
## Prerequisites
@@ -25,33 +25,6 @@ OpenHands requests an API-scoped token during OAuth authentication. By default,
To restrict the agent's permissions, you can define a custom secret `GITLAB_TOKEN`, which will override the default token assigned to the agent.
While the high-permission API token is still requested and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
## Working on GitLab Issues and Merge Requests Using Openhands
<Note>
This feature works for personal projects and is available for group projects with a
[Premium or Ultimate tier subscription](https://docs.gitlab.com/user/project/integrations/webhooks/#group-webhooks).
A webhook is automatically installed within a few minutes after the owner/maintainer of the project or group logs into
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but we are planning to improve this in a future release.
</Note>
Giving GitLab repository access to OpenHands also allows you to work on GitLab issues and merge requests directly.
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it.
- You can click on the link to track the progress on OpenHands Cloud.
2. Open a merge request if it determines that the issue has been successfully resolved.
3. Comment on the issue with a summary of the performed tasks and a link to the PR.
### Working with Merge Requests
To get OpenHands to work on merge requests, mention `@openhands` in the comments to:
- Ask questions
- Request updates
- Get code explanations
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).

View File

@@ -129,159 +129,4 @@ describe("ActionSuggestions", () => {
expect(createPRPrompt).toContain("meaningful branch name");
expect(createPRPrompt).not.toContain("SAME branch name");
});
it("should use correct provider name based on conversation git_provider, not user authenticated providers", async () => {
// Test case for GitHub repository
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-github",
title: "GitHub Test",
selected_repository: "test-repo",
git_provider: "github",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
// Mock user having both GitHub and Bitbucket tokens
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "github-token",
bitbucket: "bitbucket-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
expect(prButton).toBeInTheDocument();
if (prButton) {
prButton.click();
}
// The suggestion should mention GitHub, not Bitbucket
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("GitHub")
);
expect(onSuggestionsClick).not.toHaveBeenCalledWith(
expect.stringContaining("Bitbucket")
);
});
it("should use GitLab terminology when git_provider is gitlab", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-gitlab",
title: "GitLab Test",
selected_repository: "test-repo",
git_provider: "gitlab",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
gitlab: "gitlab-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
if (prButton) {
prButton.click();
}
// Should mention GitLab and "merge request" instead of "pull request"
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("GitLab")
);
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("merge request")
);
});
it("should use Bitbucket terminology when git_provider is bitbucket", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-bitbucket",
title: "Bitbucket Test",
selected_repository: "test-repo",
git_provider: "bitbucket",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
bitbucket: "bitbucket-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
if (prButton) {
prButton.click();
}
// Should mention Bitbucket
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("Bitbucket")
);
});
});

View File

@@ -489,24 +489,6 @@ class OpenHands {
return data;
}
/**
* Get the GitHub user installation IDs
* @returns List of GitHub installation IDs
*/
static async getGitHubUserInstallationIds(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/github/installations");
return data;
}
/**
* Get the BitBucket workspaces
* @returns List of BitBucket workspaces
*/
static async getBitBucketWorkspaces(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/bitbucket/installations");
return data;
}
}
export default OpenHands;

View File

@@ -19,11 +19,8 @@ export function ActionSuggestions({
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const providersAreSet = providers.length > 0;
// Use the git_provider from the conversation, not the user's authenticated providers
const currentGitProvider = conversation?.git_provider;
const isGitLab = currentGitProvider === "gitlab";
const isBitbucket = currentGitProvider === "bitbucket";
const isGitLab = providers.includes("gitlab");
const isBitbucket = providers.includes("bitbucket");
const pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";

View File

@@ -10,9 +10,6 @@ import { BrandButton } from "../settings/brand-button";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useDebounce } from "#/hooks/use-debounce";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { useUserProviders } from "#/hooks/use-user-providers";
import { Provider } from "#/types/settings";
import { SettingsDropdownInput } from "../settings/settings-dropdown-input";
import {
RepositoryDropdown,
RepositoryLoadingState,
@@ -35,10 +32,8 @@ export function RepositorySelectionForm({
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
null,
);
const [selectedProvider, setSelectedProvider] = React.useState<Provider | null>(null);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = React.useRef<boolean>(false);
const { providers } = useUserProviders();
const {
data: repositories,
isLoading: isLoadingRepositories,
@@ -61,13 +56,6 @@ export function RepositorySelectionForm({
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
// Auto-select provider if there's only one
React.useEffect(() => {
if (providers.length === 1 && !selectedProvider) {
setSelectedProvider(providers[0]);
}
}, [providers, selectedProvider]);
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
React.useEffect(() => {
if (
@@ -95,10 +83,8 @@ export function RepositorySelectionForm({
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
// Use all repositories without filtering by provider for now
const allRepositories = repositories?.concat(searchedRepos || []);
const repositoriesItems = (allRepositories || []).map((repo) => ({
const repositoriesItems = allRepositories?.map((repo) => ({
key: repo.id,
label: decodeURIComponent(repo.full_name),
}));
@@ -108,14 +94,6 @@ export function RepositorySelectionForm({
label: branch.name,
}));
// Create provider dropdown items
const providerItems = React.useMemo(() => {
return providers.map(provider => ({
key: provider,
label: provider.charAt(0).toUpperCase() + provider.slice(1), // Capitalize first letter
}));
}, [providers]);
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
if (selectedRepo) onRepoSelection(selectedRepo);
@@ -124,14 +102,6 @@ export function RepositorySelectionForm({
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
};
const handleProviderSelection = (key: React.Key | null) => {
const provider = key as Provider | null;
setSelectedProvider(provider);
setSelectedRepository(null); // Reset repository selection when provider changes
setSelectedBranch(null); // Reset branch selection when provider changes
onRepoSelection(null); // Reset parent component's selected repo
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
@@ -163,26 +133,6 @@ export function RepositorySelectionForm({
}
};
// Render the provider dropdown
const renderProviderSelector = () => {
// Only render if there are multiple providers
if (providers.length <= 1) {
return null;
}
return (
<SettingsDropdownInput
testId="provider-dropdown"
name="provider-dropdown"
placeholder="Select Provider"
items={providerItems}
wrapperClassName="max-w-[500px]"
onSelectionChange={handleProviderSelection}
selectedKey={selectedProvider || undefined}
/>
);
};
// Render the appropriate UI based on the loading/error state
const renderRepositorySelector = () => {
if (isLoadingRepositories) {
@@ -193,15 +143,11 @@ export function RepositorySelectionForm({
return <RepositoryErrorState />;
}
// For now, don't disable the repo dropdown based on provider selection
const isDisabled = false;
return (
<RepositoryDropdown
items={repositoriesItems || []}
onSelectionChange={handleRepoSelection}
onInputChange={handleRepoInputChange}
isDisabled={isDisabled}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
@@ -249,8 +195,8 @@ export function RepositorySelectionForm({
return (
<div className="flex flex-col gap-4">
{renderProviderSelector()}
{renderRepositorySelector()}
{renderBranchSelector()}
<BrandButton

View File

@@ -8,7 +8,6 @@ export interface RepositoryDropdownProps {
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
defaultFilter?: (textValue: string, inputValue: string) => boolean;
isDisabled?: boolean;
}
export function RepositoryDropdown({
@@ -16,7 +15,6 @@ export function RepositoryDropdown({
onSelectionChange,
onInputChange,
defaultFilter,
isDisabled = false,
}: RepositoryDropdownProps) {
const { t } = useTranslation();
@@ -24,13 +22,12 @@ export function RepositoryDropdown({
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder={isDisabled ? t("Please select a provider first") : t(I18nKey.REPOSITORY$SELECT_REPO)}
placeholder={t(I18nKey.REPOSITORY$SELECT_REPO)}
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
defaultFilter={defaultFilter}
isDisabled={isDisabled}
/>
);
}

View File

@@ -32,26 +32,32 @@ export function MCPConfigEditor({ mcpConfig, onChange }: MCPConfigEditorProps) {
{t(I18nKey.SETTINGS$MCP_DESCRIPTION)}
</p>
</div>
{!isEditing && (
<div className="flex justify-between items-center mb-4">
<div className="flex items-center">
<BrandButton
type="button"
variant="primary"
onClick={() => setIsEditing(true)}
>
{t(I18nKey.SETTINGS$MCP_EDIT_CONFIGURATION)}
</BrandButton>
</div>
<div className="flex justify-between items-center mb-4">
<div className="flex items-center">
<a
href="https://docs.all-hands.dev/usage/mcp"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-400 hover:underline mr-3"
onClick={(e) => e.stopPropagation()}
>
{t(I18nKey.COMMON$DOCUMENTATION)}
</a>
<BrandButton
type="button"
variant="primary"
onClick={() => setIsEditing(!isEditing)}
>
{isEditing
? t(I18nKey.SETTINGS$MCP_CANCEL)
: t(I18nKey.SETTINGS$MCP_EDIT_CONFIGURATION)}
</BrandButton>
</div>
)}
</div>
<div>
{isEditing ? (
<MCPJsonEditor
mcpConfig={mcpConfig}
onChange={handleConfigChange}
onCancel={() => setIsEditing(false)}
/>
<MCPJsonEditor mcpConfig={mcpConfig} onChange={handleConfigChange} />
) : (
<>
<div className="flex flex-col gap-6">

View File

@@ -1,21 +1,15 @@
import React, { useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import { MCPConfig } from "#/types/settings";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
import { cn } from "#/utils/utils";
interface MCPJsonEditorProps {
mcpConfig?: MCPConfig;
onChange: (config: MCPConfig) => void;
onCancel: () => void;
}
export function MCPJsonEditor({
mcpConfig,
onChange,
onCancel,
}: MCPJsonEditorProps) {
export function MCPJsonEditor({ mcpConfig, onChange }: MCPJsonEditorProps) {
const { t } = useTranslation();
const [configText, setConfigText] = useState(() =>
mcpConfig
@@ -71,31 +65,11 @@ export function MCPJsonEditor({
return (
<div>
<p className="mb-2 text-sm text-gray-400">
<Trans
i18nKey={I18nKey.SETTINGS$MCP_CONFIG_DESCRIPTION}
components={{
a: (
<a
href="https://docs.all-hands.dev/usage/mcp"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
documentation
</a>
),
}}
/>
</p>
<div className="mb-2 text-sm text-gray-400">
{t(I18nKey.SETTINGS$MCP_CONFIG_DESCRIPTION)}
</div>
<textarea
className={cn(
"w-full h-64 resize-y p-2 rounded-sm text-sm font-mono",
"bg-tertiary border border-[#717888]",
"placeholder:italic placeholder:text-tertiary-alt",
"focus:outline-none focus:ring-1 focus:ring-primary",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
className="w-full h-64 p-2 text-sm font-mono bg-base-tertiary rounded-md focus:border-blue-500 focus:outline-hidden"
value={configText}
onChange={handleTextChange}
spellCheck="false"
@@ -113,12 +87,9 @@ export function MCPJsonEditor({
}
</code>
</div>
<div className="mt-4 flex justify-end gap-3">
<BrandButton type="button" variant="secondary" onClick={onCancel}>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<div className="mt-4 flex justify-end">
<BrandButton type="button" variant="primary" onClick={handleSave}>
{t(I18nKey.SETTINGS$MCP_CONFIRM_CHANGES)}
{t(I18nKey.SETTINGS$MCP_APPLY_CHANGES)}
</BrandButton>
</div>
</div>

View File

@@ -13,6 +13,7 @@ function Terminal() {
const ref = useTerminal({
commands,
readOnly: true, // Make terminal read-only
});
return (

View File

@@ -1,23 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
import OpenHands from "#/api/open-hands";
import { useUserProviders } from "../use-user-providers";
export const useAppInstallations = () => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
const { providers } = useUserProviders();
return useQuery({
queryKey: ["installations", providers, config?.GITHUB_CLIENT_ID],
queryFn: OpenHands.getGitHubUserInstallationIds,
enabled:
userIsAuthenticated &&
providers.includes("github") &&
!!config?.GITHUB_CLIENT_ID &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@@ -1,22 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
import OpenHands from "#/api/open-hands";
import { useUserProviders } from "../use-user-providers";
export const useBitbucketWorkspaces = () => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
const { providers } = useUserProviders();
return useQuery({
queryKey: ["workspaces", providers],
queryFn: OpenHands.getBitBucketWorkspaces,
enabled:
userIsAuthenticated &&
providers.includes("bitbucket") &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@@ -16,10 +16,12 @@ import { parseTerminalOutput } from "#/utils/parse-terminal-output";
interface UseTerminalConfig {
commands: Command[];
readOnly?: boolean;
}
const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = {
commands: [],
readOnly: false,
};
const renderCommand = (command: Command, terminal: Terminal) => {
@@ -36,6 +38,7 @@ const persistentLastCommandIndex = { current: 0 };
export const useTerminal = ({
commands,
readOnly = false,
}: UseTerminalConfig = DEFAULT_TERMINAL_CONFIG) => {
const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
@@ -44,7 +47,7 @@ export const useTerminal = ({
const ref = React.useRef<HTMLDivElement>(null);
const lastCommandIndex = persistentLastCommandIndex; // Use the persistent reference
const keyEventDisposable = React.useRef<{ dispose: () => void } | null>(null);
const disabled = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const disabled = RUNTIME_INACTIVE_STATES.includes(curAgentState) || readOnly;
const createTerminal = () =>
new Terminal({

View File

@@ -50,7 +50,8 @@ export enum I18nKey {
SETTINGS$NAV_MCP = "SETTINGS$NAV_MCP",
SETTINGS$MCP_CONFIGURATION = "SETTINGS$MCP_CONFIGURATION",
SETTINGS$MCP_EDIT_CONFIGURATION = "SETTINGS$MCP_EDIT_CONFIGURATION",
SETTINGS$MCP_CONFIRM_CHANGES = "SETTINGS$MCP_CONFIRM_CHANGES",
SETTINGS$MCP_CANCEL = "SETTINGS$MCP_CANCEL",
SETTINGS$MCP_APPLY_CHANGES = "SETTINGS$MCP_APPLY_CHANGES",
SETTINGS$MCP_CONFIG_DESCRIPTION = "SETTINGS$MCP_CONFIG_DESCRIPTION",
SETTINGS$MCP_CONFIG_ERROR = "SETTINGS$MCP_CONFIG_ERROR",
SETTINGS$MCP_CONFIG_EXAMPLE = "SETTINGS$MCP_CONFIG_EXAMPLE",
@@ -578,6 +579,7 @@ export enum I18nKey {
BITBUCKET$TOKEN_LINK_TEXT = "BITBUCKET$TOKEN_LINK_TEXT",
BITBUCKET$INSTRUCTIONS_LINK_TEXT = "BITBUCKET$INSTRUCTIONS_LINK_TEXT",
GITLAB$OR_SEE = "GITLAB$OR_SEE",
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED",
DIFF_VIEWER$LOADING = "DIFF_VIEWER$LOADING",
DIFF_VIEWER$GETTING_LATEST_CHANGES = "DIFF_VIEWER$GETTING_LATEST_CHANGES",

View File

@@ -799,37 +799,53 @@
"de": "Konfiguration bearbeiten",
"uk": "Редагувати налаштування"
},
"SETTINGS$MCP_CONFIRM_CHANGES": {
"en": "Confirm Changes",
"ja": "変更を確定",
"zh-CN": "确认更改",
"zh-TW": "確認變更",
"ko-KR": "변경 사항 확인",
"no": "Bekreft endringer",
"it": "Conferma modifiche",
"pt": "Confirmar alterações",
"es": "Confirmar cambios",
"ar": "تأكيد التغييرات",
"fr": "Confirmer les modifications",
"tr": "Değişiklikleri Onayla",
"de": "Änderungen bestätigen",
"uk": "Підтвердити зміни"
"SETTINGS$MCP_CANCEL": {
"en": "Cancel",
"ja": "キャンセル",
"zh-CN": "取消",
"zh-TW": "取消",
"ko-KR": "취소",
"no": "Avbryt",
"it": "Annulla",
"pt": "Cancelar",
"es": "Cancelar",
"ar": "إلغاء",
"fr": "Annuler",
"tr": "İptal",
"de": "Abbrechen",
"uk": "Скасувати"
},
"SETTINGS$MCP_APPLY_CHANGES": {
"en": "Apply Changes",
"ja": "変更を適用",
"zh-CN": "应用更改",
"zh-TW": "應用更改",
"ko-KR": "변경 사항 적용",
"no": "Bruk endringer",
"it": "Applica modifiche",
"pt": "Aplicar alterações",
"es": "Aplicar cambios",
"ar": "تطبيق التغييرات",
"fr": "Appliquer les modifications",
"tr": "Değişiklikleri Uygula",
"de": "Änderungen anwenden",
"uk": "Застосувати зміни"
},
"SETTINGS$MCP_CONFIG_DESCRIPTION": {
"en": "Edit the JSON configuration for MCP servers below. The configuration must include both sse_servers and stdio_servers arrays. For full configuration details and integration examples, see the <a>documentation</a>.",
"ja": "以下のMCPサーバーのJSON設定を編集してください。設定にはsse_serversとstdio_serversの両方の配列を含める必要があります。詳細な設定と統合の例については、<a>ドキュメント</a>を参照してください。",
"zh-CN": "在下方编辑MCP服务器的JSON配置。配置必须包含sse_servers和stdio_servers数组。有关完整的配置详情和集成示例,请参阅<a>文档</a>。",
"zh-TW": "在下方編輯MCP服務器的JSON配置。配置必須包含sse_servers和stdio_servers數組。有關完整配置詳情與整合範例,請參閱<a>文件</a>。",
"ko-KR": "아래에서 MCP 서버의 JSON 구성을 편집하세요. 구성에는 sse_servers와 stdio_servers 배열이 모두 포함되어야 합니다. 전체 구성 세부 정보와 통합 예시는 <a>문서</a>를 참조하세요.",
"no": "Rediger JSON-konfigurasjonen for MCP-servere nedenfor. Konfigurasjonen må inkludere både sse_servers og stdio_servers-matriser. For detaljer om konfigurasjon og integrasjon, se <a>dokumentasjonen</a>.",
"it": "Modifica la configurazione JSON per i server MCP qui sotto. La configurazione deve includere sia gli array sse_servers che stdio_servers. Per i dettagli completi sulla configurazione e gli esempi di integrazione, vedi la <a>documentazione</a>.",
"pt": "Edite a configuração JSON para servidores MCP abaixo. A configuração deve incluir os arrays sse_servers e stdio_servers. Para detalhes completos de configuração e exemplos de integração, veja a <a>documentação</a>.",
"es": "Edite la configuración JSON para los servidores MCP a continuación. La configuración debe incluir tanto los arrays sse_servers como stdio_servers. Para ver detalles completos de configuración y ejemplos de integración, consulte la <a>documentación</a>.",
"ar": "قم بتحرير تكوين JSON لخوادم MCP أدناه. يجب أن يتضمن التكوين كلاً من مصفوفات sse_servers و stdio_servers. للحصول على تفاصيل التكوين الكاملة وأمثلة التكامل، راجع <a>التوثيق</a>.",
"fr": "Modifiez la configuration JSON pour les serveurs MCP ci-dessous. La configuration doit inclure à la fois les tableaux sse_servers et stdio_servers. Pour plus de détails sur la configuration et des exemples d'intégration, voir la <a>documentation</a>.",
"tr": "Aşağıdaki MCP sunucuları için JSON yapılandırmasını düzenleyin. Yapılandırma hem sse_servers hem de stdio_servers dizilerini içermelidir. Tam yapılandırma ayrıntıları ve entegrasyon örnekleri için <a>belgeler</a>'e bakın.",
"de": "Bearbeiten Sie die JSON-Konfiguration für MCP-Server unten. Die Konfiguration muss sowohl sse_servers- als auch stdio_servers-Arrays enthalten. Weitere Konfigurationsdetails und Integrationsbeispiele finden Sie in der <a>Dokumentation</a>.",
"uk": "Відредагуйте JSON-конфігурацію для серверів MCP нижче. Конфігурація повинна включати масиви sse_servers та stdio_servers. Повну інформацію про конфігурацію та приклади інтеграції дивіться в <a>документації</a>."
"en": "Edit the JSON configuration for MCP servers below. The configuration must include both sse_servers and stdio_servers arrays.",
"ja": "以下のMCPサーバーのJSON設定を編集してください。設定にはsse_serversとstdio_serversの両方の配列を含める必要があります。",
"zh-CN": "在下方编辑MCP服务器的JSON配置。配置必须包含sse_servers和stdio_servers数组。",
"zh-TW": "在下方編輯MCP服務器的JSON配置。配置必須包含sse_servers和stdio_servers數組。",
"ko-KR": "아래에서 MCP 서버의 JSON 구성을 편집하세요. 구성에는 sse_servers와 stdio_servers 배열이 모두 포함되어야 합니다.",
"no": "Rediger JSON-konfigurasjonen for MCP-servere nedenfor. Konfigurasjonen må inkludere både sse_servers og stdio_servers-matriser.",
"it": "Modifica la configurazione JSON per i server MCP qui sotto. La configurazione deve includere sia gli array sse_servers che stdio_servers.",
"pt": "Edite a configuração JSON para servidores MCP abaixo. A configuração deve incluir os arrays sse_servers e stdio_servers.",
"es": "Edite la configuración JSON para los servidores MCP a continuación. La configuración debe incluir tanto los arrays sse_servers como stdio_servers.",
"ar": "قم بتحرير تكوين JSON لخوادم MCP أدناه. يجب أن يتضمن التكوين كلاً من مصفوفات sse_servers و stdio_servers.",
"fr": "Modifiez la configuration JSON pour les serveurs MCP ci-dessous. La configuration doit inclure à la fois les tableaux sse_servers et stdio_servers.",
"tr": "Aşağıdaki MCP sunucuları için JSON yapılandırmasını düzenleyin. Yapılandırma hem sse_servers hem de stdio_servers dizilerini içermelidir.",
"de": "Bearbeiten Sie die JSON-Konfiguration für MCP-Server unten. Die Konfiguration muss sowohl sse_servers- als auch stdio_servers-Arrays enthalten.",
"uk": "Відредагуйте JSON-конфігурацію для серверів MCP нижче. Конфігурація повинна включати масиви sse_servers та stdio_servers."
},
"SETTINGS$MCP_CONFIG_ERROR": {
"en": "Error:",
@@ -9247,6 +9263,22 @@
"de": "oder siehe",
"uk": "або перегляньте"
},
"COMMON$DOCUMENTATION": {
"en": "documentation",
"ja": "ドキュメント",
"zh-CN": "文档",
"zh-TW": "文件",
"ko-KR": "문서",
"no": "dokumentasjon",
"it": "documentazione",
"pt": "documentação",
"es": "documentación",
"ar": "التوثيق",
"fr": "documentation",
"tr": "belgelendirme",
"de": "Dokumentation",
"uk": "документація"
},
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED": {
"en": "The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.",
"ja": "アクションは実行されていません。これはユーザーが停止ボタンを押したか、リソース制約によりランタイムシステムがクラッシュして再起動したことが原因かもしれません。以前に確立されたシステム状態、依存関係、または環境変数は失われている可能性があります。",

View File

@@ -27,7 +27,6 @@ Your primary role is to assist users by executing commands, modifying code, and
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
* Before implementing any changes, first thoroughly understand the codebase through exploration.
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
</CODE_QUALITY>
<VERSION_CONTROL>

View File

@@ -21,7 +21,6 @@ Your primary role is to assist users by executing commands, modifying code, and
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
* Before implementing any changes, first thoroughly understand the codebase through exploration.
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
</CODE_QUALITY>
<VERSION_CONTROL>

View File

@@ -21,7 +21,6 @@ Your primary role is to assist users by executing commands, modifying code, and
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
* Before implementing any changes, first thoroughly understand the codebase through exploration.
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
</CODE_QUALITY>
<VERSION_CONTROL>

View File

@@ -231,26 +231,12 @@ async def run_session(
return
confirmation_status = await read_confirmation_input(config)
if confirmation_status in ('yes', 'always'):
if confirmation_status == 'yes' or confirmation_status == 'always':
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
)
elif confirmation_status == 'edit':
# Tell the agent the proposed action was rejected
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_REJECTED),
EventSource.USER,
)
# Notify the user
print_formatted_text(
HTML(
'<skyblue>Okay, please tell me what I should do instead.</skyblue>'
)
)
# Solicit replacement isntructions
await prompt_for_next_task(AgentState.AWAITING_USER_INPUT)
else: # 'no' or fallback
else:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_REJECTED),
EventSource.USER,

View File

@@ -589,20 +589,34 @@ async def read_prompt_input(
async def read_confirmation_input(config: OpenHandsConfig) -> str:
try:
choices = [
'Yes, proceed',
'No, skip this action',
"Always proceed (don't ask again)",
'Let me provide different instructions',
]
prompt_session = create_prompt_session(config)
# keep the outer coroutine responsive by using asyncio.to_thread which puts the blocking call app.run() of cli_confirm() in a separate thread
index = await asyncio.to_thread(
cli_confirm, config, 'Choose an option:', choices
)
while True:
with patch_stdout():
print_formatted_text('')
confirmation: str = await prompt_session.prompt_async(
HTML('<gold>Proceed with action? (y)es/(n)o/(a)lways > </gold>'),
)
return {0: 'yes', 1: 'no', 2: 'always', 3: 'edit'}.get(index, 'no')
confirmation = (
'' if confirmation is None else confirmation.strip().lower()
)
if confirmation in ['y', 'yes']:
return 'yes'
elif confirmation in ['n', 'no']:
return 'no'
elif confirmation in ['a', 'always']:
return 'always'
else:
# Display error message for invalid input
print_formatted_text('')
print_formatted_text(
HTML(
'<ansired>Invalid input. Please enter (y)es, (n)o, or (a)lways.</ansired>'
)
)
# Continue the loop to re-prompt
except (KeyboardInterrupt, EOFError):
return 'no'

View File

@@ -219,26 +219,10 @@ class EventStream(EventStore):
def update_secrets(self, secrets: dict[str, str]) -> None:
self.secrets.update(secrets)
def _replace_secrets(
self, data: dict[str, Any], is_top_level: bool = True
) -> dict[str, Any]:
# Fields that should not have secrets replaced (only at top level - system metadata)
TOP_LEVEL_PROTECTED_FIELDS = {
'timestamp',
'id',
'source',
'cause',
'action',
'observation',
'message',
}
def _replace_secrets(self, data: dict[str, Any]) -> dict[str, Any]:
for key in data:
if is_top_level and key in TOP_LEVEL_PROTECTED_FIELDS:
# Skip secret replacement for protected system fields at top level only
continue
elif isinstance(data[key], dict):
data[key] = self._replace_secrets(data[key], is_top_level=False)
if isinstance(data[key], dict):
data[key] = self._replace_secrets(data[key])
elif isinstance(data[key], str):
for secret in self.secrets.values():
data[key] = data[key].replace(secret, '<secret_hidden>')

View File

@@ -9,7 +9,6 @@ from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
InstallationsService,
OwnerType,
ProviderType,
Repository,
@@ -21,7 +20,7 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class BitBucketService(BaseGitService, GitService, InstallationsService):
class BitBucketService(BaseGitService, GitService):
"""Default implementation of GitService for Bitbucket integration.
This is an extension point in OpenHands that allows applications to customize Bitbucket
@@ -186,89 +185,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
return all_items[:max_items] # Trim to max_items if needed
async def get_installations(self) -> list[str]:
workspaces_url = f'{self.BASE_URL}/workspaces'
workspaces = await self._fetch_paginated_data(workspaces_url, {}, 100)
installations: list[str] = []
for workspace in workspaces:
installations.append(workspace['slug'])
return installations
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
"""Get paginated repositories for a specific workspace.
Args:
page: The page number to fetch
per_page: The number of repositories per page
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
Returns:
A list of Repository objects
"""
if not installation_id:
return []
# Convert installation_id to string for use as workspace_slug
workspace_slug = installation_id
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
# Map sort parameter to Bitbucket API compatible values
bitbucket_sort = sort
if sort == 'pushed':
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
elif sort == 'updated':
bitbucket_sort = '-updated_on'
elif sort == 'created':
bitbucket_sort = '-created_on'
elif sort == 'full_name':
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
else:
# Default to most recently updated first
bitbucket_sort = '-updated_on'
params = {
'pagelen': per_page,
'page': page,
'sort': bitbucket_sort,
}
response, headers = await self._make_request(workspace_repos_url, params)
# Extract repositories from the response
repos = response.get('values', [])
# Extract link header for pagination
next_link = response.get('next', '')
repositories = [
Repository(
id=repo.get('uuid', ''),
full_name=f'{repo.get("workspace", {}).get("slug", "")}/{repo.get("slug", "")}',
git_provider=ProviderType.BITBUCKET,
is_public=repo.get('is_private', True) is False,
stargazers_count=None, # Bitbucket doesn't have stars
pushed_at=repo.get('updated_on'),
owner_type=(
OwnerType.ORGANIZATION
if repo.get('workspace', {}).get('is_private') is False
else OwnerType.USER
),
link_header=next_link,
)
for repo in repos
]
return repositories
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
"""Get repositories for the authenticated user using workspaces endpoint.
This method gets all repositories (both public and private) that the user has access to

View File

@@ -15,7 +15,6 @@ from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
InstallationsService,
OwnerType,
ProviderType,
Repository,
@@ -29,7 +28,7 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class GitHubService(BaseGitService, GitService, InstallationsService):
class GitHubService(BaseGitService, GitService):
"""Default implementation of GitService for GitHub integration.
TODO: This doesn't seem a good candidate for the get_impl() pattern. What are the abstract methods we should actually separate and implement here?
@@ -193,47 +192,14 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
ts = repo.get('pushed_at')
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ') if ts else datetime.min
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
):
params = {'page': str(page), 'per_page': str(per_page)}
if installation_id:
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
response, headers = await self._make_request(url, params)
response = response.get('repositories', [])
else:
url = f'{self.BASE_URL}/user/repos'
params['sort'] = sort
response, headers = await self._make_request(url, params)
next_link: str = headers.get('Link', '')
return [
Repository(
id=str(repo.get('id')), # type: ignore[arg-type]
full_name=repo.get('full_name'), # type: ignore[arg-type]
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,
is_public=not repo.get('private', True),
owner_type=(
OwnerType.ORGANIZATION
if repo.get('owner', {}).get('type') == 'Organization'
else OwnerType.USER
),
link_header=next_link,
)
for repo in response
]
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitHub API
all_repos: list[dict] = []
if app_mode == AppMode.SAAS:
# Get all installation IDs and fetch repos for each one
installation_ids = await self.get_installations()
installation_ids = await self.get_installation_ids()
# Iterate through each installation ID
for installation_id in installation_ids:
@@ -280,11 +246,11 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
for repo in all_repos
]
async def get_installations(self) -> list[str]:
async def get_installation_ids(self) -> list[int]:
url = f'{self.BASE_URL}/user/installations'
response, _ = await self._make_request(url)
installations = response.get('installations', [])
return [str(i['id']) for i in installations]
return [i['id'] for i in installations]
async def search_repositories(
self, query: str, per_page: int, sort: str, order: str

View File

@@ -226,49 +226,7 @@ class GitLabService(BaseGitService, GitService):
return repos
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
url = f'{self.BASE_URL}/projects'
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name',
}.get(sort, 'last_activity_at')
params = {
'page': str(page),
'per_page': str(per_page),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'owned': True, # Boolean value without quotes
'membership': True, # Include projects user is a member of
}
response, headers = await self._make_request(url, params)
next_link: str = headers.get('Link', '')
repos = [
Repository(
id=str(repo.get('id')), # type: ignore[arg-type]
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
owner_type=(
OwnerType.ORGANIZATION
if repo.get('namespace', {}).get('kind') == 'group'
else OwnerType.USER
),
link_header=next_link,
)
for repo in response
]
return repos
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from types import MappingProxyType
from typing import Annotated, Any, Coroutine, Literal, cast, overload
from typing import Annotated, Any, Coroutine, Literal, overload
from pydantic import (
BaseModel,
@@ -22,7 +22,6 @@ from openhands.integrations.service_types import (
AuthenticationError,
Branch,
GitService,
InstallationsService,
ProviderType,
Repository,
SuggestedTask,
@@ -161,61 +160,16 @@ class ProviderHandler:
service = self._get_service(provider)
return await service.get_latest_token()
async def get_github_installations(self) -> list[str]:
service = cast(InstallationsService, self._get_service(ProviderType.GITHUB))
try:
return await service.get_installations()
except Exception as e:
logger.warning(f'Failed to get github installations {e}')
return []
async def get_bitbucket_workspaces(self) -> list[str]:
service = cast(InstallationsService, self._get_service(ProviderType.BITBUCKET))
try:
return await service.get_installations()
except Exception as e:
logger.warning(f'Failed to get bitbucket workspaces {e}')
return []
async def get_repositories(
self,
sort: str,
app_mode: AppMode,
selected_provider: ProviderType | None,
page: int | None,
per_page: int | None,
installation_id: str | None,
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
"""
Get repositories from providers
"""
"""
Get repositories from providers
"""
if selected_provider:
if not page or not per_page:
logger.error('Failed to provider params for paginating repos')
return []
service = self._get_service(selected_provider)
try:
return await service.get_paginated_repos(
page, per_page, sort, installation_id
)
except Exception as e:
logger.warning(f'Error fetching repos from {selected_provider}: {e}')
return []
all_repos: list[Repository] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service_repos = await service.get_all_repositories(sort, app_mode)
service_repos = await service.get_repositories(sort, app_mode)
all_repos.extend(service_repos)
except Exception as e:
logger.warning(f'Error fetching repos from {provider}: {e}')

View File

@@ -200,12 +200,6 @@ class BaseGitService(ABC):
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
class InstallationsService(Protocol):
async def get_installations(self) -> list[str]:
"""Get installations for the service; repos live underneath these installations"""
...
class GitService(Protocol):
"""Protocol defining the interface for Git service providers"""
@@ -239,18 +233,10 @@ class GitService(Protocol):
"""Search for repositories"""
...
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
"""Get repositories for the authenticated user"""
...
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
"""Get a page of repositories for the authenticated user"""
...
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories"""
...

View File

@@ -4,7 +4,7 @@ A comment on the issue has been addressed to you.
# Steps to Handle the Comment
1. Address the comment. Use the $GITHUB_TOKEN and GitHub API to read issue title, body, and comments if you need more context
1. Address the comment. Use the GitHub API to read issue title, body, and comments if you need more context
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
3. Run the tests, and if they pass you are done!
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.

View File

@@ -1,6 +1,6 @@
Your tasking is to fix an issue in your repository. Do the following
1. Read the issue body and comments using the $GITHUB_TOKEN and Github API
1. Read the issue body and comments using the Github API
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
3. Run the tests, and if they pass you are done!
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.

View File

@@ -6,7 +6,7 @@ A comment on the PR has been addressed to you. Do NOT respond to this comment vi
# Steps to Handle the Comment
## Understand the PR Context
Use the $GITHUB_TOKEN and GitHub API to:
Use the GitHub API to:
1. Retrieve the diff against main to understand the changes
2. Fetch the PR body and the linked issue for context

View File

@@ -4,7 +4,7 @@ A comment on the issue has been addressed to you.
# Steps to Handle the Comment
1. Address the comment. Use the $GITLAB_TOKEN and GitLab API to read issue title, body, and comments if you need more context
1. Address the comment. Use the GitLab API to read issue title, body, and comments if you need more context
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
3. Run the tests, and if they pass you are done!
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.

View File

@@ -1,6 +1,6 @@
Your tasking is to fix an issue in your repository. Do the following
1. Read the issue body and comments using the $GITLAB_TOKEN and GitLab API
1. Read the issue body and comments using the GitLab API
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
3. Run the tests, and if they pass you are done!
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.

View File

@@ -6,7 +6,7 @@ A comment on the MR has been addressed to you. Do NOT respond to this comment vi
# Steps to Handle the Comment
## Understand the MR Context
Use the $GITLAB_TOKEN and GitLab API to:
Use the GitLab API to:
1. Retrieve the diff against main to understand the changes
2. Fetch the MR body and the linked issue for context

View File

@@ -87,7 +87,6 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
'gemini-2.5-pro',
'gpt-4.1',
'kimi-k2-0711-preview',
'kimi-k2-instruct',
]
REASONING_EFFORT_SUPPORTED_MODELS = [
@@ -811,8 +810,6 @@ class LLM(RetryMixin, DebugMixin):
message.function_calling_enabled = self.is_function_calling_active()
if 'deepseek' in self.config.model:
message.force_string_serializer = True
if 'kimi-k2-instruct' in self.config.model and 'groq' in self.config.model:
message.force_string_serializer = True
# let pydantic handle the serialization
return [message.model_dump() for message in messages]

View File

@@ -38,55 +38,9 @@ from openhands.server.user_auth import (
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
@app.get('/github/installations', response_model=list[str])
async def get_user_github_installations(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
return await client.get_github_installations()
return JSONResponse(
content='Git provider token required. (such as GitHub).',
status_code=status.HTTP_401_UNAUTHORIZED,
)
@app.get('/bitbucket/installations', response_model=list[str])
async def get_user_bitbucket_installations(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
return await client.get_github_installations()
return JSONResponse(
content='Git provider token required. (such as GitHub).',
status_code=status.HTTP_401_UNAUTHORIZED,
)
@app.get('/repositories', response_model=list[Repository])
async def get_user_repositories(
sort: str = 'pushed',
selected_provider: ProviderType | None = None,
page: int | None = None,
per_page: int | None = None,
installation_id: str | None = None,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
@@ -99,14 +53,7 @@ async def get_user_repositories(
)
try:
return await client.get_repositories(
sort,
server_config.app_mode,
selected_provider,
page,
per_page,
installation_id,
)
return await client.get_repositories(sort, server_config.app_mode)
except AuthenticationError as e:
logger.info(

View File

@@ -450,7 +450,7 @@ async def test_bitbucket_sort_parameter_mapping():
]
# Call get_repositories with sort='pushed'
await service.get_all_repositories('pushed', AppMode.SAAS)
await service.get_repositories('pushed', AppMode.SAAS)
# Verify that the second call used 'updated_on' instead of 'pushed'
assert mock_request.call_count == 2
@@ -520,7 +520,7 @@ async def test_bitbucket_pagination():
]
# Call get_repositories
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify that all three requests were made (workspaces + 2 pages of repos)
assert mock_request.call_count == 3
@@ -619,7 +619,7 @@ async def test_bitbucket_get_repositories_with_user_owner_type():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_repos]
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -658,7 +658,7 @@ async def test_bitbucket_get_repositories_with_organization_owner_type():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_repos]
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -706,7 +706,7 @@ async def test_bitbucket_get_repositories_mixed_owner_types():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_user_repos, mock_org_repos]
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got repositories from both workspaces
assert len(repositories) == 2
@@ -746,7 +746,7 @@ async def test_bitbucket_get_repositories_owner_type_fallback():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_repos]
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify all repositories default to USER owner_type for private workspaces
for repo in repositories:

View File

@@ -1,4 +1,4 @@
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
@@ -275,46 +275,171 @@ class TestUserCancelledError:
class TestReadConfirmationInput:
@pytest.mark.asyncio
@patch('openhands.cli.tui.cli_confirm')
async def test_read_confirmation_input_yes(self, mock_confirm):
mock_confirm.return_value = 0 # user picked first menu item
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_yes(self, mock_create_session):
mock_session = AsyncMock()
mock_session.prompt_async.return_value = 'y'
mock_create_session.return_value = mock_session
cfg = MagicMock() # <- no spec for simplicity
cfg.cli = MagicMock(vi_mode=False)
result = await read_confirmation_input(config=cfg)
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'yes'
@pytest.mark.asyncio
@patch('openhands.cli.tui.cli_confirm')
async def test_read_confirmation_input_no(self, mock_confirm):
mock_confirm.return_value = 1 # user picked second menu item
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_yes_full(self, mock_create_session):
mock_session = AsyncMock()
mock_session.prompt_async.return_value = 'yes'
mock_create_session.return_value = mock_session
cfg = MagicMock() # <- no spec for simplicity
cfg.cli = MagicMock(vi_mode=False)
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'yes'
result = await read_confirmation_input(config=cfg)
@pytest.mark.asyncio
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_no(self, mock_create_session):
mock_session = AsyncMock()
mock_session.prompt_async.return_value = 'n'
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
@pytest.mark.asyncio
@patch('openhands.cli.tui.cli_confirm')
async def test_read_confirmation_input_always(self, mock_confirm):
mock_confirm.return_value = 2 # user picked third menu item
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_no_full(self, mock_create_session):
mock_session = AsyncMock()
mock_session.prompt_async.return_value = 'no'
mock_create_session.return_value = mock_session
cfg = MagicMock() # <- no spec for simplicity
cfg.cli = MagicMock(vi_mode=False)
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
result = await read_confirmation_input(config=cfg)
@pytest.mark.asyncio
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_always(self, mock_create_session):
mock_session = AsyncMock()
mock_session.prompt_async.return_value = 'a'
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'always'
@pytest.mark.asyncio
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_always_full(self, mock_create_session):
mock_session = AsyncMock()
mock_session.prompt_async.return_value = 'always'
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'always'
@pytest.mark.asyncio
@patch('openhands.cli.tui.print_formatted_text')
@patch('openhands.cli.tui.cli_confirm')
async def test_read_confirmation_input_edit(self, mock_confirm, mock_print):
mock_confirm.return_value = 3 # user picked third menu item
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_invalid_then_valid(
self, mock_create_session, mock_print
):
mock_session = AsyncMock()
# First return invalid input, then valid input
mock_session.prompt_async.side_effect = ['invalid', 'y']
mock_create_session.return_value = mock_session
cfg = MagicMock() # <- no spec for simplicity
cfg.cli = MagicMock(vi_mode=False)
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'yes'
result = await read_confirmation_input(config=cfg)
assert result == 'edit'
# Verify error message was displayed
error_calls = [
call
for call in mock_print.call_args_list
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
]
assert len(error_calls) > 0
@pytest.mark.asyncio
@patch('openhands.cli.tui.print_formatted_text')
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_empty_then_valid(
self, mock_create_session, mock_print
):
mock_session = AsyncMock()
# First return empty input, then valid input
mock_session.prompt_async.side_effect = ['', 'n']
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
# Verify error message was displayed
error_calls = [
call
for call in mock_print.call_args_list
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
]
assert len(error_calls) > 0
@pytest.mark.asyncio
@patch('openhands.cli.tui.print_formatted_text')
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_none_then_valid(
self, mock_create_session, mock_print
):
mock_session = AsyncMock()
# First return None, then valid input
mock_session.prompt_async.side_effect = [None, 'always']
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'always'
# Verify error message was displayed
error_calls = [
call
for call in mock_print.call_args_list
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
]
assert len(error_calls) > 0
@pytest.mark.asyncio
@patch('openhands.cli.tui.print_formatted_text')
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_multiple_invalid_then_valid(
self, mock_create_session, mock_print
):
mock_session = AsyncMock()
# Multiple invalid inputs, then valid input
mock_session.prompt_async.side_effect = ['invalid1', 'invalid2', 'maybe', 'y']
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'yes'
# Verify error message was displayed multiple times
error_calls = [
call
for call in mock_print.call_args_list
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
]
assert len(error_calls) >= 3 # Should have at least 3 error messages
@pytest.mark.asyncio
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_keyboard_interrupt(
self, mock_create_session
):
mock_session = AsyncMock()
mock_session.prompt_async.side_effect = KeyboardInterrupt
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
@pytest.mark.asyncio
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_eof_error(self, mock_create_session):
mock_session = AsyncMock()
mock_session.prompt_async.side_effect = EOFError
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'

View File

@@ -2,7 +2,6 @@ import gc
import json
import os
import time
from datetime import datetime
import psutil
import pytest
@@ -11,7 +10,6 @@ from pytest import TempPathFactory
from openhands.core.schema import ActionType, ObservationType
from openhands.events import EventSource, EventStream, EventStreamSubscriber
from openhands.events.action import (
CmdRunAction,
NullAction,
)
from openhands.events.action.files import (
@@ -737,129 +735,3 @@ def test_cache_page_with_missing_events(temp_dir: str):
# If the delete operation fails, we'll just verify that the basic functionality works
print(f'Note: Could not delete file {missing_filename}: {e}')
assert len(initial_events) > 0, 'Should retrieve events successfully'
def test_secrets_replaced_in_content(temp_dir: str):
"""Test that secrets are properly replaced in event content."""
file_store = get_file_store('local', temp_dir)
stream = EventStream('test_session', file_store)
# Set up a secret
stream.set_secrets({'api_key': 'secret123'})
# Create an event with the secret in the command
action = CmdRunAction(
command='curl -H "Authorization: Bearer secret123" https://api.example.com'
)
action._timestamp = datetime.now().isoformat()
# Convert to dict and apply secret replacement
data = event_to_dict(action)
data_with_secrets_replaced = stream._replace_secrets(data)
# The secret should be replaced in the command
assert '<secret_hidden>' in data_with_secrets_replaced['args']['command']
assert 'secret123' not in data_with_secrets_replaced['args']['command']
def test_timestamp_not_affected_by_secret_replacement(temp_dir: str):
"""Test that timestamps are not corrupted by secret replacement."""
file_store = get_file_store('local', temp_dir)
stream = EventStream('test_session', file_store)
# Set up a secret that appears in the current date (e.g., "18" for 2025-07-18)
stream.set_secrets({'test_secret': '18'})
# Create an event with a timestamp
action = CmdRunAction(command='echo "hello world"')
action._timestamp = '2025-07-18T17:01:36.799608' # Contains "18"
# Convert to dict and apply secret replacement
data = event_to_dict(action)
original_timestamp = data['timestamp']
data_with_secrets_replaced = stream._replace_secrets(data)
# The timestamp should NOT be affected by secret replacement
assert data_with_secrets_replaced['timestamp'] == original_timestamp
assert '<secret_hidden>' not in data_with_secrets_replaced['timestamp']
assert '18' in data_with_secrets_replaced['timestamp'] # Original value preserved
def test_protected_fields_not_affected_by_secret_replacement(temp_dir: str):
"""Test that protected system fields are not affected by secret replacement."""
file_store = get_file_store('local', temp_dir)
stream = EventStream('test_session', file_store)
# Set up secrets that might appear in system fields
stream.set_secrets(
{
'secret1': '123', # Could appear in ID
'secret2': 'user', # Could appear in source
'secret3': 'run', # Could appear in action/observation
'secret4': 'Running', # Could appear in message
}
)
# Create test data with protected fields
data = {
'id': 123,
'timestamp': '2025-07-18T17:01:36.799608',
'source': 'user',
'cause': 123,
'action': 'run',
'observation': 'run',
'message': 'Running command: echo hello',
'content': 'This contains secret1: 123 and secret2: user and secret3: run',
}
data_with_secrets_replaced = stream._replace_secrets(data)
# Protected fields should not be affected at top level
assert data_with_secrets_replaced['id'] == 123
assert data_with_secrets_replaced['timestamp'] == '2025-07-18T17:01:36.799608'
assert data_with_secrets_replaced['source'] == 'user'
assert data_with_secrets_replaced['cause'] == 123
assert data_with_secrets_replaced['action'] == 'run'
assert data_with_secrets_replaced['observation'] == 'run'
assert data_with_secrets_replaced['message'] == 'Running command: echo hello'
# But non-protected fields should have secrets replaced
assert '<secret_hidden>' in data_with_secrets_replaced['content']
assert '123' not in data_with_secrets_replaced['content']
assert 'user' not in data_with_secrets_replaced['content']
# Note: 'run' should still be replaced in content since it's not a protected field
def test_nested_dict_secret_replacement(temp_dir: str):
"""Test that secrets are replaced in nested dictionaries while preserving protected fields."""
file_store = get_file_store('local', temp_dir)
stream = EventStream('test_session', file_store)
stream.set_secrets({'secret': 'password123'})
# Create nested data structure
data = {
'timestamp': '2025-07-18T17:01:36.799608',
'args': {
'command': 'login --password password123',
'env': {
'SECRET_KEY': 'password123',
'timestamp': 'password123_timestamp', # This should be replaced since it's not top-level
},
},
}
data_with_secrets_replaced = stream._replace_secrets(data)
# Top-level timestamp should be protected
assert data_with_secrets_replaced['timestamp'] == '2025-07-18T17:01:36.799608'
# Nested secrets should be replaced
assert '<secret_hidden>' in data_with_secrets_replaced['args']['command']
assert data_with_secrets_replaced['args']['env']['SECRET_KEY'] == '<secret_hidden>'
assert '<secret_hidden>' in data_with_secrets_replaced['args']['env']['timestamp']
# Original secret should not appear in nested content
assert 'password123' not in data_with_secrets_replaced['args']['command']
assert 'password123' not in data_with_secrets_replaced['args']['env']['SECRET_KEY']
assert 'password123' not in data_with_secrets_replaced['args']['env']['timestamp']

View File

@@ -112,9 +112,9 @@ async def test_github_get_repositories_with_user_owner_type():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -151,9 +151,9 @@ async def test_github_get_repositories_with_organization_owner_type():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -190,9 +190,9 @@ async def test_github_get_repositories_mixed_owner_types():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -237,9 +237,9 @@ async def test_github_get_repositories_owner_type_fallback():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify all repositories default to USER owner_type
for repo in repositories:

View File

@@ -37,7 +37,7 @@ async def test_gitlab_get_repositories_with_user_owner_type():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -76,7 +76,7 @@ async def test_gitlab_get_repositories_with_organization_owner_type():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -115,7 +115,7 @@ async def test_gitlab_get_repositories_mixed_owner_types():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -162,7 +162,7 @@ async def test_gitlab_get_repositories_owner_type_fallback():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify all repositories default to USER owner_type
for repo in repositories: