mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
implement-
...
fix-termin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5444193d9b | ||
|
|
16a508c15d |
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,6 +13,7 @@ function Terminal() {
|
||||
|
||||
const ref = useTerminal({
|
||||
commands,
|
||||
readOnly: true, // Make terminal read-only
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アクションは実行されていません。これはユーザーが停止ボタンを押したか、リソース制約によりランタイムシステムがクラッシュして再起動したことが原因かもしれません。以前に確立されたシステム状態、依存関係、または環境変数は失われている可能性があります。",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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"""
|
||||
...
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user