mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -05:00
feat: hide conversations after PR closure or merge (microagent management) (#10600)
This commit is contained in:
@@ -17,7 +17,7 @@ const mockUseUserProviders = vi.fn();
|
||||
const mockUseGitRepositories = vi.fn();
|
||||
const mockUseConfig = vi.fn();
|
||||
const mockUseRepositoryMicroagents = vi.fn();
|
||||
const mockUseSearchConversations = vi.fn();
|
||||
const mockUseMicroagentManagementConversations = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
@@ -35,8 +35,9 @@ vi.mock("#/hooks/query/use-repository-microagents", () => ({
|
||||
useRepositoryMicroagents: () => mockUseRepositoryMicroagents(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-search-conversations", () => ({
|
||||
useSearchConversations: () => mockUseSearchConversations(),
|
||||
vi.mock("#/hooks/query/use-microagent-management-conversations", () => ({
|
||||
useMicroagentManagementConversations: () =>
|
||||
mockUseMicroagentManagementConversations(),
|
||||
}));
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
@@ -212,7 +213,7 @@ describe("MicroagentManagement", () => {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: mockConversations,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -859,8 +860,8 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
// Search conversations functionality tests
|
||||
describe("Search conversations functionality", () => {
|
||||
it("should call searchConversations API when repository is expanded", async () => {
|
||||
describe("Microagent management conversations functionality", () => {
|
||||
it("should call useMicroagentManagementConversations API when repository is expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
@@ -876,7 +877,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both microagents and conversations to be fetched
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -896,7 +897,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that microagents are displayed
|
||||
@@ -921,7 +922,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
@@ -958,7 +959,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that loading spinner is not displayed
|
||||
@@ -983,7 +984,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that microagent file paths are displayed for microagents
|
||||
@@ -1013,7 +1014,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -1033,7 +1034,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that the learn this repo component is displayed
|
||||
@@ -1050,7 +1051,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [...mockConversations],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -1070,7 +1071,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that conversations are displayed
|
||||
@@ -1093,7 +1094,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -1113,7 +1114,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that microagents are displayed
|
||||
@@ -1131,7 +1132,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
it("should handle error when fetching conversations", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
@@ -1150,7 +1151,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for the error to be handled
|
||||
await waitFor(() => {
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that the learn this repo component is displayed (since conversations failed)
|
||||
@@ -1195,7 +1196,7 @@ describe("MicroagentManagement", () => {
|
||||
expect(learnThisRepo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call searchConversations with correct parameters", async () => {
|
||||
it("should call useMicroagentManagementConversations with correct parameters", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
@@ -1208,9 +1209,9 @@ describe("MicroagentManagement", () => {
|
||||
const repoAccordion = screen.getByTestId("repository-name-tooltip");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for searchConversations to be called
|
||||
// Wait for useMicroagentManagementConversations to be called
|
||||
await waitFor(() => {
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1230,7 +1231,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that conversations display correct information
|
||||
@@ -1257,7 +1258,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to be called for first repo
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that both microagents and conversations are displayed
|
||||
@@ -2391,7 +2392,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -2411,7 +2412,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for microagents and conversations to be fetched
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify the learn this repo trigger is displayed when no microagents exist
|
||||
@@ -2436,7 +2437,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -2491,7 +2492,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -2508,7 +2509,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT show the learn this repo trigger when microagents exist
|
||||
|
||||
@@ -726,6 +726,27 @@ class OpenHands {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getMicroagentManagementConversations(
|
||||
selectedRepository: string,
|
||||
pageId?: string,
|
||||
limit: number = 100,
|
||||
): Promise<Conversation[]> {
|
||||
const params: Record<string, string | number> = {
|
||||
limit,
|
||||
selected_repository: selectedRepository,
|
||||
};
|
||||
|
||||
if (pageId) {
|
||||
params.page_id = pageId;
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/microagent-management/conversations",
|
||||
{ params },
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@heroui/react";
|
||||
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 { useMicroagentManagementConversations } from "#/hooks/query/use-microagent-management-conversations";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { RootState } from "#/store";
|
||||
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
|
||||
@@ -42,9 +42,9 @@ export function MicroagentManagementRepoMicroagents({
|
||||
data: conversations,
|
||||
isLoading: isLoadingConversations,
|
||||
isError: isErrorConversations,
|
||||
} = useSearchConversations(
|
||||
} = useMicroagentManagementConversations(
|
||||
repositoryName,
|
||||
"microagent_management",
|
||||
undefined,
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useMicroagentManagementConversations = (
|
||||
selectedRepository: string,
|
||||
pageId?: string,
|
||||
limit: number = 100,
|
||||
cacheDisabled: boolean = false,
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: [
|
||||
"conversations",
|
||||
"microagent-management",
|
||||
pageId,
|
||||
limit,
|
||||
selectedRepository,
|
||||
],
|
||||
queryFn: () =>
|
||||
OpenHands.getMicroagentManagementConversations(
|
||||
selectedRepository,
|
||||
pageId,
|
||||
limit,
|
||||
),
|
||||
enabled: !!selectedRepository,
|
||||
staleTime: cacheDisabled ? 0 : 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: cacheDisabled ? 0 : 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
@@ -593,6 +593,21 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
# Return the URL to the pull request
|
||||
return data.get('links', {}).get('html', {}).get('href', '')
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific pull request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The pull request number
|
||||
|
||||
Returns:
|
||||
Raw Bitbucket API response for the pull request
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repositories/{repository}/pullrequests/{pr_number}'
|
||||
pr_data, _ = await self._make_request(url)
|
||||
|
||||
return pr_data
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
@@ -628,6 +643,40 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(response, file_path)
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a Bitbucket pull request is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The PR number to check
|
||||
|
||||
Returns:
|
||||
True if PR is active (OPEN), False if closed/merged
|
||||
"""
|
||||
try:
|
||||
pr_details = await self.get_pr_details(repository, pr_number)
|
||||
|
||||
# Bitbucket API response structure
|
||||
# https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-get
|
||||
if 'state' in pr_details:
|
||||
# Bitbucket state values: OPEN, MERGED, DECLINED, SUPERSEDED
|
||||
return pr_details['state'] == 'OPEN'
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine Bitbucket PR status for {repository}#{pr_number}. '
|
||||
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine Bitbucket PR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the PR status, include the conversation to be safe
|
||||
return True
|
||||
|
||||
|
||||
bitbucket_service_cls = os.environ.get(
|
||||
'OPENHANDS_BITBUCKET_SERVICE_CLS',
|
||||
|
||||
@@ -676,6 +676,21 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
# Return the HTML URL of the created PR
|
||||
return response['html_url']
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific pull request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The pull request number
|
||||
|
||||
Returns:
|
||||
Raw GitHub API response for the pull request
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/pulls/{pr_number}'
|
||||
pr_data, _ = await self._make_request(url)
|
||||
|
||||
return pr_data
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
@@ -699,6 +714,42 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(file_content, file_path)
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a GitHub PR is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The PR number to check
|
||||
|
||||
Returns:
|
||||
True if PR is active (open), False if closed/merged
|
||||
"""
|
||||
try:
|
||||
pr_details = await self.get_pr_details(repository, pr_number)
|
||||
|
||||
# GitHub API response structure
|
||||
# https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
|
||||
if 'state' in pr_details:
|
||||
return pr_details['state'] == 'open'
|
||||
elif 'merged' in pr_details and 'closed_at' in pr_details:
|
||||
# Check if PR is merged or closed
|
||||
return not (pr_details['merged'] or pr_details['closed_at'])
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine GitHub PR status for {repository}#{pr_number}. '
|
||||
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine GitHub PR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the PR status, include the conversation to be safe
|
||||
return True
|
||||
|
||||
async def get_issue_or_pr_comments(
|
||||
self, repository: str, issue_number: int, max_comments: int = 10
|
||||
) -> list[Comment]:
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
@@ -626,6 +627,22 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
return response['web_url']
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific merge request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The merge request number (iid)
|
||||
|
||||
Returns:
|
||||
Raw GitLab API response for the merge request
|
||||
"""
|
||||
project_id = self._extract_project_id(repository)
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{pr_number}'
|
||||
mr_data, _ = await self._make_request(url)
|
||||
|
||||
return mr_data
|
||||
|
||||
def _extract_project_id(self, repository: str) -> str:
|
||||
"""Extract project_id from repository name for GitLab API calls.
|
||||
|
||||
@@ -749,6 +766,42 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
return all_comments
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a GitLab merge request is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The merge request number (iid)
|
||||
|
||||
Returns:
|
||||
True if MR is active (opened), False if closed/merged
|
||||
"""
|
||||
try:
|
||||
mr_details = await self.get_pr_details(repository, pr_number)
|
||||
|
||||
# GitLab API response structure
|
||||
# https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr
|
||||
if 'state' in mr_details:
|
||||
return mr_details['state'] == 'opened'
|
||||
elif 'merged_at' in mr_details and 'closed_at' in mr_details:
|
||||
# Check if MR is merged or closed
|
||||
return not (mr_details['merged_at'] or mr_details['closed_at'])
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine GitLab MR status for {repository}#{pr_number}. '
|
||||
f'Response keys: {list(mr_details.keys())}. Assuming MR is active.'
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine GitLab MR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the MR status, include the conversation to be safe
|
||||
return True
|
||||
|
||||
|
||||
gitlab_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITLAB_SERVICE_CLS',
|
||||
|
||||
@@ -613,3 +613,30 @@ class ProviderHandler:
|
||||
remote_url = f'https://{domain}/{repo_name}.git'
|
||||
|
||||
return remote_url
|
||||
|
||||
async def is_pr_open(
|
||||
self, repository: str, pr_number: int, git_provider: ProviderType
|
||||
) -> bool:
|
||||
"""Check if a PR is still active (not closed/merged).
|
||||
|
||||
This method checks the PR status using the provider's service method.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The PR number to check
|
||||
git_provider: The Git provider type for this repository
|
||||
|
||||
Returns:
|
||||
True if PR is active (open), False if closed/merged, True if can't determine
|
||||
"""
|
||||
try:
|
||||
service = self._get_service(git_provider)
|
||||
return await service.is_pr_open(repository, pr_number)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine PR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the PR status, include the conversation to be safe
|
||||
return True
|
||||
|
||||
@@ -520,3 +520,27 @@ class GitService(Protocol):
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific pull request/merge request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format specific to the provider
|
||||
pr_number: The pull request/merge request number
|
||||
|
||||
Returns:
|
||||
Raw API response from the git provider
|
||||
"""
|
||||
...
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a PR is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The PR number to check
|
||||
|
||||
Returns:
|
||||
True if PR is active (open), False if closed/merged
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -79,6 +79,85 @@ from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
app = APIRouter(prefix='/api', dependencies=get_dependencies())
|
||||
|
||||
|
||||
def _filter_conversations_by_age(
|
||||
conversations: list[ConversationMetadata], max_age_seconds: int
|
||||
) -> list:
|
||||
"""Filter conversations by age, removing those older than max_age_seconds.
|
||||
|
||||
Args:
|
||||
conversations: List of conversations to filter
|
||||
max_age_seconds: Maximum age in seconds for conversations to be included
|
||||
|
||||
Returns:
|
||||
List of conversations that meet the age criteria
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
filtered_results = []
|
||||
|
||||
for conversation in conversations:
|
||||
# Skip conversations without created_at or older than max_age
|
||||
if not hasattr(conversation, 'created_at'):
|
||||
continue
|
||||
|
||||
age_seconds = (
|
||||
now - conversation.created_at.replace(tzinfo=timezone.utc)
|
||||
).total_seconds()
|
||||
if age_seconds > max_age_seconds:
|
||||
continue
|
||||
|
||||
filtered_results.append(conversation)
|
||||
|
||||
return filtered_results
|
||||
|
||||
|
||||
async def _build_conversation_result_set(
|
||||
filtered_conversations: list, next_page_id: str | None
|
||||
) -> ConversationInfoResultSet:
|
||||
"""Build a ConversationInfoResultSet from filtered conversations.
|
||||
|
||||
This function handles the common logic of getting conversation IDs, connections,
|
||||
agent loop info, and building the final result set.
|
||||
|
||||
Args:
|
||||
filtered_conversations: List of filtered conversations
|
||||
next_page_id: Next page ID for pagination
|
||||
|
||||
Returns:
|
||||
ConversationInfoResultSet with the processed conversations
|
||||
"""
|
||||
conversation_ids = set(
|
||||
conversation.conversation_id for conversation in filtered_conversations
|
||||
)
|
||||
connection_ids_to_conversation_ids = await conversation_manager.get_connections(
|
||||
filter_to_sids=conversation_ids
|
||||
)
|
||||
agent_loop_info = await conversation_manager.get_agent_loop_info(
|
||||
filter_to_sids=conversation_ids
|
||||
)
|
||||
agent_loop_info_by_conversation_id = {
|
||||
info.conversation_id: info for info in agent_loop_info
|
||||
}
|
||||
|
||||
result = ConversationInfoResultSet(
|
||||
results=await wait_all(
|
||||
_get_conversation_info(
|
||||
conversation=conversation,
|
||||
num_connections=sum(
|
||||
1
|
||||
for conversation_id in connection_ids_to_conversation_ids.values()
|
||||
if conversation_id == conversation.conversation_id
|
||||
),
|
||||
agent_loop_info=agent_loop_info_by_conversation_id.get(
|
||||
conversation.conversation_id
|
||||
),
|
||||
)
|
||||
for conversation in filtered_conversations
|
||||
),
|
||||
next_page_id=next_page_id,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class InitSessionRequest(BaseModel):
|
||||
repository: str | None = None
|
||||
git_provider: ProviderType | None = None
|
||||
@@ -220,22 +299,14 @@ async def search_conversations(
|
||||
) -> ConversationInfoResultSet:
|
||||
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
|
||||
|
||||
# Apply filters at API level
|
||||
filtered_results = []
|
||||
now = datetime.now(timezone.utc)
|
||||
max_age = config.conversation_max_age_seconds
|
||||
|
||||
for conversation in conversation_metadata_result_set.results:
|
||||
# Skip conversations without created_at or older than max_age
|
||||
if not hasattr(conversation, 'created_at'):
|
||||
continue
|
||||
|
||||
age_seconds = (
|
||||
now - conversation.created_at.replace(tzinfo=timezone.utc)
|
||||
).total_seconds()
|
||||
if age_seconds > max_age:
|
||||
continue
|
||||
# Apply age filter first using common function
|
||||
filtered_results = _filter_conversations_by_age(
|
||||
conversation_metadata_result_set.results, config.conversation_max_age_seconds
|
||||
)
|
||||
|
||||
# Apply additional filters
|
||||
final_filtered_results = []
|
||||
for conversation in filtered_results:
|
||||
# Apply repository filter
|
||||
if (
|
||||
selected_repository is not None
|
||||
@@ -250,38 +321,11 @@ async def search_conversations(
|
||||
):
|
||||
continue
|
||||
|
||||
filtered_results.append(conversation)
|
||||
final_filtered_results.append(conversation)
|
||||
|
||||
conversation_ids = set(
|
||||
conversation.conversation_id for conversation in filtered_results
|
||||
return await _build_conversation_result_set(
|
||||
final_filtered_results, conversation_metadata_result_set.next_page_id
|
||||
)
|
||||
connection_ids_to_conversation_ids = await conversation_manager.get_connections(
|
||||
filter_to_sids=conversation_ids
|
||||
)
|
||||
agent_loop_info = await conversation_manager.get_agent_loop_info(
|
||||
filter_to_sids=conversation_ids
|
||||
)
|
||||
agent_loop_info_by_conversation_id = {
|
||||
info.conversation_id: info for info in agent_loop_info
|
||||
}
|
||||
result = ConversationInfoResultSet(
|
||||
results=await wait_all(
|
||||
_get_conversation_info(
|
||||
conversation=conversation,
|
||||
num_connections=sum(
|
||||
1
|
||||
for conversation_id in connection_ids_to_conversation_ids.values()
|
||||
if conversation_id == conversation.conversation_id
|
||||
),
|
||||
agent_loop_info=agent_loop_info_by_conversation_id.get(
|
||||
conversation.conversation_id
|
||||
),
|
||||
)
|
||||
for conversation in filtered_results
|
||||
),
|
||||
next_page_id=conversation_metadata_result_set.next_page_id,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.get('/conversations/{conversation_id}')
|
||||
@@ -725,3 +769,65 @@ def add_experiment_config_for_conversation(
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@app.get('/microagent-management/conversations')
|
||||
async def get_microagent_management_conversations(
|
||||
selected_repository: str,
|
||||
page_id: str | None = None,
|
||||
limit: int = 20,
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
|
||||
) -> ConversationInfoResultSet:
|
||||
"""Get conversations for the microagent management page with pagination support.
|
||||
|
||||
This endpoint returns conversations with conversation_trigger = 'microagent_management'
|
||||
and only includes conversations with active PRs. Pagination is supported.
|
||||
|
||||
Args:
|
||||
page_id: Optional page ID for pagination
|
||||
limit: Maximum number of results per page (default: 20)
|
||||
selected_repository: Optional repository filter to limit results to a specific repository
|
||||
conversation_store: Conversation store dependency
|
||||
provider_tokens: Provider tokens for checking PR status
|
||||
"""
|
||||
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
|
||||
|
||||
# Apply age filter first using common function
|
||||
filtered_results = _filter_conversations_by_age(
|
||||
conversation_metadata_result_set.results, config.conversation_max_age_seconds
|
||||
)
|
||||
|
||||
# Check if the last PR is active (not closed/merged)
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
|
||||
# Apply additional filters
|
||||
final_filtered_results = []
|
||||
for conversation in filtered_results:
|
||||
# Only include microagent_management conversations
|
||||
if conversation.trigger != ConversationTrigger.MICROAGENT_MANAGEMENT:
|
||||
continue
|
||||
|
||||
# Apply repository filter if specified
|
||||
if conversation.selected_repository != selected_repository:
|
||||
continue
|
||||
|
||||
if (
|
||||
conversation.pr_number
|
||||
and len(conversation.pr_number) > 0
|
||||
and conversation.selected_repository
|
||||
and conversation.git_provider
|
||||
and not await provider_handler.is_pr_open(
|
||||
conversation.selected_repository,
|
||||
conversation.pr_number[-1], # Get the last PR number
|
||||
conversation.git_provider,
|
||||
)
|
||||
):
|
||||
# Skip this conversation if the PR is closed/merged
|
||||
continue
|
||||
|
||||
final_filtered_results.append(conversation)
|
||||
|
||||
return await _build_conversation_result_set(
|
||||
final_filtered_results, conversation_metadata_result_set.next_page_id
|
||||
)
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
)
|
||||
from openhands.server.routes.manage_conversations import (
|
||||
get_microagent_management_conversations,
|
||||
)
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_success():
|
||||
"""Test successful retrieval of microagent management conversations."""
|
||||
# Mock data
|
||||
page_id = 'test_page_123'
|
||||
limit = 10
|
||||
selected_repository = 'owner/repo'
|
||||
|
||||
# Create mock conversations
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_2',
|
||||
user_id='user_2',
|
||||
title='Test Conversation 2',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['456'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id='next_page_456')
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id='next_page_456'
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400 # 24 hours
|
||||
|
||||
# Call the function with correct parameter order
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository=selected_repository,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert isinstance(result, ConversationInfoResultSet)
|
||||
assert result.next_page_id == 'next_page_456'
|
||||
|
||||
# Verify conversation store was called correctly
|
||||
mock_conversation_store.search.assert_called_once_with(page_id, limit)
|
||||
|
||||
# Verify provider handler was created with correct tokens
|
||||
mock_provider_handler.is_pr_open.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_no_results():
|
||||
"""Test when no conversations match the criteria."""
|
||||
# Mock conversation store with empty results
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=[], next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
with (
|
||||
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function with required selected_repository parameter
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert isinstance(result, ConversationInfoResultSet)
|
||||
assert result.next_page_id is None
|
||||
assert len(result.results) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_filter_by_repository():
|
||||
"""Test filtering conversations by selected repository."""
|
||||
# Create mock conversations with different repositories
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository='owner/repo1',
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_2',
|
||||
user_id='user_2',
|
||||
title='Test Conversation 2',
|
||||
selected_repository='owner/repo2',
|
||||
git_provider='github',
|
||||
pr_number=['456'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function - only repo1 should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[mock_conversations[0]], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function with repository filter
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo1',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify only conversations from the specified repository are returned
|
||||
assert len(result.results) == 1
|
||||
assert result.results[0].conversation_id == 'conv_1'
|
||||
assert result.results[0].selected_repository == 'owner/repo1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_filter_by_trigger():
|
||||
"""Test that only microagent_management conversations are returned."""
|
||||
# Create mock conversations with different triggers
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_2',
|
||||
user_id='user_2',
|
||||
title='Test Conversation 2',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['456'],
|
||||
trigger=ConversationTrigger.GUI, # Different trigger
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function - only microagent_management should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[mock_conversations[0]], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify only microagent_management conversations are returned
|
||||
assert len(result.results) == 1
|
||||
assert result.results[0].conversation_id == 'conv_1'
|
||||
assert result.results[0].trigger == ConversationTrigger.MICROAGENT_MANAGEMENT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_filter_inactive_pr():
|
||||
"""Test filtering out conversations with inactive PRs."""
|
||||
# Create mock conversations
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_2',
|
||||
user_id='user_2',
|
||||
title='Test Conversation 2',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['456'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler with one active and one inactive PR
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(side_effect=[True, False])
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function - only active PR should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[mock_conversations[0]], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify only conversations with active PRs are returned
|
||||
assert len(result.results) == 1
|
||||
assert result.results[0].conversation_id == 'conv_1'
|
||||
|
||||
# Verify provider handler was called for both PRs
|
||||
assert mock_provider_handler.is_pr_open.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_no_pr_number():
|
||||
"""Test conversations without PR numbers are included."""
|
||||
# Create mock conversations without PR numbers
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=[], # No PR number
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=mock_conversations, next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify conversation without PR number is included
|
||||
assert len(result.results) == 1
|
||||
assert result.results[0].conversation_id == 'conv_1'
|
||||
|
||||
# Verify provider handler was not called (no PR to check)
|
||||
mock_provider_handler.is_pr_open.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_no_repository():
|
||||
"""Test conversations without selected repository are filtered out for PR checks."""
|
||||
# Create mock conversations without repository
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository=None, # No repository
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function - conversation should be filtered out due to repository mismatch
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify conversation without repository is filtered out
|
||||
assert len(result.results) == 0
|
||||
|
||||
# Verify provider handler was not called (no repository for PR check)
|
||||
mock_provider_handler.is_pr_open.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_age_filter():
|
||||
"""Test that conversations are filtered by age."""
|
||||
# Create mock conversations with different ages
|
||||
now = datetime.now(timezone.utc)
|
||||
old_conversation = ConversationMetadata(
|
||||
conversation_id='conv_old',
|
||||
user_id='user_1',
|
||||
title='Old Conversation',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=now.replace(year=now.year - 1), # Very old
|
||||
last_updated_at=now.replace(year=now.year - 1),
|
||||
)
|
||||
|
||||
recent_conversation = ConversationMetadata(
|
||||
conversation_id='conv_recent',
|
||||
user_id='user_2',
|
||||
title='Recent Conversation',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['456'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=now, # Recent
|
||||
last_updated_at=now,
|
||||
)
|
||||
|
||||
mock_conversations = [old_conversation, recent_conversation]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function - only recent conversation should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[recent_conversation], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config with short max age
|
||||
mock_config.conversation_max_age_seconds = 3600 # 1 hour
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify only recent conversation is returned
|
||||
assert len(result.results) == 1
|
||||
assert result.results[0].conversation_id == 'conv_recent'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_pagination():
|
||||
"""Test pagination functionality."""
|
||||
# Mock conversation store with pagination
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=[], next_page_id='next_page_789')
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
with (
|
||||
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id='next_page_789'
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function with pagination parameters
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
page_id='test_page',
|
||||
limit=5,
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify pagination parameters were passed correctly
|
||||
mock_conversation_store.search.assert_called_once_with('test_page', 5)
|
||||
assert result.next_page_id == 'next_page_789'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_default_parameters():
|
||||
"""Test default parameter values."""
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=[], next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
with (
|
||||
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function without parameters (selected_repository is required)
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify default values were used
|
||||
mock_conversation_store.search.assert_called_once_with(None, 20)
|
||||
assert isinstance(result, ConversationInfoResultSet)
|
||||
Reference in New Issue
Block a user