Compare commits

..

2 Commits

Author SHA1 Message Date
openhands 80f1da66f1 Improve git changes fix with better error handling and reduced cache time
- Fix _get_default_branch() to handle repositories without remote origin
- Reduce frontend cache stale time from 5 minutes to 30 seconds for fresher data
- Add fallback to 'main' branch when remote origin is not available
- Improve robustness for edge cases like local-only repositories
2025-07-22 15:43:14 +00:00
openhands 50cd559cd4 Fix git changes showing merged files as user changes
This commit fixes an issue where the changes tab would show all merged files
from the main branch as if they were user-created changes after performing
a merge operation.

The problem occurred when:
1. User creates a branch that is NOT up-to-date with main
2. User asks OpenHands to fetch and merge latest changes from main
3. The changes tab would show all merged files as 'Added' changes
4. Only a refresh would fix the display

Root cause:
The _get_valid_ref() method was always preferring the remote tracking branch
(origin/feature-branch) as the comparison base. After a merge, this remote
branch still pointed to the old commit before the merge, causing git diff
to show all merged changes as if they were new user changes.

Solution:
- Added _has_diverged_from_remote_tracking_branch() method to detect when
  the local branch has diverged from its remote tracking branch due to merges
- Modified _get_valid_ref() to use merge-base with the default branch when
  divergence is detected, preventing merged changes from appearing as user changes
- Added comprehensive tests to verify the fix works correctly

The fix ensures that only actual user changes are shown in the changes tab,
while merged changes from the main branch are properly excluded.
2025-07-22 15:16:48 +00:00
56 changed files with 559 additions and 4382 deletions
+21 -100
View File
@@ -1,135 +1,56 @@
# Run evaluation on a PR, after releases, or manually
# Run evaluation on a PR
name: Run Eval
# Runs when a PR is labeled with one of the "run-eval-" labels, after releases, or manually triggered
# Runs when a PR is labeled with one of the "run-eval-" labels
on:
pull_request:
types: [labeled]
release:
types: [published]
workflow_dispatch:
inputs:
branch:
description: 'Branch to evaluate'
required: true
default: 'main'
eval_instances:
description: 'Number of evaluation instances'
required: true
default: '50'
type: choice
options:
- '1'
- '2'
- '50'
- '100'
reason:
description: 'Reason for manual trigger'
required: false
default: ''
env:
# Environment variable for the master GitHub issue number where all evaluation results will be commented
# This should be set to the issue number where you want all evaluation results to be posted
MASTER_EVAL_ISSUE_NUMBER: ${{ vars.MASTER_EVAL_ISSUE_NUMBER || '0' }}
jobs:
trigger-job:
name: Trigger remote eval job
if: ${{ (github.event_name == 'pull_request' && (github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100')) || github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
if: ${{ github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100' }}
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout branch
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.head_ref || (github.event_name == 'workflow_dispatch' && github.event.inputs.branch) || github.ref }}
ref: ${{ github.head_ref }}
- name: Set evaluation parameters
id: eval_params
- name: Trigger remote job
env:
PR_BRANCH: ${{ github.head_ref }}
run: |
REPO_URL="https://github.com/${{ github.repository }}"
echo "Repository URL: $REPO_URL"
echo "PR Branch: $PR_BRANCH"
# Determine branch based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
EVAL_BRANCH="${{ github.head_ref }}"
echo "PR Branch: $EVAL_BRANCH"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_BRANCH="${{ github.event.inputs.branch }}"
echo "Manual Branch: $EVAL_BRANCH"
else
# For release events, use the tag name or main branch
EVAL_BRANCH="${{ github.ref_name }}"
echo "Release Branch/Tag: $EVAL_BRANCH"
fi
# Determine evaluation instances based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_INSTANCES="${{ github.event.inputs.eval_instances }}"
else
# For release events, default to 50 instances
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
fi
echo "Evaluation instances: $EVAL_INSTANCES"
echo "repo_url=$REPO_URL" >> $GITHUB_OUTPUT
echo "eval_branch=$EVAL_BRANCH" >> $GITHUB_OUTPUT
echo "eval_instances=$EVAL_INSTANCES" >> $GITHUB_OUTPUT
- name: Trigger remote job
run: |
# Determine PR number for the remote evaluation system
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
else
# For non-PR triggers, use the master issue number as PR number
PR_NUMBER="${{ env.MASTER_EVAL_ISSUE_NUMBER }}"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
curl -X POST \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${{ steps.eval_params.outputs.repo_url }}\", \"github-branch\": \"${{ steps.eval_params.outputs.eval_branch }}\", \"pr-number\": \"${PR_NUMBER}\", \"eval-instances\": \"${{ steps.eval_params.outputs.eval_instances }}\"}}" \
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${REPO_URL}\", \"github-branch\": \"${PR_BRANCH}\", \"pr-number\": \"${{ github.event.pull_request.number }}\", \"eval-instances\": \"${EVAL_INSTANCES}\"}}" \
https://api.github.com/repos/All-Hands-AI/evaluation/actions/workflows/create-branch.yml/dispatches
# Send Slack message
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
slack_text="PR $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
elif [[ "${{ github.event_name }}" == "release" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}"
slack_text="Release $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
else
TRIGGER_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
slack_text="Manual trigger (${{ github.event.inputs.reason || 'No reason provided' }}) has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances for branch ${{ steps.eval_params.outputs.eval_branch }}..."
fi
PR_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
slack_text="PR $PR_URL has triggered evaluation on $EVAL_INSTANCES instances..."
curl -X POST -H 'Content-type: application/json' --data '{"text":"'"$slack_text"'"}' \
https://hooks.slack.com/services/${{ secrets.SLACK_TOKEN }}
- name: Comment on issue/PR
- name: Comment on PR
uses: KeisukeYamashita/create-comment@v1
with:
# For PR triggers, comment on the PR. For other triggers, comment on the master issue
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || env.MASTER_EVAL_ISSUE_NUMBER }}
unique: false
comment: |
**Evaluation Triggered**
**Trigger:** ${{ github.event_name == 'pull_request' && format('Pull Request #{0}', github.event.pull_request.number) || (github.event_name == 'release' && 'Release') || format('Manual Trigger: {0}', github.event.inputs.reason || 'No reason provided') }}
**Branch:** ${{ steps.eval_params.outputs.eval_branch }}
**Instances:** ${{ steps.eval_params.outputs.eval_instances }}
**Commit:** ${{ github.sha }}
Running evaluation on the specified branch. Once eval is done, the results will be posted here.
Running evaluation on the PR. Once eval is done, the results will be posted.
+10 -13
View File
@@ -1,32 +1,29 @@
#!/bin/bash
echo "Running OpenHands pre-commit hook..."
echo "This hook runs 'make lint' to ensure code quality before committing."
# Store the exit code to return at the end
# This allows us to be additive to existing pre-commit hooks
EXIT_CODE=0
# Run make lint to check both frontend and backend code
echo "Running linting checks with 'make lint'..."
make lint
if [ $? -ne 0 ]; then
echo "Linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Linting checks passed!"
fi
# Check if frontend directory has changed
frontend_changes=$(git diff --cached --name-only | grep "^frontend/")
if [ -n "$frontend_changes" ]; then
echo "Frontend changes detected. Running additional frontend checks..."
echo "Frontend changes detected. Running frontend checks..."
# Check if frontend directory exists
if [ -d "frontend" ]; then
# Change to frontend directory
cd frontend || exit 1
# Run lint:fix
echo "Running npm lint:fix..."
npm run lint:fix
if [ $? -ne 0 ]; then
echo "Frontend linting failed. Please fix the issues before committing."
EXIT_CODE=1
fi
# Run build
echo "Running npm build..."
npm run build
@@ -53,7 +50,7 @@ if [ -n "$frontend_changes" ]; then
echo "Frontend directory not found. Skipping frontend checks."
fi
else
echo "No frontend changes detected. Skipping additional frontend checks."
echo "No frontend changes detected. Skipping frontend checks."
fi
# Run any existing pre-commit hooks that might have been installed by the user
+4 -4
View File
@@ -68,7 +68,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.1.0",
"@types/node": "^24.0.15",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -6160,9 +6160,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"version": "24.0.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.15.tgz",
"integrity": "sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA==",
"devOptional": true,
"dependencies": {
"undici-types": "~7.8.0"
+1 -1
View File
@@ -92,7 +92,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.1.0",
"@types/node": "^24.0.15",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
-25
View File
@@ -13,7 +13,6 @@ import {
GitChange,
GetMicroagentsResponse,
GetMicroagentPromptResponse,
CreateMicroagent,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
@@ -252,28 +251,6 @@ class OpenHands {
return data.results;
}
static async searchConversations(
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
): Promise<Conversation[]> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
if (selectedRepository) {
params.append("selected_repository", selectedRepository);
}
if (conversationTrigger) {
params.append("conversation_trigger", conversationTrigger);
}
const { data } = await openHands.get<ResultSet<Conversation>>(
`/api/conversations?${params.toString()}`,
);
return data.results;
}
static async deleteUserConversation(conversationId: string): Promise<void> {
await openHands.delete(`/api/conversations/${conversationId}`);
}
@@ -285,7 +262,6 @@ class OpenHands {
suggested_task?: SuggestedTask,
selected_branch?: string,
conversationInstructions?: string,
createMicroagent?: CreateMicroagent,
): Promise<Conversation> {
const body = {
repository: selectedRepository,
@@ -294,7 +270,6 @@ class OpenHands {
initial_user_msg: initialUserMsg,
suggested_task,
conversation_instructions: conversationInstructions,
create_microagent: createMicroagent,
};
const { data } = await openHands.post<Conversation>(
+1 -12
View File
@@ -79,11 +79,7 @@ export interface RepositorySelection {
git_provider: Provider | null;
}
export type ConversationTrigger =
| "resolver"
| "gui"
| "suggested_task"
| "microagent_management";
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
export interface Conversation {
conversation_id: string;
@@ -98,7 +94,6 @@ export interface Conversation {
trigger?: ConversationTrigger;
url: string | null;
session_api_key: string | null;
pr_number?: number[] | null;
}
export interface ResultSet<T> {
@@ -138,9 +133,3 @@ export interface GetMicroagentPromptResponse {
status: string;
prompt: string;
}
export interface CreateMicroagent {
repo: string;
git_provider?: Provider;
title?: string;
}
@@ -1,8 +1,7 @@
import React, { ReactNode } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
export interface BranchDropdownProps {
items: { key: React.Key; label: string }[];
@@ -10,8 +9,6 @@ export interface BranchDropdownProps {
onInputChange: (value: string) => void;
isDisabled: boolean;
selectedKey?: string;
wrapperClassName?: string;
label?: ReactNode;
}
export function BranchDropdown({
@@ -20,8 +17,6 @@ export function BranchDropdown({
onInputChange,
isDisabled,
selectedKey,
wrapperClassName,
label,
}: BranchDropdownProps) {
const { t } = useTranslation();
@@ -31,12 +26,11 @@ export function BranchDropdown({
name="branch-dropdown"
placeholder={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
items={items}
wrapperClassName={cn("max-w-[500px]", wrapperClassName)}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isDisabled={isDisabled}
selectedKey={selectedKey}
label={label}
/>
);
}
@@ -1,19 +1,12 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
interface BranchErrorStateProps {
wrapperClassName?: string;
}
export function BranchErrorState({ wrapperClassName }: BranchErrorStateProps) {
export function BranchErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-error"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500",
wrapperClassName,
)}
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500"
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_BRANCHES")}</span>
</div>
@@ -1,22 +1,13 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { cn } from "#/utils/utils";
interface BranchLoadingStateProps {
wrapperClassName?: string;
}
export function BranchLoadingState({
wrapperClassName,
}: BranchLoadingStateProps) {
export function BranchLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-loading"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm",
wrapperClassName,
)}
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm"
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_BRANCHES")}</span>
@@ -20,7 +20,7 @@ export function MicroagentManagementAccordionTitle({
{repository.full_name}
</div>
</div>
<MicroagentManagementAddMicroagentButton repository={repository} />
<MicroagentManagementAddMicroagentButton />
</div>
);
}
@@ -1,20 +1,10 @@
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { I18nKey } from "#/i18n/declaration";
import {
setAddMicroagentModalVisible,
setSelectedRepository,
} from "#/state/microagent-management-slice";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
interface MicroagentManagementAddMicroagentButtonProps {
repository: GitRepository;
}
export function MicroagentManagementAddMicroagentButton({
repository,
}: MicroagentManagementAddMicroagentButtonProps) {
export function MicroagentManagementAddMicroagentButton() {
const { t } = useTranslation();
const { addMicroagentModalVisible } = useSelector(
@@ -26,7 +16,6 @@ export function MicroagentManagementAddMicroagentButton({
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible));
dispatch(setSelectedRepository(repository));
};
return (
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { FaCircleInfo } from "react-icons/fa6";
@@ -10,155 +10,30 @@ import { RootState } from "#/store";
import XIcon from "#/icons/x.svg?react";
import { cn } from "#/utils/utils";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { MicroagentFormData } from "#/types/microagent-management";
import { Branch, GitRepository } from "#/types/git";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import {
BranchDropdown,
BranchLoadingState,
BranchErrorState,
} from "../home/repository-selection";
interface MicroagentManagementAddMicroagentModalProps {
onConfirm: (formData: MicroagentFormData) => void;
onConfirm: () => void;
onCancel: () => void;
isLoading: boolean;
}
export function MicroagentManagementAddMicroagentModal({
onConfirm,
onCancel,
isLoading = false,
}: MicroagentManagementAddMicroagentModalProps) {
const { t } = useTranslation();
const [triggers, setTriggers] = useState<string[]>([]);
const [query, setQuery] = useState<string>("");
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = useRef<boolean>(false);
const {
data: branches,
isLoading: isLoadingBranches,
isError: isBranchesError,
} = useRepositoryBranches(selectedRepository?.full_name || null);
const branchesItems = branches?.map((branch) => ({
key: branch.name,
label: branch.name,
}));
// Auto-select main or master branch if it exists.
useEffect(() => {
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches
) {
// Look for main or master branch
const mainBranch = branches.find((branch) => branch.name === "main");
const masterBranch = branches.find((branch) => branch.name === "master");
// Select main if it exists, otherwise select master if it exists
if (mainBranch) {
setSelectedBranch(mainBranch);
} else if (masterBranch) {
setSelectedBranch(masterBranch);
}
}
}, [branches, isLoadingBranches, selectedBranch]);
const modalTitle = selectedRepository
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${(selectedRepository as GitRepository).full_name}`
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${selectedRepository}`
: t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT);
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
const handleConfirm = () => {
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
// Reset the manually cleared flag when a branch is explicitly selected
branchManuallyClearedRef.current = false;
};
const handleBranchInputChange = (value: string) => {
// Clear the selected branch if the input is empty or contains only whitespace
// This fixes the issue where users can't delete the entire default branch name
if (value === "" || value.trim() === "") {
setSelectedBranch(null);
// Set the flag to indicate that the branch was manually cleared
branchManuallyClearedRef.current = true;
} else {
// Reset the flag when the user starts typing again
branchManuallyClearedRef.current = false;
}
};
// Render the appropriate UI for branch selector based on the loading/error state
const renderBranchSelector = () => {
if (!selectedRepository) {
return (
<BranchDropdown
items={[]}
onSelectionChange={() => {}}
onInputChange={() => {}}
isDisabled
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
}
if (isLoadingBranches) {
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
}
if (isBranchesError) {
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
}
return (
<BranchDropdown
items={branchesItems || []}
onSelectionChange={handleBranchSelection}
onInputChange={handleBranchInputChange}
isDisabled={false}
selectedKey={selectedBranch?.name}
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
};
return (
@@ -189,7 +64,6 @@ export function MicroagentManagementAddMicroagentModal({
onSubmit={onSubmit}
className="flex flex-col gap-6 w-full"
>
{renderBranchSelector()}
<label
htmlFor="query-input"
className="flex flex-col gap-2 w-full text-sm font-normal"
@@ -199,8 +73,6 @@ export function MicroagentManagementAddMicroagentModal({
required
data-testid="query-input"
name="query-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(I18nKey.MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO)}
rows={6}
className={cn(
@@ -208,6 +80,19 @@ export function MicroagentManagementAddMicroagentModal({
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
<div className="flex items-center gap-2 text-[11px] font-normal text-white leading-[16px]">
<span className="font-semibold">
{t(I18nKey.COMMON$FOR_EXAMPLE)}:
</span>
<span className="underline">
{t(I18nKey.COMMON$TEST_DB_MIGRATION)}
</span>
<span className="underline">{t(I18nKey.COMMON$RUN_TEST)}</span>
<span className="underline">{t(I18nKey.COMMON$RUN_APP)}</span>
<span className="underline">
{t(I18nKey.COMMON$LEARN_FILE_STRUCTURE)}
</span>
</div>
</label>
<label
htmlFor="trigger-input"
@@ -244,26 +129,17 @@ export function MicroagentManagementAddMicroagentModal({
type="button"
variant="secondary"
onClick={onCancel}
testId="cancel-button"
data-testid="cancel-button"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<BrandButton
type="button"
variant="primary"
onClick={handleConfirm}
testId="confirm-button"
isDisabled={
!query.trim() ||
isLoading ||
isLoadingBranches ||
!selectedBranch ||
isBranchesError
}
onClick={onConfirm}
data-testid="confirm-button"
>
{isLoading || isLoadingBranches
? t(I18nKey.HOME$LOADING)
: t(I18nKey.MICROAGENT$LAUNCH)}
{t(I18nKey.MICROAGENT$LAUNCH)}
</BrandButton>
</div>
</ModalBody>
@@ -1,230 +0,0 @@
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementSidebar } from "./microagent-management-sidebar";
import { MicroagentManagementMain } from "./microagent-management-main";
import { MicroagentManagementAddMicroagentModal } from "./microagent-management-add-microagent-modal";
import { RootState } from "#/store";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
import { MicroagentFormData } from "#/types/microagent-management";
import { AgentState } from "#/types/agent-state";
import { getPR, getProviderName, getPRShort } from "#/utils/utils";
import {
isOpenHandsEvent,
isAgentStateChangeObservation,
isFinishAction,
} from "#/types/core/guards";
import { GitRepository } from "#/types/git";
import { queryClient } from "#/query-client-config";
import { Provider } from "#/types/settings";
// Handle error events
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
const shouldInvalidateConversationsList = (currentSocketEvent: unknown) => {
const hasError =
isErrorEvent(currentSocketEvent) || isAgentStatusError(currentSocketEvent);
const hasStateChanged =
isOpenHandsEvent(currentSocketEvent) &&
isAgentStateChangeObservation(currentSocketEvent);
const hasFinished =
isOpenHandsEvent(currentSocketEvent) && isFinishAction(currentSocketEvent);
return hasError || hasStateChanged || hasFinished;
};
const getConversationInstructions = (
repositoryName: string,
formData: MicroagentFormData,
pr: string,
prShort: string,
gitProvider: Provider,
) => `Create a microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered).
- Step 2: Update the markdown file with the content below:
${
formData.triggers &&
formData.triggers.length > 0 &&
`
---
triggers:
${formData.triggers.map((trigger: string) => ` - ${trigger}`).join("\n")}
---
`
}
${formData.query}
- Step 3: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
- Step 4: Please push the changes to your branch on ${getProviderName(gitProvider)} and create a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.
`;
export function MicroagentManagementContent() {
// Responsive width state
const [width, setWidth] = useState(window.innerWidth);
const { addMicroagentModalVisible, selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
function handleResize() {
setWidth(window.innerWidth);
}
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const hideAddMicroagentModal = () => {
dispatch(setAddMicroagentModalVisible(false));
};
// Reusable function to invalidate conversations list for a repository
const invalidateConversationsList = React.useCallback(
(repositoryName: string) => {
queryClient.invalidateQueries({
queryKey: [
"conversations",
"search",
repositoryName,
"microagent_management",
],
});
},
[],
);
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown) => {
// Get repository name from selectedRepository for invalidation
const repositoryName =
selectedRepository && typeof selectedRepository === "object"
? (selectedRepository as GitRepository).full_name
: "";
if (shouldInvalidateConversationsList(socketEvent)) {
invalidateConversationsList(repositoryName);
}
},
[invalidateConversationsList, selectedRepository],
);
const handleCreateMicroagent = (formData: MicroagentFormData) => {
if (!selectedRepository || typeof selectedRepository !== "object") {
return;
}
// Use the GitRepository properties
const repository = selectedRepository as GitRepository;
const repositoryName = repository.full_name;
const gitProvider = repository.git_provider;
const isGitLab = gitProvider === "gitlab";
const pr = getPR(isGitLab);
const prShort = getPRShort(isGitLab);
// Create conversation instructions for microagent generation
const conversationInstructions = getConversationInstructions(
repositoryName,
formData,
pr,
prShort,
gitProvider,
);
// Create the CreateMicroagent object
const createMicroagent = {
repo: repositoryName,
git_provider: gitProvider,
title: formData.query,
};
createConversationAndSubscribe({
query: conversationInstructions,
conversationInstructions,
repository: {
name: repositoryName,
branch: formData.selectedBranch,
gitProvider,
},
createMicroagent,
onSuccessCallback: () => {
hideAddMicroagentModal();
// Invalidate conversations list to fetch the latest conversations for this repository
invalidateConversationsList(repositoryName);
// Also invalidate microagents list to fetch the latest microagents
// Extract owner and repo from full_name (format: "owner/repo")
const [owner, repo] = repositoryName.split("/");
queryClient.invalidateQueries({
queryKey: ["repository-microagents", owner, repo],
});
hideAddMicroagentModal();
},
onEventCallback: (event: unknown) => {
// Handle conversation events for real-time status updates
handleMicroagentEvent(event);
},
});
};
if (width < 1024) {
return (
<div className="w-full h-full flex flex-col gap-6">
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] max-h-[494px] min-h-[494px]">
<MicroagentManagementSidebar isSmallerScreen />
</div>
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] flex-1 min-h-[494px]">
<MicroagentManagementMain />
</div>
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={handleCreateMicroagent}
onCancel={hideAddMicroagentModal}
isLoading={isPending}
/>
)}
</div>
);
}
return (
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E] overflow-hidden">
<MicroagentManagementSidebar />
<div className="flex-1">
<MicroagentManagementMain />
</div>
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={handleCreateMicroagent}
onCancel={hideAddMicroagentModal}
isLoading={isPending}
/>
)}
</div>
);
}
@@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementConversationStopped() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}
@@ -1,19 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementDefault() {
const { t } = useTranslation();
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
</div>
<div className="text-white text-sm font-normal text-center max-w-[455px]">
{t(
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
)}
</div>
</div>
);
}
@@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementError() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$ERROR)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}
@@ -1,52 +1,29 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RootState } from "#/store";
import { MicroagentManagementDefault } from "./microagent-management-default";
import { MicroagentManagementOpeningPr } from "./microagent-management-opening-pr";
import { MicroagentManagementReviewPr } from "./microagent-management-review-pr";
import { MicroagentManagementViewMicroagent } from "./microagent-management-view-microagent";
import { MicroagentManagementError } from "./microagent-management-error";
import { MicroagentManagementConversationStopped } from "./microagent-management-conversation-stopped";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementMain() {
const { selectedMicroagentItem } = useSelector(
const { t } = useTranslation();
const { selectedMicroagent } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent, conversation } = selectedMicroagentItem ?? {};
if (microagent) {
return <MicroagentManagementViewMicroagent />;
if (!selectedMicroagent) {
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
</div>
<div className="text-white text-sm font-normal text-center max-w-[455px]">
{t(
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
)}
</div>
</div>
);
}
if (conversation) {
if (conversation.pr_number && conversation.pr_number.length > 0) {
return <MicroagentManagementReviewPr />;
}
const isConversationStarting =
conversation.status === "STARTING" ||
conversation.runtime_status === "STATUS$STARTING_RUNTIME";
const isConversationOpeningPr =
conversation.status === "RUNNING" &&
conversation.runtime_status === "STATUS$READY";
if (isConversationStarting || isConversationOpeningPr) {
return <MicroagentManagementOpeningPr />;
}
if (conversation.runtime_status === "STATUS$ERROR") {
return <MicroagentManagementError />;
}
if (
conversation.status === "STOPPED" ||
conversation.runtime_status === "STATUS$STOPPED"
) {
return <MicroagentManagementConversationStopped />;
}
return <MicroagentManagementDefault />;
}
return <MicroagentManagementDefault />;
return null;
}
@@ -1,142 +1,34 @@
import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
import {
setSelectedMicroagentItem,
setSelectedRepository,
} from "#/state/microagent-management-slice";
import { RootState } from "#/store";
import { cn } from "#/utils/utils";
import { GitRepository } from "#/types/git";
interface MicroagentManagementMicroagentCardProps {
microagent?: RepositoryMicroagent;
conversation?: Conversation;
repository: GitRepository;
microagent: {
id: string;
name: string;
createdAt: string;
};
}
export function MicroagentManagementMicroagentCard({
microagent,
conversation,
repository,
}: MicroagentManagementMicroagentCardProps) {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const {
status: conversationStatus,
runtime_status: runtimeStatus,
pr_number: prNumber,
} = conversation ?? {};
// Format the repository URL to point to the microagent file
const microagentFilePath = microagent
? `.openhands/microagents/${microagent.name}`
: "";
const microagentFilePath = `.openhands/microagents/${microagent.name}`;
// Format the createdAt date using MM/DD/YYYY format
const formattedCreatedAt = useMemo(() => {
if (microagent) {
return formatDateMMDDYYYY(new Date(microagent.created_at));
}
if (conversation) {
return formatDateMMDDYYYY(new Date(conversation.created_at));
}
return "";
}, [microagent, conversation]);
const hasPr = !!(prNumber && prNumber.length > 0);
// Helper function to get status text
const statusText = useMemo(() => {
if (hasPr) {
return t(I18nKey.COMMON$READY_FOR_REVIEW);
}
if (
conversationStatus === "STARTING" ||
runtimeStatus === "STATUS$STARTING_RUNTIME"
) {
return t(I18nKey.COMMON$STARTING);
}
if (
conversationStatus === "STOPPED" ||
runtimeStatus === "STATUS$STOPPED"
) {
return t(I18nKey.COMMON$STOPPED);
}
if (runtimeStatus === "STATUS$ERROR") {
return t(I18nKey.MICROAGENT$STATUS_ERROR);
}
if (conversationStatus === "RUNNING" && runtimeStatus === "STATUS$READY") {
return t(I18nKey.MICROAGENT$STATUS_OPENING_PR);
}
return "";
}, [conversationStatus, runtimeStatus, t, hasPr]);
const cardTitle = microagent?.name ?? conversation?.title;
const isCardSelected = useMemo(() => {
if (microagent && selectedMicroagentItem?.microagent) {
return selectedMicroagentItem.microagent.name === microagent.name;
}
if (conversation && selectedMicroagentItem?.conversation) {
return (
selectedMicroagentItem.conversation.conversation_id ===
conversation.conversation_id
);
}
return false;
}, [microagent, conversation, selectedMicroagentItem]);
const onMicroagentCardClicked = () => {
dispatch(
setSelectedMicroagentItem(
microagent
? {
microagent,
conversation: null,
}
: {
microagent: null,
conversation,
},
),
);
dispatch(setSelectedRepository(repository));
};
const formattedCreatedAt = formatDateMMDDYYYY(new Date(microagent.createdAt));
return (
<div
className={cn(
"rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300",
isCardSelected && "bg-[#ffffff33] border-[#C9B974]",
)}
onClick={onMicroagentCardClicked}
>
<div className="flex flex-col items-start gap-2">
{statusText && (
<div className="px-[6px] py-[2px] text-[11px] font-medium bg-[#C9B97433] text-white rounded-2xl">
{statusText}
</div>
)}
<div className="text-white text-[16px] font-semibold">{cardTitle}</div>
{!!microagent && (
<div className="text-white text-sm font-normal">
{microagentFilePath}
</div>
)}
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
</div>
<div className="rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300">
<div className="text-white text-[16px] font-semibold">
{microagent.name}
</div>
<div className="text-white text-sm font-normal">{microagentFilePath}</div>
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
</div>
</div>
);
@@ -1,47 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementOpeningPr() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-semibold pb-2">
{t(I18nKey.COMMON$WORKING_ON_IT)}!
</div>
<div className="text-[#ffffff99] text-[18px] font-normal text-center max-w-[518px] pb-[22px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}
@@ -1,74 +1,30 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { GitRepository } from "#/types/git";
import { getGitProviderBaseUrl } from "#/utils/utils";
import { RootState } from "#/store";
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
export interface RepoMicroagent {
id: string;
repositoryName: string;
repositoryUrl: string;
}
interface MicroagentManagementRepoMicroagentsProps {
repository: GitRepository;
repoMicroagent: RepoMicroagent;
}
export function MicroagentManagementRepoMicroagents({
repository,
repoMicroagent,
}: MicroagentManagementRepoMicroagentsProps) {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const { full_name: repositoryName, git_provider: gitProvider } = repository;
// Extract owner and repo from repositoryName (format: "owner/repo")
const [owner, repo] = repositoryName.split("/");
const repositoryUrl = `${getGitProviderBaseUrl(gitProvider)}/${repositoryName}`;
const [owner, repo] = repoMicroagent.repositoryName.split("/");
const {
data: microagents,
isLoading: isLoadingMicroagents,
isError: isErrorMicroagents,
isLoading,
isError,
} = useRepositoryMicroagents(owner, repo);
const {
data: conversations,
isLoading: isLoadingConversations,
isError: isErrorConversations,
} = useSearchConversations(repositoryName, "microagent_management", 1000);
useEffect(() => {
const hasConversations = conversations && conversations.length > 0;
const selectedConversation = selectedMicroagentItem?.conversation;
if (hasConversations && selectedConversation) {
// get the latest selected conversation.
const latestSelectedConversation = conversations.find(
(conversation) =>
conversation.conversation_id === selectedConversation.conversation_id,
);
if (latestSelectedConversation) {
dispatch(
setSelectedMicroagentItem({
microagent: null,
conversation: latestSelectedConversation,
}),
);
}
}
}, [conversations]);
// Show loading only when both queries are loading
const isLoading = isLoadingMicroagents || isLoadingConversations;
// Show error UI.
const isError = isErrorMicroagents || isErrorConversations;
if (isLoading) {
return (
<div className="pb-4 flex justify-center">
@@ -77,43 +33,34 @@ export function MicroagentManagementRepoMicroagents({
);
}
// If there's an error with microagents, show the learn this repo component
if (isError) {
return (
<div className="pb-4">
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
</div>
);
}
const numberOfMicroagents = microagents?.length || 0;
const numberOfConversations = conversations?.length || 0;
const totalItems = numberOfMicroagents + numberOfConversations;
return (
<div className="pb-4">
{totalItems === 0 && (
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
{numberOfMicroagents === 0 && (
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
)}
{/* Render microagents */}
{numberOfMicroagents > 0 &&
microagents?.map((microagent) => (
<div key={microagent.name} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
microagent={microagent}
repository={repository}
/>
</div>
))}
{/* Render conversations */}
{numberOfConversations > 0 &&
conversations?.map((conversation) => (
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
conversation={conversation}
repository={repository}
microagent={{
id: microagent.name,
name: microagent.name,
createdAt: microagent.created_at,
}}
/>
</div>
))}
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { Accordion, AccordionItem } from "@heroui/react";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { GitRepository } from "#/types/git";
import { cn } from "#/utils/utils";
import { getGitProviderBaseUrl, cn } from "#/utils/utils";
import { TabType } from "#/types/microagent-management";
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
import { I18nKey } from "#/i18n/declaration";
@@ -110,7 +110,13 @@ export function MicroagentManagementRepositories({
<MicroagentManagementAccordionTitle repository={repository} />
}
>
<MicroagentManagementRepoMicroagents repository={repository} />
<MicroagentManagementRepoMicroagents
repoMicroagent={{
id: repository.id,
repositoryName: repository.full_name,
repositoryUrl: `${getGitProviderBaseUrl(repository.git_provider)}/${repository.full_name}`,
}}
/>
</AccordionItem>
))}
</Accordion>
@@ -1,74 +0,0 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { getProviderName, constructPullRequestUrl } from "#/utils/utils";
import { Provider } from "#/types/settings";
import { RootState } from "#/store";
export function MicroagentManagementReviewPr() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const {
conversation_id: conversationId,
selected_repository: selectedRepository,
git_provider: gitProvider,
pr_number: prNumber,
} = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY)}
</div>
<div className="flex gap-[22px]">
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
<a
href={
selectedRepository && gitProvider && prNumber && prNumber.length > 0
? constructPullRequestUrl(
prNumber[0],
gitProvider,
selectedRepository,
)
: "/#"
}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="primary"
testId="view-conversation-button"
>
{`${t(I18nKey.COMMON$REVIEW_PR_IN)} ${getProviderName(
gitProvider as Provider,
)}`}
</BrandButton>
</a>
</div>
</div>
);
}
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import QuestionCircleIcon from "#/icons/question-circle.svg?react";
import { DOCUMENTATION_URL } from "#/utils/constants";
export function MicroagentManagementSidebarHeader() {
const { t } = useTranslation();
@@ -13,13 +12,7 @@ export function MicroagentManagementSidebarHeader() {
</h1>
<p className="text-white text-sm font-normal leading-[20px] pt-2">
{t(I18nKey.MICROAGENT_MANAGEMENT$USE_MICROAGENTS)}
<a
href={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
target="_blank"
rel="noopener noreferrer"
>
<QuestionCircleIcon className="inline-block ml-1" />
</a>
<QuestionCircleIcon className="inline-block ml-1" />
</p>
</div>
);
@@ -11,15 +11,8 @@ import {
} from "#/state/microagent-management-slice";
import { GitRepository } from "#/types/git";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { cn } from "#/utils/utils";
interface MicroagentManagementSidebarProps {
isSmallerScreen?: boolean;
}
export function MicroagentManagementSidebar({
isSmallerScreen = false,
}: MicroagentManagementSidebarProps) {
export function MicroagentManagementSidebar() {
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: repositories, isLoading } = useUserRepositories();
@@ -49,12 +42,7 @@ export function MicroagentManagementSidebar({
}, [repositories, dispatch]);
return (
<div
className={cn(
"w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col",
isSmallerScreen && "w-full border-none",
)}
>
<div className="w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col">
<MicroagentManagementSidebarHeader />
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 flex-1">
@@ -1,73 +0,0 @@
import { useSelector } from "react-redux";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { code } from "../markdown/code";
import { ul, ol } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
import { anchor } from "../markdown/anchor";
import { RootState } from "#/store";
export function MicroagentManagementViewMicroagentContent() {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
const transformMicroagentContent = (): string => {
if (!microagent) {
return "";
}
// If no triggers exist, return the content as-is
if (!microagent.triggers || microagent.triggers.length === 0) {
return microagent.content;
}
// Create the triggers frontmatter
const triggersFrontmatter = `
---
triggers:
${microagent.triggers.map((trigger) => ` - ${trigger}`).join("\n")}
---
`;
// Prepend the frontmatter to the content
return `
${triggersFrontmatter}
${microagent.content}
`;
};
if (!microagent || !selectedRepository) {
return null;
}
// Transform the content to include triggers frontmatter if applicable
const transformedContent = transformMicroagentContent();
return (
<div className="w-full h-full p-6 bg-[#ffffff1a] rounded-2xl text-white text-sm">
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{transformedContent}
</Markdown>
</div>
);
}
@@ -1,60 +0,0 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RootState } from "#/store";
import { BrandButton } from "../settings/brand-button";
import { getProviderName, constructMicroagentUrl } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementViewMicroagentHeader() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
if (!microagent || !selectedRepository) {
return null;
}
// Construct the microagent URL
const microagentUrl = constructMicroagentUrl(
selectedRepository.git_provider,
selectedRepository.full_name,
microagent.path,
);
return (
<div className="flex items-center justify-between pb-2">
<span className="text-sm text-[#ffffff99]">
{selectedRepository.full_name}
</span>
<div className="flex items-center justify-end gap-2">
<a href={microagentUrl} target="_blank" rel="noopener noreferrer">
<BrandButton
type="button"
variant="secondary"
testId="edit-in-git-button"
className="py-1 px-2"
>
{`${t(I18nKey.COMMON$EDIT_IN)} ${getProviderName(selectedRepository.git_provider)}`}
</BrandButton>
</a>
<BrandButton
type="button"
variant="primary"
onClick={() => {}}
testId="learn-button"
className="py-1 px-2"
>
{t(I18nKey.COMMON$LEARN)}
</BrandButton>
</div>
</div>
);
}
@@ -1,35 +0,0 @@
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { MicroagentManagementViewMicroagentHeader } from "./microagent-management-view-microagent-header";
import { MicroagentManagementViewMicroagentContent } from "./microagent-management-view-microagent-content";
export function MicroagentManagementViewMicroagent() {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
if (!microagent || !selectedRepository) {
return null;
}
return (
<div className="flex flex-col w-full h-full p-6 overflow-auto">
<MicroagentManagementViewMicroagentHeader />
<span className="text-white text-2xl font-medium pb-2">
{microagent.name}
</span>
<span className="text-white text-lg font-medium pb-6">
{microagent.path}
</span>
<div className="flex-1">
<MicroagentManagementViewMicroagentContent />
</div>
</div>
);
}
-25
View File
@@ -1,25 +0,0 @@
import { cn } from "#/utils/utils";
interface LoaderProps {
size?: "small" | "medium" | "large";
className?: string;
}
export function Loader({ size = "medium", className }: LoaderProps) {
const sizeClasses = {
small: "w-3 h-3",
medium: "w-4 h-4",
large: "w-5 h-5",
};
const dotSize = sizeClasses[size];
return (
<div
data-testid="loader"
className={cn("flex items-center justify-center", className)}
>
<div className={cn("loader rounded-full", dotSize)} />
</div>
);
}
@@ -3,7 +3,6 @@ import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { Provider } from "#/types/settings";
import { CreateMicroagent } from "#/api/open-hands.types";
interface CreateConversationVariables {
query?: string;
@@ -14,7 +13,6 @@ interface CreateConversationVariables {
};
suggestedTask?: SuggestedTask;
conversationInstructions?: string;
createMicroagent?: CreateMicroagent;
}
export const useCreateConversation = () => {
@@ -23,13 +21,8 @@ export const useCreateConversation = () => {
return useMutation({
mutationKey: ["create-conversation"],
mutationFn: async (variables: CreateConversationVariables) => {
const {
query,
repository,
suggestedTask,
conversationInstructions,
createMicroagent,
} = variables;
const { query, repository, suggestedTask, conversationInstructions } =
variables;
return OpenHands.createConversation(
repository?.name,
@@ -38,7 +31,6 @@ export const useCreateConversation = () => {
suggestedTask,
repository?.branch,
conversationInstructions,
createMicroagent,
);
},
onSuccess: async (_, { query, repository }) => {
@@ -15,7 +15,7 @@ export const useGetGitChanges = () => {
queryKey: ["file_changes", conversationId],
queryFn: () => OpenHands.getGitChanges(conversationId),
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
staleTime: 1000 * 30, // 30 seconds (reduced from 5 minutes to ensure fresher data after git operations)
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: runtimeIsReady && !!conversationId,
meta: {
@@ -1,26 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useSearchConversations = (
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
) =>
useQuery({
queryKey: [
"conversations",
"search",
selectedRepository,
conversationTrigger,
limit,
],
queryFn: () =>
OpenHands.searchConversations(
selectedRepository,
conversationTrigger,
limit,
),
enabled: true, // Always enabled since parameters are optional
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
@@ -3,7 +3,6 @@ import { useCreateConversation } from "./mutation/use-create-conversation";
import { useUserProviders } from "./use-user-providers";
import { useConversationSubscriptions } from "#/context/conversation-subscriptions-provider";
import { Provider } from "#/types/settings";
import { CreateMicroagent } from "#/api/open-hands.types";
/**
* Custom hook to create a conversation and subscribe to it, supporting multiple subscriptions.
@@ -25,7 +24,6 @@ export const useCreateConversationAndSubscribeMultiple = () => {
query,
conversationInstructions,
repository,
createMicroagent,
onSuccessCallback,
onEventCallback,
}: {
@@ -36,7 +34,6 @@ export const useCreateConversationAndSubscribeMultiple = () => {
branch: string;
gitProvider: Provider;
};
createMicroagent?: CreateMicroagent;
onSuccessCallback?: (conversationId: string) => void;
onEventCallback?: (event: unknown, conversationId: string) => void;
}) => {
@@ -45,7 +42,6 @@ export const useCreateConversationAndSubscribeMultiple = () => {
query,
conversationInstructions,
repository,
createMicroagent,
},
{
onSuccess: (data) => {
-14
View File
@@ -12,7 +12,6 @@ export enum I18nKey {
MICROAGENT$VIEW_CONVERSATION = "MICROAGENT$VIEW_CONVERSATION",
MICROAGENT$SUCCESS_PR_READY = "MICROAGENT$SUCCESS_PR_READY",
MICROAGENT$STATUS_CREATING = "MICROAGENT$STATUS_CREATING",
MICROAGENT$STATUS_OPENING_PR = "MICROAGENT$STATUS_OPENING_PR",
MICROAGENT$STATUS_COMPLETED = "MICROAGENT$STATUS_COMPLETED",
MICROAGENT$STATUS_ERROR = "MICROAGENT$STATUS_ERROR",
MICROAGENT$VIEW_YOUR_PR = "MICROAGENT$VIEW_YOUR_PR",
@@ -713,17 +712,4 @@ export enum I18nKey {
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS",
COMMON$SEARCH_REPOSITORIES = "COMMON$SEARCH_REPOSITORIES",
COMMON$READY_FOR_REVIEW = "COMMON$READY_FOR_REVIEW",
COMMON$COMPLETED = "COMMON$COMPLETED",
COMMON$COMPLETED_PARTIALLY = "COMMON$COMPLETED_PARTIALLY",
COMMON$STOPPED = "COMMON$STOPPED",
COMMON$WORKING_ON_IT = "COMMON$WORKING_ON_IT",
MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT = "MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT",
MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY = "MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY",
COMMON$REVIEW_PR_IN = "COMMON$REVIEW_PR_IN",
COMMON$EDIT_IN = "COMMON$EDIT_IN",
COMMON$LEARN = "COMMON$LEARN",
COMMON$STARTING = "COMMON$STARTING",
MICROAGENT_MANAGEMENT$ERROR = "MICROAGENT_MANAGEMENT$ERROR",
MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED = "MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED",
}
-224
View File
@@ -191,22 +191,6 @@
"de": "Microagent wird geändert...",
"uk": "Зміна мікроагента..."
},
"MICROAGENT$STATUS_OPENING_PR": {
"en": "Opening PR",
"ja": "PRを開いています",
"zh-CN": "正在打开PR",
"zh-TW": "正在打開PR",
"ko-KR": "PR 열는 중",
"no": "Åpner PR",
"it": "Apertura PR",
"pt": "Abrindo PR",
"es": "Abriendo PR",
"ar": "فتح PR",
"fr": "Ouverture de la PR",
"tr": "PR açılıyor",
"de": "PR wird geöffnet",
"uk": "Відкриття PR"
},
"MICROAGENT$STATUS_COMPLETED": {
"en": "View microagent update",
"ja": "マイクロエージェントの更新を表示",
@@ -11406,213 +11390,5 @@
"tr": "Depo ara",
"de": "Repositorys durchsuchen",
"uk": "Пошук репозиторіїв"
},
"COMMON$READY_FOR_REVIEW": {
"en": "Ready for review",
"ja": "レビューの準備ができました",
"zh-CN": "准备好审核",
"zh-TW": "已準備好審查",
"ko-KR": "검토 준비 완료",
"no": "Klar for gjennomgang",
"it": "Pronto per la revisione",
"pt": "Pronto para revisão",
"es": "Listo para revisión",
"ar": "جاهز للمراجعة",
"fr": "Prêt pour la relecture",
"tr": "İncelemeye hazır",
"de": "Bereit zur Überprüfung",
"uk": "Готово до перегляду"
},
"COMMON$COMPLETED": {
"en": "Completed",
"ja": "完了",
"zh-CN": "已完成",
"zh-TW": "已完成",
"ko-KR": "완료됨",
"no": "Fullført",
"it": "Completato",
"pt": "Concluído",
"es": "Completado",
"ar": "مكتمل",
"fr": "Terminé",
"tr": "Tamamlandı",
"de": "Abgeschlossen",
"uk": "Завершено"
},
"COMMON$COMPLETED_PARTIALLY": {
"en": "Completed partially",
"ja": "一部完了",
"zh-CN": "部分完成",
"zh-TW": "部分完成",
"ko-KR": "부분적으로 완료됨",
"no": "Delvis fullført",
"it": "Completato parzialmente",
"pt": "Concluído parcialmente",
"es": "Completado parcialmente",
"ar": "مكتمل جزئيًا",
"fr": "Partiellement terminé",
"tr": "Kısmen tamamlandı",
"de": "Teilweise abgeschlossen",
"uk": "Частково завершено"
},
"COMMON$STOPPED": {
"en": "Stopped",
"ja": "停止しました",
"zh-CN": "已停止",
"zh-TW": "已停止",
"ko-KR": "중지됨",
"no": "Stoppet",
"it": "Interrotto",
"pt": "Parado",
"es": "Detenido",
"ar": "متوقف",
"fr": "Arrêté",
"tr": "Durduruldu",
"de": "Gestoppt",
"uk": "Зупинено"
},
"COMMON$WORKING_ON_IT": {
"en": "Working on it",
"ja": "作業中",
"zh-CN": "正在处理",
"zh-TW": "正在處理",
"ko-KR": "작업 중",
"no": "Jobber med det",
"it": "Ci sto lavorando",
"pt": "Trabalhando nisso",
"es": "Trabajando en ello",
"ar": "يتم العمل عليه",
"fr": "En cours",
"tr": "Üzerinde çalışılıyor",
"de": "Wird bearbeitet",
"uk": "В процесі виконання"
},
"MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT": {
"en": "We're working on it! Once OpenHands is done investigating, you'll be able to review its pull request before merging your new microagent.",
"ja": "作業中です!OpenHandsの調査が完了すると、新しいマイクロエージェントをマージする前にプルリクエストを確認できます。",
"zh-CN": "我们正在处理!OpenHands 调查完成后,您将能够在合并新微代理之前审查其拉取请求。",
"zh-TW": "我們正在處理!OpenHands 調查完成後,您將能在合併新微代理前審查其拉取請求。",
"ko-KR": "작업 중입니다! OpenHands의 조사가 끝나면 새 마이크로에이전트를 병합하기 전에 풀 리퀘스트를 검토할 수 있습니다.",
"no": "Vi jobber med det! Når OpenHands er ferdig med å undersøke, kan du gjennomgå pull requesten før du slår sammen din nye mikroagent.",
"it": "Ci stiamo lavorando! Una volta che OpenHands avrà terminato l'analisi, potrai rivedere la pull request prima di unire il tuo nuovo microagent.",
"pt": "Estamos trabalhando nisso! Assim que o OpenHands terminar a investigação, você poderá revisar o pull request antes de mesclar seu novo microagente.",
"es": "¡Estamos trabajando en ello! Una vez que OpenHands termine de investigar, podrás revisar su pull request antes de fusionar tu nuevo microagente.",
"ar": "نحن نعمل على ذلك! بمجرد أن ينتهي OpenHands من التحقيق، ستتمكن من مراجعة طلب السحب قبل دمج وكيلك الدقيق الجديد.",
"fr": "Nous y travaillons ! Une fois qu'OpenHands aura terminé l'investigation, vous pourrez examiner sa pull request avant de fusionner votre nouveau microagent.",
"tr": "Üzerinde çalışıyoruz! OpenHands incelemeyi bitirdiğinde, yeni mikro ajanınızı birleştirmeden önce pull request'i gözden geçirebileceksiniz.",
"de": "Wir arbeiten daran! Sobald OpenHands die Untersuchung abgeschlossen hat, können Sie den Pull Request überprüfen, bevor Sie Ihren neuen Microagenten zusammenführen.",
"uk": "Ми працюємо над цим! Після завершення розслідування OpenHands ви зможете переглянути його pull request перед об'єднанням нового мікроагента."
},
"MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY": {
"en": "Your microagent is ready! Merge the PR in GitHub to start using it.",
"ja": "マイクロエージェントの準備ができました!GitHubでPRをマージして使い始めましょう。",
"zh-CN": "您的微代理已准备就绪!在 GitHub 上合并 PR 即可开始使用。",
"zh-TW": "您的微代理已準備就緒!在 GitHub 上合併 PR 即可開始使用。",
"ko-KR": "마이크로에이전트가 준비되었습니다! GitHub에서 PR을 병합하여 사용을 시작하세요.",
"no": "Din mikroagent er klar! Slå sammen PR-en i GitHub for å begynne å bruke den.",
"it": "Il tuo microagente è pronto! Unisci la PR su GitHub per iniziare a usarlo.",
"pt": "Seu microagente está pronto! Faça o merge do PR no GitHub para começar a usá-lo.",
"es": "¡Tu microagente está listo! Haz merge del PR en GitHub para empezar a usarlo.",
"ar": "وكيلك المصغر جاهز! ادمج طلب السحب في GitHub لبدء استخدامه.",
"fr": "Votre micro-agent est prêt ! Fusionnez la PR sur GitHub pour commencer à l'utiliser.",
"tr": "Mikro ajanınız hazır! Kullanmak için GitHub'da PR'ı birleştirin.",
"de": "Ihr Microagent ist bereit! Führen Sie den PR in GitHub zusammen, um ihn zu verwenden.",
"uk": "Ваш мікроагент готовий! Злийте PR у GitHub, щоб почати ним користуватися."
},
"COMMON$REVIEW_PR_IN": {
"en": "Review PR in",
"ja": "でPRをレビュー",
"zh-CN": "在中审查PR",
"zh-TW": "在中審查PR",
"ko-KR": "에서 PR 검토",
"no": "Se gjennom PR i",
"it": "Revisiona la PR su",
"pt": "Revisar PR em",
"es": "Revisar PR en",
"ar": "مراجعة PR في",
"fr": "Examiner la PR sur",
"tr": "PR'ı şurada gözden geçir:",
"de": "PR überprüfen in",
"uk": "Переглянути PR у"
},
"COMMON$EDIT_IN": {
"en": "Edit in",
"ja": "で編集",
"zh-CN": "在中编辑",
"zh-TW": "在中編輯",
"ko-KR": "에서 편집",
"no": "Rediger i",
"it": "Modifica su",
"pt": "Editar em",
"es": "Editar en",
"ar": "تعديل في",
"fr": "Modifier dans",
"tr": "Şurada düzenle:",
"de": "Bearbeiten in",
"uk": "Редагувати у"
},
"COMMON$LEARN": {
"en": "Learn",
"ja": "学ぶ",
"zh-CN": "学习",
"zh-TW": "學習",
"ko-KR": "학습",
"no": "Lær",
"it": "Impara",
"pt": "Aprender",
"es": "Aprender",
"ar": "تعلم",
"fr": "Apprendre",
"tr": "Öğren",
"de": "Lernen",
"uk": "Вчитися"
},
"COMMON$STARTING": {
"en": "Starting",
"ja": "開始中",
"zh-CN": "启动中",
"zh-TW": "啟動中",
"ko-KR": "시작 중",
"no": "Starter",
"it": "Avvio",
"pt": "Iniciando",
"es": "Iniciando",
"ar": "جارٍ البدء",
"fr": "Démarrage",
"tr": "Başlatılıyor",
"de": "Wird gestartet",
"uk": "Запуск"
},
"MICROAGENT_MANAGEMENT$ERROR": {
"en": "The system has encountered an error. Please try again later.",
"ja": "システムでエラーが発生しました。後でもう一度お試しください。",
"zh-CN": "系统遇到错误。请稍后再试。",
"zh-TW": "系統發生錯誤。請稍後再試。",
"ko-KR": "시스템에 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
"no": "Systemet har oppdaget en feil. Prøv igjen senere.",
"it": "Il sistema ha riscontrato un errore. Riprova più tardi.",
"pt": "O sistema encontrou um erro. Por favor, tente novamente mais tarde.",
"es": "El sistema ha encontrado un error. Por favor, inténtalo de nuevo más tarde.",
"ar": "واجه النظام خطأ. يرجى المحاولة مرة أخرى لاحقًا.",
"fr": "Le système a rencontré une erreur. Veuillez réessayer plus tard.",
"tr": "Sistem bir hata ile karşılaştı. Lütfen daha sonra tekrar deneyin.",
"de": "Das System hat einen Fehler festgestellt. Bitte versuchen Sie es später erneut.",
"uk": "Система зіткнулася з помилкою. Будь ласка, спробуйте пізніше."
},
"MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED": {
"en": "The conversation has been stopped.",
"ja": "会話が停止されました。",
"zh-CN": "对话已被停止。",
"zh-TW": "對話已被停止。",
"ko-KR": "대화가 중단되었습니다.",
"no": "Samtalen har blitt stoppet.",
"it": "La conversazione è stata interrotta.",
"pt": "A conversa foi interrompida.",
"es": "La conversación ha sido detenida.",
"ar": "تم إيقاف المحادثة.",
"fr": "La conversation a été arrêtée.",
"tr": "Konuşma durduruldu.",
"de": "Das Gespräch wurde gestoppt.",
"uk": "Розмову зупинено."
}
}
+30 -8
View File
@@ -1,11 +1,14 @@
import { redirect } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementSidebar } from "#/components/features/microagent-management/microagent-management-sidebar";
import { Route } from "./+types/settings";
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { MicroagentManagementContent } from "#/components/features/microagent-management/microagent-management-content";
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
import { EventHandler } from "#/wrapper/event-handler";
import { MicroagentManagementMain } from "#/components/features/microagent-management/microagent-management-main";
import { MicroagentManagementAddMicroagentModal } from "#/components/features/microagent-management/microagent-management-add-microagent-modal";
import { RootState } from "#/store";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
const url = new URL(request.url);
@@ -28,12 +31,31 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
};
function MicroagentManagement() {
const { addMicroagentModalVisible } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const hideAddMicroagentModal = () => {
dispatch(setAddMicroagentModalVisible(false));
};
return (
<ConversationSubscriptionsProvider>
<EventHandler>
<MicroagentManagementContent />
</EventHandler>
</ConversationSubscriptionsProvider>
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E]">
<MicroagentManagementSidebar />
<MicroagentManagementMain />
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={() => {
hideAddMicroagentModal();
}}
onCancel={() => {
hideAddMicroagentModal();
}}
/>
)}
</div>
);
}
@@ -1,18 +1,20 @@
import { createSlice } from "@reduxjs/toolkit";
import { GitRepository } from "#/types/git";
import { IMicroagentItem } from "#/types/microagent-management";
export const microagentManagementSlice = createSlice({
name: "microagentManagement",
initialState: {
selectedMicroagent: null,
addMicroagentModalVisible: false,
selectedRepository: null as GitRepository | null,
selectedRepository: null,
personalRepositories: [] as GitRepository[],
organizationRepositories: [] as GitRepository[],
repositories: [] as GitRepository[],
selectedMicroagentItem: null as IMicroagentItem | null,
},
reducers: {
setSelectedMicroagent: (state, action) => {
state.selectedMicroagent = action.payload;
},
setAddMicroagentModalVisible: (state, action) => {
state.addMicroagentModalVisible = action.payload;
},
@@ -28,19 +30,16 @@ export const microagentManagementSlice = createSlice({
setRepositories: (state, action) => {
state.repositories = action.payload;
},
setSelectedMicroagentItem: (state, action) => {
state.selectedMicroagentItem = action.payload;
},
},
});
export const {
setSelectedMicroagent,
setAddMicroagentModalVisible,
setSelectedRepository,
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
setSelectedMicroagentItem,
} = microagentManagementSlice.actions;
export default microagentManagementSlice.reducer;
-24
View File
@@ -20,27 +20,3 @@
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}
.loader {
background: #C9B974;
animation: l5 1s infinite linear alternate;
}
@keyframes l5 {
0% {
box-shadow: 20px 0 #C9B974, -20px 0 rgba(201,185,116,0.1);
background: #C9B974;
}
33% {
box-shadow: 20px 0 #C9B974, -20px 0 rgba(201,185,116,0.1);
background: rgba(201,185,116,0.1);
}
66% {
box-shadow: 20px 0 rgba(201,185,116,0.1), -20px 0 #C9B974;
background: rgba(201,185,116,0.1);
}
100% {
box-shadow: 20px 0 rgba(201,185,116,0.1), -20px 0 #C9B974;
background: #C9B974;
}
}
@@ -1,5 +1,3 @@
import { Conversation } from "#/api/open-hands.types";
export type TabType = "personal" | "repositories" | "organizations";
export interface RepositoryMicroagent {
@@ -11,16 +9,4 @@ export interface RepositoryMicroagent {
tools: string[];
created_at: string;
git_provider: string;
path: string;
}
export interface IMicroagentItem {
microagent?: RepositoryMicroagent;
conversation?: Conversation;
}
export interface MicroagentFormData {
query: string;
triggers: string[];
selectedBranch: string;
}
-91
View File
@@ -116,94 +116,3 @@ export const getGitProviderBaseUrl = (gitProvider: Provider): string => {
return "";
}
};
/**
* Get the name of the git provider
* @param gitProvider The git provider
* @returns The name of the git provider
*/
export const getProviderName = (gitProvider: Provider) => {
if (gitProvider === "gitlab") return "GitLab";
if (gitProvider === "bitbucket") return "Bitbucket";
return "GitHub";
};
/**
* Get the name of the PR
* @param isGitLab Whether the git provider is GitLab
* @returns The name of the PR
*/
export const getPR = (isGitLab: boolean) =>
isGitLab ? "merge request" : "pull request";
/**
* Get the short name of the PR
* @param isGitLab Whether the git provider is GitLab
* @returns The short name of the PR
*/
export const getPRShort = (isGitLab: boolean) => (isGitLab ? "MR" : "PR");
/**
* Construct the pull request (merge request) URL for different providers
* @param prNumber The pull request number
* @param provider The git provider
* @param repositoryName The repository name in format "owner/repo"
* @returns The pull request URL
*
* @example
* constructPullRequestUrl(123, "github", "owner/repo") // "https://github.com/owner/repo/pull/123"
* constructPullRequestUrl(456, "gitlab", "owner/repo") // "https://gitlab.com/owner/repo/-/merge_requests/456"
* constructPullRequestUrl(789, "bitbucket", "owner/repo") // "https://bitbucket.org/owner/repo/pull-requests/789"
*/
export const constructPullRequestUrl = (
prNumber: number,
provider: Provider,
repositoryName: string,
): string => {
const baseUrl = getGitProviderBaseUrl(provider);
switch (provider) {
case "github":
return `${baseUrl}/${repositoryName}/pull/${prNumber}`;
case "gitlab":
return `${baseUrl}/${repositoryName}/-/merge_requests/${prNumber}`;
case "bitbucket":
return `${baseUrl}/${repositoryName}/pull-requests/${prNumber}`;
default:
return "";
}
};
/**
* Construct the microagent URL for different providers
* @param gitProvider The git provider
* @param repositoryName The repository name in format "owner/repo"
* @param microagentPath The path to the microagent in the repository
* @returns The URL to the microagent file in the Git provider
*
* @example
* constructMicroagentUrl("github", "owner/repo", ".openhands/microagents/tell-me-a-joke.md")
* // "https://github.com/owner/repo/blob/main/.openhands/microagents/tell-me-a-joke.md"
* constructMicroagentUrl("gitlab", "owner/repo", "microagents/git-helper.md")
* // "https://gitlab.com/owner/repo/-/blob/main/microagents/git-helper.md"
* constructMicroagentUrl("bitbucket", "owner/repo", ".openhands/microagents/docker-helper.md")
* // "https://bitbucket.org/owner/repo/src/main/.openhands/microagents/docker-helper.md"
*/
export const constructMicroagentUrl = (
gitProvider: Provider,
repositoryName: string,
microagentPath: string,
): string => {
const baseUrl = getGitProviderBaseUrl(gitProvider);
switch (gitProvider) {
case "github":
return `${baseUrl}/${repositoryName}/blob/main/${microagentPath}`;
case "gitlab":
return `${baseUrl}/${repositoryName}/-/blob/main/${microagentPath}`;
case "bitbucket":
return `${baseUrl}/${repositoryName}/src/main/${microagentPath}`;
default:
return "";
}
};
+1 -6
View File
@@ -72,10 +72,7 @@ from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.memory_monitor import MemoryMonitor
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system_stats import (
get_system_stats,
update_last_execution_time,
)
from openhands.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all
if sys.platform == 'win32':
@@ -847,8 +844,6 @@ if __name__ == '__main__':
status_code=500,
detail=traceback.format_exc(),
)
finally:
update_last_execution_time()
@app.post('/update_mcp_server')
async def update_mcp_server(request: Request):
@@ -46,7 +46,6 @@ from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.runtime.base import Runtime
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.request import send_request
from openhands.runtime.utils.system_stats import update_last_execution_time
from openhands.utils.http_session import HttpSession
from openhands.utils.tenacity_stop import stop_if_should_exit
@@ -329,8 +328,6 @@ class ActionExecutionClient(Runtime):
raise AgentRuntimeTimeoutError(
f'Runtime failed to return execute_action before the requested timeout of {action.timeout}s'
)
finally:
update_last_execution_time()
return obs
def run(self, action: CmdRunAction) -> Observation:
+14 -99
View File
@@ -31,7 +31,6 @@ from openhands.runtime.utils.command import (
get_action_execution_server_startup_command,
)
from openhands.runtime.utils.log_streamer import LogStreamer
from openhands.runtime.utils.port_lock import PortLock, find_available_port_with_lock
from openhands.runtime.utils.runtime_build import build_runtime_image
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import add_shutdown_listener
@@ -105,11 +104,6 @@ class DockerRuntime(ActionExecutionClient):
self._vscode_port = -1
self._app_ports: list[int] = []
# Port locks to prevent race conditions
self._host_port_lock: PortLock | None = None
self._vscode_port_lock: PortLock | None = None
self._app_port_locks: list[PortLock] = []
if os.environ.get('DOCKER_HOST_ADDR'):
logger.info(
f'Using DOCKER_HOST_IP: {os.environ["DOCKER_HOST_ADDR"]} for local_runtime_url'
@@ -282,31 +276,17 @@ class DockerRuntime(ActionExecutionClient):
def init_container(self) -> None:
self.log('debug', 'Preparing to start container...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
# Allocate host port with locking to prevent race conditions
self._host_port, self._host_port_lock = self._find_available_port_with_lock(
EXECUTION_SERVER_PORT_RANGE
)
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
self._container_port = self._host_port
# Use the configured vscode_port if provided, otherwise find an available port
if self.config.sandbox.vscode_port:
self._vscode_port = self.config.sandbox.vscode_port
self._vscode_port_lock = None # No lock needed for configured port
else:
self._vscode_port, self._vscode_port_lock = (
self._find_available_port_with_lock(VSCODE_PORT_RANGE)
)
# Allocate app ports with locking
app_port_1, app_lock_1 = self._find_available_port_with_lock(APP_PORT_RANGE_1)
app_port_2, app_lock_2 = self._find_available_port_with_lock(APP_PORT_RANGE_2)
self._app_ports = [app_port_1, app_port_2]
self._app_port_locks = [
lock for lock in [app_lock_1, app_lock_2] if lock is not None
self._vscode_port = (
self.config.sandbox.vscode_port
or self._find_available_port(VSCODE_PORT_RANGE)
)
self._app_ports = [
self._find_available_port(APP_PORT_RANGE_1),
self._find_available_port(APP_PORT_RANGE_2),
]
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
use_host_network = self.config.sandbox.use_host_network
@@ -496,28 +476,6 @@ class DockerRuntime(ActionExecutionClient):
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
)
stop_all_containers(close_prefix)
self._release_port_locks()
def _release_port_locks(self) -> None:
"""Release all acquired port locks."""
if self._host_port_lock:
self._host_port_lock.release()
self._host_port_lock = None
logger.debug(f'Released host port lock for port {self._host_port}')
if self._vscode_port_lock:
self._vscode_port_lock.release()
self._vscode_port_lock = None
logger.debug(f'Released VSCode port lock for port {self._vscode_port}')
for i, lock in enumerate(self._app_port_locks):
if lock:
lock.release()
logger.debug(
f'Released app port lock for port {self._app_ports[i] if i < len(self._app_ports) else "unknown"}'
)
self._app_port_locks.clear()
def _is_port_in_use_docker(self, port: int) -> bool:
containers = self.docker_client.containers.list()
@@ -527,58 +485,15 @@ class DockerRuntime(ActionExecutionClient):
return True
return False
def _find_available_port_with_lock(
self, port_range: tuple[int, int], max_attempts: int = 5
) -> tuple[int, PortLock | None]:
"""Find an available port with race condition protection.
This method uses file-based locking to prevent multiple workers
from allocating the same port simultaneously.
Args:
port_range: Tuple of (min_port, max_port)
max_attempts: Maximum number of attempts to find a port
Returns:
Tuple of (port_number, port_lock) where port_lock may be None if locking failed
"""
# Try to find and lock an available port
result = find_available_port_with_lock(
min_port=port_range[0],
max_port=port_range[1],
max_attempts=max_attempts,
bind_address='0.0.0.0',
lock_timeout=1.0,
)
if result is None:
# Fallback to original method if port locking fails
logger.warning(
f'Port locking failed for range {port_range}, falling back to original method'
)
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(port_range[0], port_range[1])
if not self._is_port_in_use_docker(port):
return port, None
return port, None
port, port_lock = result
# Additional check with Docker to ensure port is not in use
if self._is_port_in_use_docker(port):
port_lock.release()
# Try again with a different port
logger.debug(f'Port {port} is in use by Docker, trying again')
return self._find_available_port_with_lock(port_range, max_attempts - 1)
return port, port_lock
def _find_available_port(
self, port_range: tuple[int, int], max_attempts: int = 5
) -> int:
"""Find an available port (legacy method for backward compatibility)."""
port, _ = self._find_available_port_with_lock(port_range, max_attempts)
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(port_range[0], port_range[1])
if not self._is_port_in_use_docker(port):
return port
# If no port is found after max_attempts, return the last tried port
return port
@property
+121 -86
View File
@@ -4,7 +4,8 @@ from typing import Callable
@dataclass
class CommandResult:
"""Represents the result of a shell command execution.
"""
Represents the result of a shell command execution.
Attributes:
content (str): The output content of the command.
@@ -16,7 +17,9 @@ class CommandResult:
class GitHandler:
"""A handler for executing Git-related operations via shell commands."""
"""
A handler for executing Git-related operations via shell commands.
"""
def __init__(
self,
@@ -26,7 +29,8 @@ class GitHandler:
self.cwd: str | None = None
def set_cwd(self, cwd: str) -> None:
"""Sets the current working directory for Git operations.
"""
Sets the current working directory for Git operations.
Args:
cwd (str): The directory path.
@@ -34,7 +38,8 @@ class GitHandler:
self.cwd = cwd
def _is_git_repo(self) -> bool:
"""Checks if the current directory is a Git repository.
"""
Checks if the current directory is a Git repository.
Returns:
bool: True if inside a Git repository, otherwise False.
@@ -44,7 +49,8 @@ class GitHandler:
return output.content.strip() == 'true'
def _get_current_file_content(self, file_path: str) -> str:
"""Retrieves the current content of a given file.
"""
Retrieves the current content of a given file.
Args:
file_path (str): Path to the file.
@@ -56,7 +62,8 @@ class GitHandler:
return output.content
def _verify_ref_exists(self, ref: str) -> bool:
"""Verifies whether a specific Git reference exists.
"""
Verifies whether a specific Git reference exists.
Args:
ref (str): The Git reference to check.
@@ -68,71 +75,13 @@ class GitHandler:
output = self.execute(cmd, self.cwd)
return output.exit_code == 0
def _is_ahead_of_remote_branch(self, remote_branch: str) -> bool:
"""Checks if the current branch is ahead of the specified remote branch.
Args:
remote_branch (str): The remote branch reference (e.g., 'origin/feature-branch').
Returns:
bool: True if current branch is ahead, False otherwise.
"""
cmd = f'git --no-pager rev-list --count {remote_branch}..HEAD'
output = self.execute(cmd, self.cwd)
if output.exit_code != 0:
return False
return int(output.content.strip()) > 0
def _includes_merged_main_commits(self, remote_branch: str, default_branch: str) -> bool:
"""Checks if the local branch includes commits that were merged from the default branch.
Since the remote branch was last updated.
Args:
remote_branch (str): The remote branch reference (e.g., 'origin/feature-branch').
default_branch (str): The default branch name (e.g., 'main').
Returns:
bool: True if merged main commits are included in the diff.
"""
# Get commits that are in HEAD but not in remote_branch
cmd = f'git --no-pager log --oneline {remote_branch}..HEAD'
output = self.execute(cmd, self.cwd)
if output.exit_code != 0:
return False
local_commits = output.content.strip().splitlines()
if not local_commits:
return False
# Get commits that are in origin/default_branch but not in remote_branch
origin_default = f'origin/{default_branch}'
if not self._verify_ref_exists(origin_default):
return False
cmd = f'git --no-pager log --oneline {remote_branch}..{origin_default}'
output = self.execute(cmd, self.cwd)
if output.exit_code != 0:
return False
main_commits = output.content.strip().splitlines()
if not main_commits:
return False
# Extract commit hashes from both lists
local_hashes = {line.split()[0] for line in local_commits if line.strip()}
main_hashes = {line.split()[0] for line in main_commits if line.strip()}
# If there's significant overlap, we likely have merged main commits
overlap = local_hashes.intersection(main_hashes)
return len(overlap) >= min(2, len(main_hashes) // 2)
def _get_valid_ref(self) -> str | None:
"""Determines a valid Git reference for comparison using a hybrid approach.
- Uses origin/current_branch when it's the best representation of push status
- Falls back to merge-base when origin/current_branch includes merged main commits
"""
Determines a valid Git reference for comparison.
This method intelligently selects a comparison base that avoids showing
merged changes as if they were user-created changes.
Returns:
str | None: A valid Git reference or None if no valid reference is found.
"""
@@ -144,18 +93,22 @@ class GitHandler:
ref_default_branch = 'origin/' + default_branch
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
# Hybrid logic: check if origin/current_branch exists and causes pollution
# Check if the current branch's remote tracking branch exists
if self._verify_ref_exists(ref_current_branch):
# If we're ahead of remote and it includes merged main commits, use merge-base instead
if (self._is_ahead_of_remote_branch(ref_current_branch) and
self._includes_merged_main_commits(ref_current_branch, default_branch)):
# Try merge-base first to avoid pollution
# Check if the current HEAD has diverged significantly from the remote tracking branch
# This happens after merging changes from the default branch
if self._has_diverged_from_remote_tracking_branch(
current_branch, default_branch
):
# If we've diverged due to a merge, prefer using merge-base with default branch
# This prevents showing merged changes as user changes
if self._verify_ref_exists(ref_non_default_branch):
return ref_non_default_branch
# Otherwise use origin/current_branch for normal push workflow
# If no significant divergence or merge-base doesn't exist, use the remote tracking branch
return ref_current_branch
# Fallback to original logic
# Fallback to other references if remote tracking branch doesn't exist
refs = [
ref_non_default_branch,
ref_default_branch,
@@ -167,8 +120,76 @@ class GitHandler:
return None
def _has_diverged_from_remote_tracking_branch(
self, current_branch: str, default_branch: str
) -> bool:
"""
Checks if the current branch has diverged significantly from its remote tracking branch.
This typically happens after merging changes from the default branch, where the local
branch has new commits but the remote tracking branch hasn't been updated yet.
Args:
current_branch (str): The name of the current branch.
default_branch (str): The name of the default branch.
Returns:
bool: True if the branch has diverged due to a merge, False otherwise.
"""
try:
# Get the commit hash of the current HEAD
head_cmd = 'git --no-pager rev-parse HEAD'
head_result = self.execute(head_cmd, self.cwd)
if head_result.exit_code != 0:
return False
current_head = head_result.content.strip()
# Get the commit hash of the remote tracking branch
remote_branch_cmd = f'git --no-pager rev-parse origin/{current_branch}'
remote_result = self.execute(remote_branch_cmd, self.cwd)
if remote_result.exit_code != 0:
return False
remote_head = remote_result.content.strip()
# If they're the same, no divergence
if current_head == remote_head:
return False
# Check if the current HEAD is ahead of the remote tracking branch
ahead_cmd = f'git --no-pager rev-list --count origin/{current_branch}..HEAD'
ahead_result = self.execute(ahead_cmd, self.cwd)
if ahead_result.exit_code != 0:
return False
commits_ahead = int(ahead_result.content.strip())
# Check if the current HEAD contains commits from the default branch
# that are not in the remote tracking branch
if commits_ahead > 0:
# Check if any of the commits ahead are merge commits or contain changes from default branch
merge_check_cmd = f'git --no-pager log --oneline --merges origin/{current_branch}..HEAD'
merge_result = self.execute(merge_check_cmd, self.cwd)
# If there are merge commits, this indicates a divergence due to merging
if merge_result.exit_code == 0 and merge_result.content.strip():
return True
# Also check if the commits ahead include changes that exist in the default branch
# This catches cases where changes were merged without creating a merge commit
if (
commits_ahead >= 2
): # Threshold to avoid false positives for single commits
return True
return False
except (ValueError, Exception):
# If any error occurs, assume no divergence to be safe
return False
def _get_ref_content(self, file_path: str) -> str:
"""Retrieves the content of a file from a valid Git reference.
"""
Retrieves the content of a file from a valid Git reference.
Args:
file_path (str): The file path in the repository.
@@ -185,17 +206,26 @@ class GitHandler:
return output.content if output.exit_code == 0 else ''
def _get_default_branch(self) -> str:
"""Retrieves the primary Git branch name of the repository.
"""
Retrieves the primary Git branch name of the repository.
Returns:
str: The name of the primary branch.
"""
cmd = 'git --no-pager remote show origin | grep "HEAD branch"'
output = self.execute(cmd, self.cwd)
return output.content.split()[-1].strip()
if output.exit_code != 0 or not output.content.strip():
# Fallback to 'main' if no remote origin exists
return 'main'
parts = output.content.split()
if len(parts) == 0:
return 'main'
return parts[-1].strip()
def _get_current_branch(self) -> str:
"""Retrieves the currently selected Git branch.
"""
Retrieves the currently selected Git branch.
Returns:
str: The name of the current branch.
@@ -205,7 +235,8 @@ class GitHandler:
return output.content.strip()
def _get_changed_files(self) -> list[str]:
"""Retrieves a list of changed files compared to a valid Git reference.
"""
Retrieves a list of changed files compared to a valid Git reference.
Returns:
list[str]: A list of changed file paths.
@@ -223,7 +254,8 @@ class GitHandler:
return output.content.splitlines()
def _get_untracked_files(self) -> list[dict[str, str]]:
"""Retrieves a list of untracked files in the repository. This is useful for detecting new files.
"""
Retrieves a list of untracked files in the repository. This is useful for detecting new files.
Returns:
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
@@ -238,7 +270,8 @@ class GitHandler:
)
def get_git_changes(self) -> list[dict[str, str]] | None:
"""Retrieves the list of changed files in the Git repository.
"""
Retrieves the list of changed files in the Git repository.
Returns:
list[dict[str, str]] | None: A list of dictionaries containing file paths and statuses. None if not a git repository.
@@ -254,7 +287,8 @@ class GitHandler:
return result
def get_git_diff(self, file_path: str) -> dict[str, str]:
"""Retrieves the original and modified content of a file in the repository.
"""
Retrieves the original and modified content of a file in the repository.
Args:
file_path (str): Path to the file.
@@ -272,7 +306,8 @@ class GitHandler:
def parse_git_changes(changes_list: list[str]) -> list[dict[str, str]]:
"""Parses the list of changed files and extracts their statuses and paths.
"""
Parses the list of changed files and extracts their statuses and paths.
Args:
changes_list (list[str]): List of changed file entries.
-268
View File
@@ -1,268 +0,0 @@
"""File-based port locking system for preventing race conditions in port allocation."""
import os
import random
import socket
import tempfile
import time
from typing import Optional
from openhands.core.logger import openhands_logger as logger
# Import fcntl only on Unix systems
try:
import fcntl
HAS_FCNTL = True
except ImportError:
HAS_FCNTL = False
class PortLock:
"""File-based lock for a specific port to prevent race conditions."""
def __init__(self, port: int, lock_dir: Optional[str] = None):
self.port = port
self.lock_dir = lock_dir or os.path.join(
tempfile.gettempdir(), 'openhands_port_locks'
)
self.lock_file_path = os.path.join(self.lock_dir, f'port_{port}.lock')
self.lock_fd: Optional[int] = None
self._locked = False
# Ensure lock directory exists
os.makedirs(self.lock_dir, exist_ok=True)
def acquire(self, timeout: float = 1.0) -> bool:
"""Acquire the lock for this port.
Args:
timeout: Maximum time to wait for the lock
Returns:
True if lock was acquired, False otherwise
"""
if self._locked:
return True
try:
if HAS_FCNTL:
# Unix-style file locking with fcntl
self.lock_fd = os.open(
self.lock_file_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC
)
# Try to acquire exclusive lock with timeout
start_time = time.time()
while time.time() - start_time < timeout:
try:
fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
self._locked = True
# Write port number to lock file for debugging
os.write(self.lock_fd, f'{self.port}\n'.encode())
os.fsync(self.lock_fd)
logger.debug(f'Acquired lock for port {self.port}')
return True
except (OSError, IOError):
# Lock is held by another process, wait a bit
time.sleep(0.01)
# Timeout reached
if self.lock_fd:
os.close(self.lock_fd)
self.lock_fd = None
return False
else:
# Windows fallback: use atomic file creation
start_time = time.time()
while time.time() - start_time < timeout:
try:
# Try to create lock file exclusively
self.lock_fd = os.open(
self.lock_file_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY
)
self._locked = True
# Write port number to lock file for debugging
os.write(self.lock_fd, f'{self.port}\n'.encode())
os.fsync(self.lock_fd)
logger.debug(f'Acquired lock for port {self.port}')
return True
except OSError:
# Lock file already exists, wait a bit
time.sleep(0.01)
# Timeout reached
return False
except Exception as e:
logger.debug(f'Failed to acquire lock for port {self.port}: {e}')
if self.lock_fd:
try:
os.close(self.lock_fd)
except OSError:
pass
self.lock_fd = None
return False
def release(self) -> None:
"""Release the lock."""
if self.lock_fd is not None:
try:
if HAS_FCNTL:
# Unix: unlock and close
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
os.close(self.lock_fd)
# Remove lock file (both Unix and Windows)
try:
os.unlink(self.lock_file_path)
except FileNotFoundError:
pass
logger.debug(f'Released lock for port {self.port}')
except Exception as e:
logger.warning(f'Error releasing lock for port {self.port}: {e}')
finally:
self.lock_fd = None
self._locked = False
def __enter__(self) -> 'PortLock':
if not self.acquire():
raise OSError(f'Could not acquire lock for port {self.port}')
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.release()
@property
def is_locked(self) -> bool:
return self._locked
def find_available_port_with_lock(
min_port: int = 30000,
max_port: int = 39999,
max_attempts: int = 20,
bind_address: str = '0.0.0.0',
lock_timeout: float = 1.0,
) -> Optional[tuple[int, PortLock]]:
"""Find an available port and acquire a lock for it.
This function combines file-based locking with port availability checking
to prevent race conditions in multi-process scenarios.
Args:
min_port: Minimum port number to try
max_port: Maximum port number to try
max_attempts: Maximum number of ports to try
bind_address: Address to bind to when checking availability
lock_timeout: Timeout for acquiring port lock
Returns:
Tuple of (port, lock) if successful, None otherwise
"""
rng = random.SystemRandom()
# Try random ports first for better distribution
random_attempts = min(max_attempts // 2, 10)
for _ in range(random_attempts):
port = rng.randint(min_port, max_port)
# Try to acquire lock first
lock = PortLock(port)
if lock.acquire(timeout=lock_timeout):
# Check if port is actually available
if _check_port_available(port, bind_address):
logger.debug(f'Found and locked available port {port}')
return port, lock
else:
# Port is locked but not available (maybe in TIME_WAIT state)
lock.release()
# Small delay to reduce contention
time.sleep(0.001)
# If random attempts failed, try sequential search
remaining_attempts = max_attempts - random_attempts
start_port = rng.randint(min_port, max_port - remaining_attempts)
for i in range(remaining_attempts):
port = start_port + i
if port > max_port:
port = min_port + (port - max_port - 1)
# Try to acquire lock first
lock = PortLock(port)
if lock.acquire(timeout=lock_timeout):
# Check if port is actually available
if _check_port_available(port, bind_address):
logger.debug(f'Found and locked available port {port}')
return port, lock
else:
# Port is locked but not available
lock.release()
# Small delay to reduce contention
time.sleep(0.001)
logger.error(
f'Could not find and lock available port in range {min_port}-{max_port} after {max_attempts} attempts'
)
return None
def _check_port_available(port: int, bind_address: str = '0.0.0.0') -> bool:
"""Check if a port is available by trying to bind to it."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((bind_address, port))
sock.close()
return True
except OSError:
return False
def cleanup_stale_locks(max_age_seconds: int = 300) -> int:
"""Clean up stale lock files.
Args:
max_age_seconds: Maximum age of lock files before they're considered stale
Returns:
Number of lock files cleaned up
"""
lock_dir = os.path.join(tempfile.gettempdir(), 'openhands_port_locks')
if not os.path.exists(lock_dir):
return 0
cleaned = 0
current_time = time.time()
try:
for filename in os.listdir(lock_dir):
if filename.startswith('port_') and filename.endswith('.lock'):
lock_path = os.path.join(lock_dir, filename)
try:
# Check if lock file is old
stat = os.stat(lock_path)
if current_time - stat.st_mtime > max_age_seconds:
# Try to remove stale lock
os.unlink(lock_path)
cleaned += 1
logger.debug(f'Cleaned up stale lock file: {filename}')
except (OSError, FileNotFoundError):
# File might have been removed by another process
pass
except OSError:
# Directory might not exist or be accessible
pass
if cleaned > 0:
logger.info(f'Cleaned up {cleaned} stale port lock files')
return cleaned
-19
View File
@@ -4,25 +4,6 @@ import time
import psutil
_start_time = time.time()
_last_execution_time = time.time()
def get_system_info() -> dict[str, object]:
current_time = time.time()
uptime = current_time - _start_time
idle_time = current_time - _last_execution_time
return {
'uptime': uptime,
'idle_time': idle_time,
'resources': get_system_stats(),
}
def update_last_execution_time():
global _last_execution_time
_last_execution_time = time.time()
def get_system_stats() -> dict[str, object]:
"""Get current system resource statistics.
@@ -27,4 +27,3 @@ class ConversationInfo:
url: str | None = None
session_api_key: str | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
pr_number: list[int] = field(default_factory=list)
-3
View File
@@ -254,7 +254,6 @@ class MicroagentResponse(BaseModel):
tools: list[str] = []
created_at: datetime
git_provider: ProviderType
path: str # Path to the microagent in the Git provider (e.g., ".openhands/microagents/tell-me-a-joke")
def _get_file_creation_time(repo_dir: Path, file_path: Path) -> datetime:
@@ -454,7 +453,6 @@ def _process_microagents(
),
created_at=created_at,
git_provider=git_provider,
path=str(agent_file_path.relative_to(repo_dir)),
)
)
@@ -478,7 +476,6 @@ def _process_microagents(
),
created_at=created_at,
git_provider=git_provider,
path=str(agent_file_path.relative_to(repo_dir)),
)
)
+24 -3
View File
@@ -1,6 +1,11 @@
from fastapi import FastAPI
import time
from openhands.runtime.utils.system_stats import get_system_info
from fastapi import FastAPI, Request
from openhands.runtime.utils.system_stats import get_system_stats
start_time = time.time()
last_execution_time = start_time
def add_health_endpoints(app: FastAPI):
@@ -14,4 +19,20 @@ def add_health_endpoints(app: FastAPI):
@app.get('/server_info')
async def get_server_info():
return get_system_info()
current_time = time.time()
uptime = current_time - start_time
idle_time = current_time - last_execution_time
response = {
'uptime': uptime,
'idle_time': idle_time,
'resources': get_system_stats(),
}
return response
@app.middleware('http')
async def update_last_execution_time(request: Request, call_next):
global last_execution_time
response = await call_next(request)
last_execution_time = time.time()
return response
@@ -424,7 +424,6 @@ async def _get_conversation_info(
num_connections=num_connections,
url=agent_loop_info.url if agent_loop_info else None,
session_api_key=getattr(agent_loop_info, 'session_api_key', None),
pr_number=conversation.pr_number,
)
except Exception as e:
logger.error(
@@ -73,9 +73,9 @@ class FileConversationStore(ConversationStore):
metadata_dir = self.get_conversation_metadata_dir()
try:
conversation_ids = [
Path(path).name
path.split('/')[-2]
for path in self.file_store.list(metadata_dir)
if not Path(path).name.startswith('.')
if not path.startswith(f'{metadata_dir}/.')
]
except FileNotFoundError:
return ConversationMetadataResultSet([])
-219
View File
@@ -1,219 +0,0 @@
"""Test for port allocation race condition fix."""
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from openhands.runtime.utils.port_lock import PortLock, find_available_port_with_lock
class TestPortLockingFix:
"""Test cases for port allocation race condition fix."""
def test_port_lock_prevents_duplicate_allocation(self):
"""Test that port locking prevents duplicate port allocation."""
allocated_ports = []
port_locks = []
def allocate_port():
"""Simulate port allocation by multiple workers."""
result = find_available_port_with_lock(
min_port=30000,
max_port=30010, # Small range to force conflicts
max_attempts=5,
bind_address='0.0.0.0',
lock_timeout=2.0,
)
if result:
port, lock = result
allocated_ports.append(port)
port_locks.append(lock)
# Simulate some work time
time.sleep(0.1)
return port
return None
# Run multiple threads concurrently
num_workers = 8
with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = [executor.submit(allocate_port) for _ in range(num_workers)]
results = [future.result() for future in as_completed(futures)]
# Filter out None results
successful_ports = [port for port in results if port is not None]
# Verify no duplicate ports were allocated
assert len(successful_ports) == len(set(successful_ports)), (
f'Duplicate ports allocated: {successful_ports}'
)
# Clean up locks
for lock in port_locks:
if lock:
lock.release()
print(
f'Successfully allocated {len(successful_ports)} unique ports: {successful_ports}'
)
def test_port_lock_basic_functionality(self):
"""Test basic port lock functionality."""
port = 30001
# Test acquiring and releasing a lock
lock1 = PortLock(port)
assert lock1.acquire(timeout=1.0)
assert lock1.is_locked
# Test that another lock cannot acquire the same port
lock2 = PortLock(port)
assert not lock2.acquire(timeout=0.1)
assert not lock2.is_locked
# Release first lock
lock1.release()
assert not lock1.is_locked
# Now second lock should be able to acquire
assert lock2.acquire(timeout=1.0)
assert lock2.is_locked
lock2.release()
def test_port_lock_context_manager(self):
"""Test port lock context manager functionality."""
port = 30002
# Test successful context manager usage
with PortLock(port) as lock:
assert lock.is_locked
# Test that another lock cannot acquire while in context
lock2 = PortLock(port)
assert not lock2.acquire(timeout=0.1)
# After context, lock should be released
assert not lock.is_locked
# Now another lock should be able to acquire
lock3 = PortLock(port)
assert lock3.acquire(timeout=1.0)
lock3.release()
def test_concurrent_port_allocation_stress_test(self):
"""Stress test concurrent port allocation."""
allocated_ports = []
port_locks = []
errors = []
def worker_allocate_port(worker_id):
"""Worker function that allocates a port."""
try:
result = find_available_port_with_lock(
min_port=31000,
max_port=31020, # Small range to force contention
max_attempts=10,
bind_address='0.0.0.0',
lock_timeout=3.0,
)
if result:
port, lock = result
allocated_ports.append((worker_id, port))
port_locks.append(lock)
# Simulate work
time.sleep(0.05)
return port
else:
errors.append(f'Worker {worker_id}: No port available')
return None
except Exception as e:
errors.append(f'Worker {worker_id}: {str(e)}')
return None
# Run many workers concurrently
num_workers = 15
with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = {
executor.submit(worker_allocate_port, i): i for i in range(num_workers)
}
results = {}
for future in as_completed(futures):
worker_id = futures[future]
try:
result = future.result()
results[worker_id] = result
except Exception as e:
errors.append(f'Worker {worker_id} exception: {str(e)}')
# Analyze results
successful_allocations = [
(wid, port) for wid, port in allocated_ports if port is not None
]
allocated_port_numbers = [port for _, port in successful_allocations]
print(f'Successful allocations: {len(successful_allocations)}')
print(f'Allocated ports: {allocated_port_numbers}')
print(f'Errors: {len(errors)}')
if errors:
print(f'Error details: {errors[:5]}') # Show first 5 errors
# Verify no duplicate ports
unique_ports = set(allocated_port_numbers)
assert len(allocated_port_numbers) == len(unique_ports), (
f'Duplicate ports found: {allocated_port_numbers}'
)
# Clean up locks
for lock in port_locks:
if lock:
lock.release()
def test_port_allocation_without_locking_shows_race_condition(self):
"""Test that demonstrates race condition without locking."""
from openhands.runtime.utils import find_available_tcp_port
allocated_ports = []
def allocate_port_without_lock():
"""Simulate port allocation without locking (old method)."""
# This simulates the old behavior that had race conditions
port = find_available_tcp_port(32000, 32010)
allocated_ports.append(port)
# Small delay to increase chance of race condition
time.sleep(0.01)
return port
# Run multiple threads concurrently
num_workers = 10
with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = [
executor.submit(allocate_port_without_lock) for _ in range(num_workers)
]
results = [future.result() for future in as_completed(futures)]
# Check if we got duplicate ports (race condition)
unique_ports = set(results)
duplicates_found = len(results) != len(unique_ports)
print(
f'Without locking - Total ports: {len(results)}, Unique: {len(unique_ports)}'
)
print(f'Ports allocated: {results}')
print(f'Race condition detected: {duplicates_found}')
# This test demonstrates the problem exists without locking
# In a real race condition scenario, we might get duplicates
# But since the race window is small, we'll just verify the test runs
assert len(results) == num_workers
if __name__ == '__main__':
test = TestPortLockingFix()
test.test_port_lock_prevents_duplicate_allocation()
test.test_port_lock_basic_functionality()
test.test_port_lock_context_manager()
test.test_concurrent_port_allocation_stress_test()
test.test_port_allocation_without_locking_shows_race_condition()
print('All tests passed!')
+1 -101
View File
@@ -1,15 +1,8 @@
"""Tests for system stats utilities."""
import time
from unittest.mock import patch
import psutil
from openhands.runtime.utils.system_stats import (
get_system_info,
get_system_stats,
update_last_execution_time,
)
from openhands.runtime.utils.system_stats import get_system_stats
def test_get_system_stats():
@@ -65,96 +58,3 @@ def test_get_system_stats_stability():
stats = get_system_stats()
assert isinstance(stats, dict)
assert stats['cpu_percent'] >= 0
def test_get_system_info():
"""Test that get_system_info returns valid system information."""
with patch(
'openhands.runtime.utils.system_stats.get_system_stats'
) as mock_get_stats:
mock_get_stats.return_value = {'cpu_percent': 10.0}
info = get_system_info()
# Test structure
assert isinstance(info, dict)
assert set(info.keys()) == {'uptime', 'idle_time', 'resources'}
# Test values
assert isinstance(info['uptime'], float)
assert isinstance(info['idle_time'], float)
assert info['uptime'] > 0
assert info['idle_time'] >= 0
assert info['resources'] == {'cpu_percent': 10.0}
# Verify get_system_stats was called
mock_get_stats.assert_called_once()
def test_update_last_execution_time():
"""Test that update_last_execution_time updates the last execution time."""
# Get initial system info
initial_info = get_system_info()
initial_idle_time = initial_info['idle_time']
# Wait a bit to ensure time difference
time.sleep(0.1)
# Update last execution time
update_last_execution_time()
# Get updated system info
updated_info = get_system_info()
updated_idle_time = updated_info['idle_time']
# The idle time should be reset (close to zero)
assert updated_idle_time < initial_idle_time
assert updated_idle_time < 0.1 # Should be very small
def test_idle_time_increases_without_updates():
"""Test that idle_time increases when no updates are made."""
# Update last execution time to reset idle time
update_last_execution_time()
# Get initial system info
initial_info = get_system_info()
initial_idle_time = initial_info['idle_time']
# Wait a bit
time.sleep(0.2)
# Get updated system info without calling update_last_execution_time
updated_info = get_system_info()
updated_idle_time = updated_info['idle_time']
# The idle time should have increased
assert updated_idle_time > initial_idle_time
assert updated_idle_time >= 0.2 # Should be at least the sleep time
@patch('time.time')
def test_idle_time_calculation(mock_time):
"""Test that idle_time is calculated correctly."""
# Mock time.time() to return controlled values
mock_time.side_effect = [
100.0, # Initial _start_time
100.0, # Initial _last_execution_time
110.0, # Current time in get_system_info
]
# Import the module again to reset the global variables with our mocked time
import importlib
import openhands.runtime.utils.system_stats
importlib.reload(openhands.runtime.utils.system_stats)
# Get system info
from openhands.runtime.utils.system_stats import get_system_info
info = get_system_info()
# Verify idle_time calculation
assert info['uptime'] == 10.0 # 110 - 100
assert info['idle_time'] == 10.0 # 110 - 100
-364
View File
@@ -179,7 +179,6 @@ async def test_search_conversations():
selected_repository='foobar',
num_connections=0,
url=None,
pr_number=[], # Default empty list for pr_number
)
]
)
@@ -639,7 +638,6 @@ async def test_get_conversation():
selected_repository='foobar',
num_connections=0,
url=None,
pr_number=[], # Default empty list for pr_number
)
assert conversation == expected
@@ -1200,365 +1198,3 @@ async def test_new_conversation_with_create_microagent_minimal(provider_handler_
assert (
call_args['git_provider'] is None
) # Should remain None since not set in create_microagent
@pytest.mark.asyncio
async def test_search_conversations_with_pr_number():
"""Test searching conversations includes pr_number field in response."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_with_pr',
title='Conversation with PR',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[123, 456], # Multiple PR numbers
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes pr_number field
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == [123, 456]
assert conversation_info.conversation_id == 'conversation_with_pr'
assert conversation_info.title == 'Conversation with PR'
@pytest.mark.asyncio
async def test_search_conversations_with_empty_pr_number():
"""Test searching conversations with empty pr_number field."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_no_pr',
title='Conversation without PR',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[], # Empty PR numbers list
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes empty pr_number field
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == []
assert conversation_info.conversation_id == 'conversation_no_pr'
assert conversation_info.title == 'Conversation without PR'
@pytest.mark.asyncio
async def test_search_conversations_with_single_pr_number():
"""Test searching conversations with single PR number."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_single_pr',
title='Conversation with Single PR',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[789], # Single PR number
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes single pr_number
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == [789]
assert conversation_info.conversation_id == 'conversation_single_pr'
assert conversation_info.title == 'Conversation with Single PR'
@pytest.mark.asyncio
async def test_get_conversation_with_pr_number():
"""Test getting a single conversation includes pr_number field."""
with _patch_store():
# Mock the conversation store
mock_store = MagicMock()
mock_store.get_metadata = AsyncMock(
return_value=ConversationMetadata(
conversation_id='conversation_with_pr',
title='Conversation with PR',
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
selected_repository='test/repo',
user_id='12345',
pr_number=[123, 456, 789], # Multiple PR numbers
)
)
# Mock the conversation manager
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
mock_manager.get_connections = AsyncMock(return_value={})
mock_manager.get_agent_loop_info = AsyncMock(return_value=[])
conversation = await get_conversation(
'conversation_with_pr', conversation_store=mock_store
)
expected = ConversationInfo(
conversation_id='conversation_with_pr',
title='Conversation with PR',
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
status=ConversationStatus.STOPPED,
selected_repository='test/repo',
num_connections=0,
url=None,
pr_number=[123, 456, 789], # Should include PR numbers
)
assert conversation == expected
@pytest.mark.asyncio
async def test_search_conversations_multiple_with_pr_numbers():
"""Test searching conversations with multiple conversations having different PR numbers."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_1',
title='Conversation 1',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[100, 200], # Multiple PR numbers
),
ConversationMetadata(
conversation_id='conversation_2',
title='Conversation 2',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[], # Empty PR numbers
),
ConversationMetadata(
conversation_id='conversation_3',
title='Conversation 3',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[300], # Single PR number
),
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify all results include pr_number field
assert len(result_set.results) == 3
# Check first conversation
assert result_set.results[0].conversation_id == 'conversation_1'
assert result_set.results[0].pr_number == [100, 200]
# Check second conversation
assert result_set.results[1].conversation_id == 'conversation_2'
assert result_set.results[1].pr_number == []
# Check third conversation
assert result_set.results[2].conversation_id == 'conversation_3'
assert result_set.results[2].pr_number == [300]
+21 -382
View File
@@ -102,7 +102,7 @@ def mock_repo_microagent():
]
),
),
source='.openhands/microagents/test_repo_agent.md',
source='test_source',
type=MicroagentType.REPO_KNOWLEDGE,
)
@@ -128,7 +128,7 @@ def mock_knowledge_microagent():
]
),
),
source='.openhands/microagents/test_knowledge_agent.md',
source='test_source',
type=MicroagentType.KNOWLEDGE,
triggers=['test', 'knowledge', 'search'],
)
@@ -283,72 +283,7 @@ class TestGetRepositoryMicroagents:
mock_result.stderr = ''
mock_subprocess_run.return_value = mock_result
# Create mock microagents with proper absolute paths
repo_agent_with_path = RepoMicroagent(
name='test_repo_agent',
content='This is a test repository microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_repo_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[
InputMetadata(
name='query',
type='str',
description='Search query for the repository',
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='git', command='git'),
MCPStdioServerConfig(name='file_editor', command='editor'),
]
),
),
source=str(
Path(temp_microagents_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_repo_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
knowledge_agent_with_path = KnowledgeMicroagent(
name='test_knowledge_agent',
content='This is a test knowledge microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_knowledge_agent',
type=MicroagentType.KNOWLEDGE,
inputs=[
InputMetadata(
name='topic', type='str', description='Topic to search for'
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='search', command='search'),
MCPStdioServerConfig(name='fetch', command='fetch'),
]
),
),
source=str(
Path(temp_microagents_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_knowledge_agent.md'
),
type=MicroagentType.KNOWLEDGE,
triggers=['test', 'knowledge', 'search'],
)
mock_microagents_data_with_paths = (
{'test_repo_agent': repo_agent_with_path},
{'test_knowledge_agent': knowledge_agent_with_path},
)
mock_load_microagents.return_value = mock_microagents_data_with_paths
mock_load_microagents.return_value = mock_microagents_data
mock_mkdtemp.return_value = temp_microagents_dir
# Execute test
@@ -373,8 +308,6 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in repo_agent
assert 'git_provider' in repo_agent
assert repo_agent['git_provider'] == 'github'
assert 'path' in repo_agent
assert repo_agent['path'] == '.openhands/microagents/test_repo_agent.md'
# Check knowledge microagent
knowledge_agent = next(m for m in data if m['name'] == 'test_knowledge_agent')
@@ -390,10 +323,6 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in knowledge_agent
assert 'git_provider' in knowledge_agent
assert knowledge_agent['git_provider'] == 'github'
assert 'path' in knowledge_agent
assert (
knowledge_agent['path'] == '.openhands/microagents/test_knowledge_agent.md'
)
@pytest.mark.asyncio
@patch('openhands.server.routes.git.ProviderHandler')
@@ -626,38 +555,8 @@ class TestGetRepositoryMicroagents:
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create mock microagents with proper absolute paths
repo_agent_with_path = RepoMicroagent(
name='test_repo_agent',
content='This is a test repository microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_repo_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[
InputMetadata(
name='query',
type='str',
description='Search query for the repository',
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='git', command='git'),
MCPStdioServerConfig(name='file_editor', command='editor'),
]
),
),
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_repo_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
mock_repo_agents = {'test_repo_agent': repo_agent_with_path}
# Mock load_microagents_from_dir
mock_repo_agents = {'test_repo_agent': mock_repo_microagent}
mock_knowledge_agents = {}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
@@ -675,8 +574,6 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in data[0]
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'github'
assert 'path' in data[0]
assert data[0]['path'] == '.openhands/microagents/test_repo_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -737,38 +634,8 @@ class TestGetRepositoryMicroagents:
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create mock microagents with proper absolute paths
repo_agent_with_path = RepoMicroagent(
name='test_repo_agent',
content='This is a test repository microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_repo_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[
InputMetadata(
name='query',
type='str',
description='Search query for the repository',
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='git', command='git'),
MCPStdioServerConfig(name='file_editor', command='editor'),
]
),
),
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_repo_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
mock_repo_agents = {'test_repo_agent': repo_agent_with_path}
# Mock load_microagents_from_dir
mock_repo_agents = {'test_repo_agent': mock_repo_microagent}
mock_knowledge_agents = {}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
@@ -785,8 +652,6 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in data[0]
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'github'
assert 'path' in data[0]
assert data[0]['path'] == '.openhands/microagents/test_repo_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -883,6 +748,20 @@ class TestGetRepositoryMicroagents:
lambda: mock_provider_tokens
)
# Create microagent without MCP tools
repo_microagent = RepoMicroagent(
name='simple_agent',
content='Simple agent without MCP tools',
metadata=MicroagentMetadata(
name='simple_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source='test_source',
type=MicroagentType.REPO_KNOWLEDGE,
)
mock_provider_handler = MagicMock()
mock_repository = Repository(
id='123456',
@@ -910,26 +789,6 @@ class TestGetRepositoryMicroagents:
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create microagent without MCP tools
repo_microagent = RepoMicroagent(
name='simple_agent',
content='Simple agent without MCP tools',
metadata=MicroagentMetadata(
name='simple_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'simple_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
# Mock load_microagents_from_dir
mock_repo_agents = {'simple_agent': repo_microagent}
mock_knowledge_agents = {}
@@ -949,225 +808,5 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in data[0]
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'github'
assert 'path' in data[0]
assert data[0]['path'] == '.openhands/microagents/simple_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.mark.asyncio
@patch(
'openhands.server.routes.git._get_file_creation_time',
return_value=datetime.now(),
)
@patch('openhands.server.routes.git.tempfile.mkdtemp')
@patch('openhands.server.routes.git.load_microagents_from_dir')
@patch('openhands.server.routes.git.subprocess.run')
@patch('openhands.server.routes.git.ProviderHandler')
async def test_get_microagents_path_field_variations(
self,
mock_provider_handler_class,
mock_subprocess_run,
mock_load_microagents,
mock_mkdtemp,
mock_get_file_creation_time,
test_client,
mock_provider_tokens,
):
"""Test path field with different microagent file locations and structures."""
# Setup mocks
test_client.app.dependency_overrides[get_provider_tokens] = (
lambda: mock_provider_tokens
)
mock_provider_handler = MagicMock()
mock_repository = Repository(
id='123456',
full_name='test/repo',
git_provider=ProviderType.GITHUB,
is_public=True,
stargazers_count=100,
)
mock_provider_handler.verify_repo_provider = AsyncMock(
return_value=mock_repository
)
mock_provider_handler.get_authenticated_git_url = AsyncMock(
return_value='https://ghp_test_token@github.com/test/repo.git'
)
mock_provider_handler_class.return_value = mock_provider_handler
# Mock subprocess.run for successful clone
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = ''
mock_subprocess_run.return_value = mock_result
# Create temporary directory with microagents
temp_dir = tempfile.mkdtemp()
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create microagents with different source paths
repo_microagent_deep = RepoMicroagent(
name='deep_agent',
content='Agent in nested directory',
metadata=MicroagentMetadata(
name='deep_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'nested'
/ 'deep_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
knowledge_microagent_root = KnowledgeMicroagent(
name='root_agent',
content='Agent in root microagents directory',
metadata=MicroagentMetadata(
name='root_agent',
type=MicroagentType.KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source=str(
Path(temp_dir) / 'repo' / '.openhands' / 'microagents' / 'root_agent.md'
),
type=MicroagentType.KNOWLEDGE,
triggers=[],
)
# Mock load_microagents_from_dir
mock_repo_agents = {'deep_agent': repo_microagent_deep}
mock_knowledge_agents = {'root_agent': knowledge_microagent_root}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
try:
# Execute test
response = test_client.get('/api/user/repository/test/repo/microagents')
# Assertions
assert response.status_code == 200
data = response.json()
assert len(data) == 2
# Check repo microagent with nested path
repo_agent = next(m for m in data if m['name'] == 'deep_agent')
assert repo_agent['type'] == 'repo'
assert 'path' in repo_agent
assert repo_agent['path'] == '.openhands/microagents/nested/deep_agent.md'
# Check knowledge microagent with root path
knowledge_agent = next(m for m in data if m['name'] == 'root_agent')
assert knowledge_agent['type'] == 'knowledge'
assert 'path' in knowledge_agent
assert knowledge_agent['path'] == '.openhands/microagents/root_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.mark.asyncio
@patch(
'openhands.server.routes.git._get_file_creation_time',
return_value=datetime.now(),
)
@patch('openhands.server.routes.git.tempfile.mkdtemp')
@patch('openhands.server.routes.git.load_microagents_from_dir')
@patch('openhands.server.routes.git.subprocess.run')
@patch('openhands.server.routes.git.ProviderHandler')
async def test_get_microagents_path_field_gitlab_structure(
self,
mock_provider_handler_class,
mock_subprocess_run,
mock_load_microagents,
mock_mkdtemp,
mock_get_file_creation_time,
test_client,
mock_provider_tokens,
):
"""Test path field with GitLab repository structure (openhands-config)."""
# Setup mocks with GitLab provider
provider_tokens = MappingProxyType(
{
ProviderType.GITLAB: ProviderToken(
token=SecretStr('glpat_test_token'), host='gitlab.com'
)
}
)
test_client.app.dependency_overrides[get_provider_tokens] = (
lambda: provider_tokens
)
mock_provider_handler = MagicMock()
mock_repository = Repository(
id='123456',
full_name='test/openhands-config',
git_provider=ProviderType.GITLAB,
is_public=True,
stargazers_count=100,
)
mock_provider_handler.verify_repo_provider = AsyncMock(
return_value=mock_repository
)
mock_provider_handler.get_authenticated_git_url = AsyncMock(
return_value='https://glpat_test_token@gitlab.com/test/openhands-config.git'
)
mock_provider_handler_class.return_value = mock_provider_handler
# Mock subprocess.run for successful clone
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = ''
mock_subprocess_run.return_value = mock_result
# Create temporary directory with GitLab structure
temp_dir = tempfile.mkdtemp()
microagents_dir = Path(temp_dir) / 'repo' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create microagent for GitLab structure
repo_microagent = RepoMicroagent(
name='gitlab_agent',
content='Agent in GitLab repository',
metadata=MicroagentMetadata(
name='gitlab_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source=str(Path(temp_dir) / 'repo' / 'microagents' / 'gitlab_agent.md'),
type=MicroagentType.REPO_KNOWLEDGE,
)
# Mock load_microagents_from_dir
mock_repo_agents = {'gitlab_agent': repo_microagent}
mock_knowledge_agents = {}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
try:
# Execute test
response = test_client.get(
'/api/user/repository/test/openhands-config/microagents'
)
# Assertions
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]['name'] == 'gitlab_agent'
assert data[0]['type'] == 'repo'
assert 'path' in data[0]
assert data[0]['path'] == 'microagents/gitlab_agent.md'
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'gitlab'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
+167
View File
@@ -0,0 +1,167 @@
import os
import shutil
import subprocess
import tempfile
import unittest
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
class TestGitHandlerMergeFix(unittest.TestCase):
"""Test the fix for git changes showing merged files as user changes."""
def setUp(self):
# Create temporary directories for our test repositories
self.test_dir = tempfile.mkdtemp()
self.origin_dir = os.path.join(self.test_dir, 'origin')
self.local_dir = os.path.join(self.test_dir, 'local')
# Create the directories
os.makedirs(self.origin_dir, exist_ok=True)
os.makedirs(self.local_dir, exist_ok=True)
# Track executed commands for verification
self.executed_commands = []
# Initialize the GitHandler with our real execute function
self.git_handler = GitHandler(self._execute_command)
self.git_handler.set_cwd(self.local_dir)
# Set up the git repositories
self._setup_git_repos()
def tearDown(self):
# Clean up the temporary directories
shutil.rmtree(self.test_dir)
def _execute_command(self, cmd, cwd=None):
"""Execute a shell command and return the result."""
self.executed_commands.append((cmd, cwd))
try:
result = subprocess.run(
cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False
)
return CommandResult(result.stdout, result.returncode)
except Exception as e:
return CommandResult(str(e), 1)
def _setup_git_repos(self):
"""Set up git repositories for testing the merge scenario."""
# Set up origin repository
self._execute_command('git init --initial-branch=main', self.origin_dir)
self._execute_command(
"git config user.email 'test@example.com'", self.origin_dir
)
self._execute_command("git config user.name 'Test User'", self.origin_dir)
# Create initial file and commit
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
f.write('Initial content\n')
self._execute_command('git add file1.txt', self.origin_dir)
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
# Clone to local
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
self._execute_command(
"git config user.email 'test@example.com'", self.local_dir
)
self._execute_command("git config user.name 'Test User'", self.local_dir)
# Create a feature branch
self._execute_command('git checkout -b feature-branch', self.local_dir)
# Make some changes on feature branch
with open(os.path.join(self.local_dir, 'feature_file.txt'), 'w') as f:
f.write('Feature content\n')
self._execute_command('git add feature_file.txt', self.local_dir)
self._execute_command("git commit -m 'Add feature file'", self.local_dir)
# Push feature branch to origin
self._execute_command('git push -u origin feature-branch', self.local_dir)
# Now simulate main branch getting ahead
# Switch to main in origin and add more commits
self._execute_command('git checkout main', self.origin_dir)
with open(os.path.join(self.origin_dir, 'main_file1.txt'), 'w') as f:
f.write('Main content 1\n')
self._execute_command('git add main_file1.txt', self.origin_dir)
self._execute_command("git commit -m 'Add main file 1'", self.origin_dir)
with open(os.path.join(self.origin_dir, 'main_file2.txt'), 'w') as f:
f.write('Main content 2\n')
self._execute_command('git add main_file2.txt', self.origin_dir)
self._execute_command("git commit -m 'Add main file 2'", self.origin_dir)
def test_git_changes_before_merge(self):
"""Test that git changes shows no changes before merge."""
changes = self.git_handler.get_git_changes()
self.assertEqual(changes, [])
def test_git_changes_after_merge_shows_only_user_changes(self):
"""Test that git changes after merge shows only user changes, not merged files."""
# First, fetch latest changes from main
self._execute_command('git fetch origin', self.local_dir)
# Merge main into feature branch
self._execute_command('git merge origin/main', self.local_dir)
# Clear executed commands to start fresh for the git handler calls
self.executed_commands = []
# Get git changes after merge
changes = self.git_handler.get_git_changes()
# Should only show the feature file, not the merged files
self.assertIsNotNone(changes)
self.assertEqual(len(changes), 1)
self.assertEqual(changes[0]['path'], 'feature_file.txt')
self.assertEqual(changes[0]['status'], 'A')
# Verify that the merged files are not shown as changes
paths = [change['path'] for change in changes]
self.assertNotIn('main_file1.txt', paths)
self.assertNotIn('main_file2.txt', paths)
def test_divergence_detection_after_merge(self):
"""Test that divergence detection correctly identifies merge scenarios."""
# Before merge, should not detect divergence
has_diverged_before = (
self.git_handler._has_diverged_from_remote_tracking_branch(
'feature-branch', 'main'
)
)
self.assertFalse(has_diverged_before)
# Fetch and merge
self._execute_command('git fetch origin', self.local_dir)
self._execute_command('git merge origin/main', self.local_dir)
# After merge, should detect divergence
has_diverged_after = self.git_handler._has_diverged_from_remote_tracking_branch(
'feature-branch', 'main'
)
self.assertTrue(has_diverged_after)
def test_valid_ref_selection_after_merge(self):
"""Test that _get_valid_ref selects merge-base after detecting divergence."""
# Fetch and merge
self._execute_command('git fetch origin', self.local_dir)
self._execute_command('git merge origin/main', self.local_dir)
# Clear executed commands to start fresh
self.executed_commands = []
# Get valid ref
valid_ref = self.git_handler._get_valid_ref()
# Should use merge-base instead of remote tracking branch
self.assertIsNotNone(valid_ref)
self.assertIn('merge-base', valid_ref)
self.assertNotEqual(valid_ref, 'origin/feature-branch')
if __name__ == '__main__':
unittest.main()