From d567d227484c83c62f227c048abe2e8c6f8eb4bf Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 23 Jul 2025 01:01:49 +0700 Subject: [PATCH] feat: Handle Click Events for Microagents and Conversations on the Microagent Management Page. (#9853) --- .../microagent-management.test.tsx | 424 +++++++++++++++++- .../microagent-management-content.tsx | 6 +- ...oagent-management-conversation-stopped.tsx | 44 ++ .../microagent-management-default.tsx | 19 + .../microagent-management-error.tsx | 44 ++ .../microagent-management-main.tsx | 61 ++- .../microagent-management-microagent-card.tsx | 112 +++-- .../microagent-management-opening-pr.tsx | 47 ++ ...microagent-management-repo-microagents.tsx | 81 ++-- .../microagent-management-repositories.tsx | 10 +- .../microagent-management-review-pr.tsx | 74 +++ .../microagent-management-sidebar-header.tsx | 9 +- ...ent-management-view-microagent-content.tsx | 73 +++ ...gent-management-view-microagent-header.tsx | 60 +++ .../microagent-management-view-microagent.tsx | 35 ++ frontend/src/components/shared/loader.tsx | 25 ++ frontend/src/i18n/declaration.ts | 9 + frontend/src/i18n/translation.json | 144 ++++++ .../src/state/microagent-management-slice.tsx | 11 +- frontend/src/tailwind.css | 24 + frontend/src/types/microagent-management.tsx | 8 + frontend/src/utils/utils.ts | 65 +++ openhands/server/routes/git.py | 3 + tests/unit/test_get_repository_microagents.py | 393 +++++++++++++++- 24 files changed, 1660 insertions(+), 121 deletions(-) create mode 100644 frontend/src/components/features/microagent-management/microagent-management-conversation-stopped.tsx create mode 100644 frontend/src/components/features/microagent-management/microagent-management-default.tsx create mode 100644 frontend/src/components/features/microagent-management/microagent-management-error.tsx create mode 100644 frontend/src/components/features/microagent-management/microagent-management-opening-pr.tsx create mode 100644 frontend/src/components/features/microagent-management/microagent-management-review-pr.tsx create mode 100644 frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx create mode 100644 frontend/src/components/features/microagent-management/microagent-management-view-microagent-header.tsx create mode 100644 frontend/src/components/features/microagent-management/microagent-management-view-microagent.tsx create mode 100644 frontend/src/components/shared/loader.tsx diff --git a/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx b/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx index 59a4231668..6b6d6b0231 100644 --- a/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx +++ b/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx @@ -6,6 +6,7 @@ import { createRoutesStub } from "react-router"; import React from "react"; import { renderWithProviders } from "test-utils"; import MicroagentManagement from "#/routes/microagent-management"; +import { MicroagentManagementMain } from "#/components/features/microagent-management/microagent-management-main"; import OpenHands from "#/api/open-hands"; import { GitRepository } from "#/types/git"; import { RepositoryMicroagent } from "#/types/microagent-management"; @@ -28,12 +29,12 @@ describe("MicroagentManagement", () => { usage: null, }, microagentManagement: { - selectedMicroagent: null, addMicroagentModalVisible: false, selectedRepository: null, personalRepositories: [], organizationRepositories: [], repositories: [], + selectedMicroagentItem: null, }, }, }); @@ -109,6 +110,7 @@ describe("MicroagentManagement", () => { tools: [], created_at: "2021-10-01T12:00:00Z", git_provider: "github", + path: ".openhands/microagents/test-microagent-1", }, { name: "test-microagent-2", @@ -119,6 +121,7 @@ describe("MicroagentManagement", () => { tools: [], created_at: "2021-10-02T12:00:00Z", git_provider: "github", + path: ".openhands/microagents/test-microagent-2", }, ]; @@ -168,10 +171,6 @@ describe("MicroagentManagement", () => { vi.spyOn(OpenHands, "searchConversations").mockResolvedValue([ ...mockConversations, ]); - // Mock branches to always return a 'main' branch for the modal - vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([ - { name: "main", commit_sha: "abc123", protected: false }, - ]); }); it("should render the microagent management page", async () => { @@ -1234,6 +1233,12 @@ describe("MicroagentManagement", () => { // Add microagent integration tests describe("Add microagent functionality", () => { + beforeEach(() => { + vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([ + { name: "main", commit_sha: "abc123", protected: false }, + ]); + }); + it("should render add microagent button", async () => { renderMicroagentManagement(); @@ -1276,17 +1281,16 @@ describe("MicroagentManagement", () => { usage: null, }, microagentManagement: { - selectedMicroagent: null, + selectedMicroagentItem: null, addMicroagentModalVisible: true, // Start with modal visible selectedRepository: { id: "1", - name: "test-repo", full_name: "user/test-repo", - private: false, git_provider: "github", - default_branch: "main", is_public: true, - } as GitRepository, + owner_type: "user", + pushed_at: "2021-10-01T12:00:00Z", + }, personalRepositories: [], organizationRepositories: [], repositories: [], @@ -1395,9 +1399,10 @@ describe("MicroagentManagement", () => { await waitFor(() => { expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument(); }); - // Wait for the confirm button to be enabled after entering query and branch selection + // Enter query text const queryInput = screen.getByTestId("query-input"); await user.type(queryInput, "Test query"); + // Wait for the confirm button to be enabled after entering query and branch selection await waitFor(() => { const confirmButton = screen.getByTestId("confirm-button"); expect(confirmButton).not.toBeDisabled(); @@ -1457,4 +1462,401 @@ describe("MicroagentManagement", () => { }); }); }); + + // MicroagentManagementMain component tests + describe("MicroagentManagementMain", () => { + const mockRepositoryMicroagent: RepositoryMicroagent = { + name: "test-microagent", + type: "repo", + content: "Test microagent content", + triggers: ["test", "microagent"], + inputs: [], + tools: [], + created_at: "2021-10-01T12:00:00Z", + git_provider: "github", + path: ".openhands/microagents/test-microagent", + }; + + const mockConversationWithPr: Conversation = { + conversation_id: "conv-with-pr", + title: "Test Conversation with PR", + selected_repository: "user/repo2/.openhands", + selected_branch: "main", + git_provider: "github", + last_updated_at: "2021-10-01T12:00:00Z", + created_at: "2021-10-01T12:00:00Z", + status: "RUNNING", + runtime_status: "STATUS$READY", + trigger: "microagent_management", + url: null, + session_api_key: null, + pr_number: [123], + }; + + const mockConversationWithoutPr: Conversation = { + conversation_id: "conv-without-pr", + title: "Test Conversation without PR", + selected_repository: "user/repo2/.openhands", + selected_branch: "main", + git_provider: "github", + last_updated_at: "2021-10-01T12:00:00Z", + created_at: "2021-10-01T12:00:00Z", + status: "RUNNING", + runtime_status: "STATUS$READY", + trigger: "microagent_management", + url: null, + session_api_key: null, + pr_number: [], + }; + + const mockConversationWithNullPr: Conversation = { + conversation_id: "conv-null-pr", + title: "Test Conversation with null PR", + selected_repository: "user/repo2/.openhands", + selected_branch: "main", + git_provider: "github", + last_updated_at: "2021-10-01T12:00:00Z", + created_at: "2021-10-01T12:00:00Z", + status: "RUNNING", + runtime_status: null, + trigger: "microagent_management", + url: null, + session_api_key: null, + pr_number: null, + }; + + const renderMicroagentManagementMain = (selectedMicroagentItem: any) => { + return renderWithProviders(, { + preloadedState: { + metrics: { + cost: null, + max_budget_per_task: null, + usage: null, + }, + microagentManagement: { + addMicroagentModalVisible: false, + selectedRepository: { + id: "1", + full_name: "user/test-repo", + git_provider: "github", + is_public: true, + owner_type: "user", + pushed_at: "2021-10-01T12:00:00Z", + }, + personalRepositories: [], + organizationRepositories: [], + repositories: [], + selectedMicroagentItem, + }, + }, + }); + }; + + it("should render MicroagentManagementDefault when no microagent or conversation is selected", async () => { + renderMicroagentManagementMain(null); + + // Check that the default component is rendered + await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT"); + expect( + screen.getByText( + "MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES", + ), + ).toBeInTheDocument(); + }); + + it("should render MicroagentManagementDefault when selectedMicroagentItem is empty object", async () => { + renderMicroagentManagementMain({}); + + // Check that the default component is rendered + await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT"); + expect( + screen.getByText( + "MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES", + ), + ).toBeInTheDocument(); + }); + + it("should render MicroagentManagementViewMicroagent when microagent is selected", async () => { + renderMicroagentManagementMain({ + microagent: mockRepositoryMicroagent, + conversation: null, + }); + + // Check that the microagent view component is rendered + await screen.findByText("test-microagent"); + expect( + screen.getByText(".openhands/microagents/test-microagent"), + ).toBeInTheDocument(); + }); + + it("should render MicroagentManagementOpeningPr when conversation is selected with empty pr_number array", async () => { + renderMicroagentManagementMain({ + microagent: null, + conversation: mockConversationWithoutPr, + }); + + // Check that the opening PR component is rendered + await screen.findByText( + (content) => content === "COMMON$WORKING_ON_IT!", + { exact: false }, + ); + expect( + screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"), + ).toBeInTheDocument(); + expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(1); + }); + + it("should render MicroagentManagementOpeningPr when conversation is selected with null pr_number", async () => { + const conversationWithNullPr = { + ...mockConversationWithoutPr, + pr_number: null, + }; + renderMicroagentManagementMain({ + microagent: null, + conversation: conversationWithNullPr, + }); + + // Check that the opening PR component is rendered + await screen.findByText( + (content) => content === "COMMON$WORKING_ON_IT!", + { exact: false }, + ); + expect( + screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"), + ).toBeInTheDocument(); + expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(1); + }); + + it("should render MicroagentManagementReviewPr when conversation is selected with non-empty pr_number array", async () => { + renderMicroagentManagementMain({ + microagent: null, + conversation: mockConversationWithPr, + }); + + // Check that the review PR component is rendered + await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY"); + expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2); + }); + + it("should prioritize microagent over conversation when both are present", async () => { + renderMicroagentManagementMain({ + microagent: mockRepositoryMicroagent, + conversation: mockConversationWithPr, + }); + + // Should render the microagent view, not the conversation view + await screen.findByText("test-microagent"); + expect( + screen.getByText(".openhands/microagents/test-microagent"), + ).toBeInTheDocument(); + + // Should NOT render the review PR component + expect( + screen.queryByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY"), + ).not.toBeInTheDocument(); + }); + + it("should handle conversation with undefined pr_number", async () => { + const conversationWithUndefinedPr = { + ...mockConversationWithoutPr, + }; + delete conversationWithUndefinedPr.pr_number; + + renderMicroagentManagementMain({ + microagent: null, + conversation: conversationWithUndefinedPr, + }); + + // Should render the opening PR component (treats undefined as empty array) + await screen.findByText( + (content) => content === "COMMON$WORKING_ON_IT!", + { exact: false }, + ); + expect( + screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"), + ).toBeInTheDocument(); + }); + + it("should handle conversation with multiple PR numbers", async () => { + const conversationWithMultiplePrs = { + ...mockConversationWithPr, + pr_number: [123, 456, 789], + }; + + renderMicroagentManagementMain({ + microagent: null, + conversation: conversationWithMultiplePrs, + }); + + // Should render the review PR component (non-empty array) + await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY"); + expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2); + }); + + it("should handle conversation with empty string pr_number", async () => { + const conversationWithEmptyStringPr = { + ...mockConversationWithoutPr, + pr_number: "", + }; + + renderMicroagentManagementMain({ + microagent: null, + conversation: conversationWithEmptyStringPr, + }); + + // Should render the opening PR component (treats empty string as empty array) + await screen.findByText( + (content) => content === "COMMON$WORKING_ON_IT!", + { exact: false }, + ); + expect( + screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"), + ).toBeInTheDocument(); + }); + + it("should handle conversation with zero pr_number", async () => { + const conversationWithZeroPr = { + ...mockConversationWithoutPr, + pr_number: 0, + }; + + renderMicroagentManagementMain({ + microagent: null, + conversation: conversationWithZeroPr, + }); + + // Should render the opening PR component (treats 0 as falsy) + await screen.findByText( + (content) => content === "COMMON$WORKING_ON_IT!", + { exact: false }, + ); + expect( + screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"), + ).toBeInTheDocument(); + }); + + it("should handle conversation with single PR number as array", async () => { + const conversationWithSinglePr = { + ...mockConversationWithPr, + pr_number: [42], + }; + + renderMicroagentManagementMain({ + microagent: null, + conversation: conversationWithSinglePr, + }); + + // Should render the review PR component (non-empty array) + await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY"); + expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2); + }); + + it("should handle edge case with null selectedMicroagentItem", async () => { + renderMicroagentManagementMain(null); + + // Should render the default component + await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT"); + expect( + screen.getByText( + "MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES", + ), + ).toBeInTheDocument(); + }); + + it("should handle edge case with undefined selectedMicroagentItem", async () => { + renderMicroagentManagementMain(undefined); + + // Should render the default component + await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT"); + expect( + screen.getByText( + "MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES", + ), + ).toBeInTheDocument(); + }); + + it("should handle conversation with missing pr_number property", async () => { + const conversationWithoutPrNumber = { + conversation_id: "conv-no-pr-number", + title: "Test Conversation without PR number property", + selected_repository: "user/repo2/.openhands", + selected_branch: "main", + git_provider: "github", + last_updated_at: "2021-10-01T12:00:00Z", + created_at: "2021-10-01T12:00:00Z", + status: "RUNNING", + runtime_status: "STATUS$READY", + trigger: "microagent_management", + url: null, + session_api_key: null, + // pr_number property is missing + }; + + renderMicroagentManagementMain({ + microagent: null, + conversation: conversationWithoutPrNumber, + }); + + // Should render the opening PR component (undefined pr_number defaults to empty array) + await screen.findByText( + (content) => content === "COMMON$WORKING_ON_IT!", + { exact: false }, + ); + expect( + screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"), + ).toBeInTheDocument(); + }); + + it("should handle microagent with all required properties", async () => { + const completeMicroagent: RepositoryMicroagent = { + name: "complete-microagent", + type: "knowledge", + content: "Complete microagent content with all properties", + triggers: ["complete", "test"], + inputs: ["input1", "input2"], + tools: ["tool1", "tool2"], + created_at: "2021-10-01T12:00:00Z", + git_provider: "github", + path: ".openhands/microagents/complete-microagent", + }; + + renderMicroagentManagementMain({ + microagent: completeMicroagent, + conversation: null, + }); + + // Check that the microagent view component is rendered with complete data + await screen.findByText("complete-microagent"); + expect( + screen.getByText(".openhands/microagents/complete-microagent"), + ).toBeInTheDocument(); + }); + + it("should handle conversation with all required properties", async () => { + const completeConversation: Conversation = { + conversation_id: "complete-conversation", + title: "Complete Conversation", + selected_repository: "user/complete-repo/.openhands", + selected_branch: "main", + git_provider: "github", + last_updated_at: "2021-10-01T12:00:00Z", + created_at: "2021-10-01T12:00:00Z", + status: "RUNNING", + runtime_status: "STATUS$READY", + trigger: "microagent_management", + url: "https://example.com", + session_api_key: "test-api-key", + pr_number: [999], + }; + + renderMicroagentManagementMain({ + microagent: null, + conversation: completeConversation, + }); + + // Check that the review PR component is rendered with complete data + await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY"); + expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2); + }); + }); }); diff --git a/frontend/src/components/features/microagent-management/microagent-management-content.tsx b/frontend/src/components/features/microagent-management/microagent-management-content.tsx index a0e43b97a6..e7f6cb0bfa 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-content.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-content.tsx @@ -178,9 +178,11 @@ export function MicroagentManagementContent() { }; return ( -
+
- +
+ +
{addMicroagentModalVisible && ( state.microagentManagement, + ); + + const { conversation } = selectedMicroagentItem ?? {}; + + const { conversation_id: conversationId } = conversation ?? {}; + + if (!conversationId) { + return null; + } + + return ( +
+
+ {t(I18nKey.MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED)} +
+ + + + {t(I18nKey.MICROAGENT$VIEW_CONVERSATION)} + + +
+ ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-default.tsx b/frontend/src/components/features/microagent-management/microagent-management-default.tsx new file mode 100644 index 0000000000..b13af1fc37 --- /dev/null +++ b/frontend/src/components/features/microagent-management/microagent-management-default.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; + +export function MicroagentManagementDefault() { + const { t } = useTranslation(); + + return ( +
+
+ {t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)} +
+
+ {t( + I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES, + )} +
+
+ ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-error.tsx b/frontend/src/components/features/microagent-management/microagent-management-error.tsx new file mode 100644 index 0000000000..9e2cc40349 --- /dev/null +++ b/frontend/src/components/features/microagent-management/microagent-management-error.tsx @@ -0,0 +1,44 @@ +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 ( +
+
+ {t(I18nKey.MICROAGENT_MANAGEMENT$ERROR)} +
+ + + + {t(I18nKey.MICROAGENT$VIEW_CONVERSATION)} + + +
+ ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-main.tsx b/frontend/src/components/features/microagent-management/microagent-management-main.tsx index 5ad9c1e452..46a42cd992 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-main.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-main.tsx @@ -1,29 +1,52 @@ import { useSelector } from "react-redux"; -import { useTranslation } from "react-i18next"; import { RootState } from "#/store"; -import { I18nKey } from "#/i18n/declaration"; +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"; export function MicroagentManagementMain() { - const { t } = useTranslation(); - - const { selectedMicroagent } = useSelector( + const { selectedMicroagentItem } = useSelector( (state: RootState) => state.microagentManagement, ); - if (!selectedMicroagent) { - return ( -
-
- {t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)} -
-
- {t( - I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES, - )} -
-
- ); + const { microagent, conversation } = selectedMicroagentItem ?? {}; + + if (microagent) { + return ; } - return null; + if (conversation) { + if (conversation.pr_number && conversation.pr_number.length > 0) { + return ; + } + + 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 ; + } + + if (conversation.runtime_status === "STATUS$ERROR") { + return ; + } + + if ( + conversation.status === "STOPPED" || + conversation.runtime_status === "STATUS$STOPPED" + ) { + return ; + } + + return ; + } + + return ; } diff --git a/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx b/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx index 43311623d2..a4e50c97ab 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx @@ -1,43 +1,72 @@ 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 { ConversationStatus } from "#/types/conversation-status"; -import { RuntimeStatus } from "#/types/runtime-status"; +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: { - id: string; - name: string; - createdAt: string; - conversationStatus?: ConversationStatus; - runtimeStatus?: RuntimeStatus; - prNumber?: number[] | null; - }; - showMicroagentFilePath?: boolean; + microagent?: RepositoryMicroagent; + conversation?: Conversation; + repository: GitRepository; } export function MicroagentManagementMicroagentCard({ microagent, - showMicroagentFilePath = true, + conversation, + repository, }: MicroagentManagementMicroagentCardProps) { const { t } = useTranslation(); - const { conversationStatus, runtimeStatus, prNumber } = microagent; + 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 = `.openhands/microagents/${microagent.name}`; + const microagentFilePath = microagent + ? `.openhands/microagents/${microagent.name}` + : ""; // Format the createdAt date using MM/DD/YYYY format - const formattedCreatedAt = formatDateMMDDYYYY(new Date(microagent.createdAt)); + 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; + 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" @@ -47,27 +76,60 @@ export function MicroagentManagementMicroagentCard({ if (runtimeStatus === "STATUS$ERROR") { return t(I18nKey.MICROAGENT$STATUS_ERROR); } - if ( - (conversationStatus === "STARTING" || conversationStatus === "RUNNING") && - runtimeStatus === "STATUS$READY" - ) { + 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)); + }; + return ( -
+
{statusText && (
{statusText}
)} -
- {microagent.name} -
- {showMicroagentFilePath && ( +
{cardTitle}
+ {!!microagent && (
{microagentFilePath}
diff --git a/frontend/src/components/features/microagent-management/microagent-management-opening-pr.tsx b/frontend/src/components/features/microagent-management/microagent-management-opening-pr.tsx new file mode 100644 index 0000000000..eb2d175422 --- /dev/null +++ b/frontend/src/components/features/microagent-management/microagent-management-opening-pr.tsx @@ -0,0 +1,47 @@ +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 ( +
+
+ {t(I18nKey.COMMON$WORKING_ON_IT)}! +
+
+ {t(I18nKey.MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT)} +
+ + + + {t(I18nKey.MICROAGENT$VIEW_CONVERSATION)} + + +
+ ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx b/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx index 86a6a39f9b..d22ae98c10 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx @@ -1,24 +1,34 @@ +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"; - -export interface RepoMicroagent { - id: string; - repositoryName: string; - repositoryUrl: string; -} +import { GitRepository } from "#/types/git"; +import { getGitProviderBaseUrl } from "#/utils/utils"; +import { RootState } from "#/store"; +import { setSelectedMicroagentItem } from "#/state/microagent-management-slice"; interface MicroagentManagementRepoMicroagentsProps { - repoMicroagent: RepoMicroagent; + repository: GitRepository; } export function MicroagentManagementRepoMicroagents({ - repoMicroagent, + repository, }: 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] = repoMicroagent.repositoryName.split("/"); + const [owner, repo] = repositoryName.split("/"); + + const repositoryUrl = `${getGitProviderBaseUrl(gitProvider)}/${repositoryName}`; const { data: microagents, @@ -30,11 +40,28 @@ export function MicroagentManagementRepoMicroagents({ data: conversations, isLoading: isLoadingConversations, isError: isErrorConversations, - } = useSearchConversations( - repoMicroagent.repositoryName, - "microagent_management", - 1000, - ); + } = 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; @@ -54,9 +81,7 @@ export function MicroagentManagementRepoMicroagents({ if (isError) { return (
- +
); } @@ -68,9 +93,7 @@ export function MicroagentManagementRepoMicroagents({ return (
{totalItems === 0 && ( - + )} {/* Render microagents */} @@ -78,11 +101,8 @@ export function MicroagentManagementRepoMicroagents({ microagents?.map((microagent) => (
))} @@ -92,15 +112,8 @@ export function MicroagentManagementRepoMicroagents({ conversations?.map((conversation) => (
))} diff --git a/frontend/src/components/features/microagent-management/microagent-management-repositories.tsx b/frontend/src/components/features/microagent-management/microagent-management-repositories.tsx index 3069afbada..d64f2a4da5 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-repositories.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-repositories.tsx @@ -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 { getGitProviderBaseUrl, cn } from "#/utils/utils"; +import { cn } from "#/utils/utils"; import { TabType } from "#/types/microagent-management"; import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories"; import { I18nKey } from "#/i18n/declaration"; @@ -110,13 +110,7 @@ export function MicroagentManagementRepositories({ } > - + ))} diff --git a/frontend/src/components/features/microagent-management/microagent-management-review-pr.tsx b/frontend/src/components/features/microagent-management/microagent-management-review-pr.tsx new file mode 100644 index 0000000000..428af34620 --- /dev/null +++ b/frontend/src/components/features/microagent-management/microagent-management-review-pr.tsx @@ -0,0 +1,74 @@ +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 ( + + ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-sidebar-header.tsx b/frontend/src/components/features/microagent-management/microagent-management-sidebar-header.tsx index 3bc4c07c86..3e2a9bef60 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-sidebar-header.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-sidebar-header.tsx @@ -1,6 +1,7 @@ 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(); @@ -12,7 +13,13 @@ export function MicroagentManagementSidebarHeader() {

{t(I18nKey.MICROAGENT_MANAGEMENT$USE_MICROAGENTS)} - + + +

); diff --git a/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx new file mode 100644 index 0000000000..ad31c69c31 --- /dev/null +++ b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx @@ -0,0 +1,73 @@ +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 ( +
+ + {transformedContent} + +
+ ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-view-microagent-header.tsx b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-header.tsx new file mode 100644 index 0000000000..20983ef69b --- /dev/null +++ b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-header.tsx @@ -0,0 +1,60 @@ +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 ( +
+ + {selectedRepository.full_name} + +
+ + + {`${t(I18nKey.COMMON$EDIT_IN)} ${getProviderName(selectedRepository.git_provider)}`} + + + {}} + testId="learn-button" + className="py-1 px-2" + > + {t(I18nKey.COMMON$LEARN)} + +
+
+ ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-view-microagent.tsx b/frontend/src/components/features/microagent-management/microagent-management-view-microagent.tsx new file mode 100644 index 0000000000..6256dd5e07 --- /dev/null +++ b/frontend/src/components/features/microagent-management/microagent-management-view-microagent.tsx @@ -0,0 +1,35 @@ +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 ( +
+ + + {microagent.name} + + + {microagent.path} + +
+ +
+
+ ); +} diff --git a/frontend/src/components/shared/loader.tsx b/frontend/src/components/shared/loader.tsx new file mode 100644 index 0000000000..0bd32ea441 --- /dev/null +++ b/frontend/src/components/shared/loader.tsx @@ -0,0 +1,25 @@ +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 ( +
+
+
+ ); +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 4711e829b7..5503973cf5 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -717,4 +717,13 @@ export enum I18nKey { 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", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 0dcb2fb421..a98332527d 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -11470,5 +11470,149 @@ "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": "Розмову зупинено." } } diff --git a/frontend/src/state/microagent-management-slice.tsx b/frontend/src/state/microagent-management-slice.tsx index 3efebb7feb..0b6be4e1f3 100644 --- a/frontend/src/state/microagent-management-slice.tsx +++ b/frontend/src/state/microagent-management-slice.tsx @@ -1,20 +1,18 @@ 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, 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; }, @@ -30,16 +28,19 @@ 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; diff --git a/frontend/src/tailwind.css b/frontend/src/tailwind.css index 9f628ef04f..f38e2bfc76 100644 --- a/frontend/src/tailwind.css +++ b/frontend/src/tailwind.css @@ -20,3 +20,27 @@ .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; + } +} diff --git a/frontend/src/types/microagent-management.tsx b/frontend/src/types/microagent-management.tsx index ae93fa07b2..527eabcc17 100644 --- a/frontend/src/types/microagent-management.tsx +++ b/frontend/src/types/microagent-management.tsx @@ -1,3 +1,5 @@ +import { Conversation } from "#/api/open-hands.types"; + export type TabType = "personal" | "repositories" | "organizations"; export interface RepositoryMicroagent { @@ -9,6 +11,12 @@ export interface RepositoryMicroagent { tools: string[]; created_at: string; git_provider: string; + path: string; +} + +export interface IMicroagentItem { + microagent?: RepositoryMicroagent; + conversation?: Conversation; } export interface MicroagentFormData { diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 07b8c1b341..7a113a8a23 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -142,3 +142,68 @@ export const getPR = (isGitLab: boolean) => * @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 ""; + } +}; diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py index 0d483414f6..afce241b87 100644 --- a/openhands/server/routes/git.py +++ b/openhands/server/routes/git.py @@ -254,6 +254,7 @@ 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: @@ -453,6 +454,7 @@ def _process_microagents( ), created_at=created_at, git_provider=git_provider, + path=str(agent_file_path.relative_to(repo_dir)), ) ) @@ -476,6 +478,7 @@ def _process_microagents( ), created_at=created_at, git_provider=git_provider, + path=str(agent_file_path.relative_to(repo_dir)), ) ) diff --git a/tests/unit/test_get_repository_microagents.py b/tests/unit/test_get_repository_microagents.py index 53d99c6b10..bf26028152 100644 --- a/tests/unit/test_get_repository_microagents.py +++ b/tests/unit/test_get_repository_microagents.py @@ -102,7 +102,7 @@ def mock_repo_microagent(): ] ), ), - source='test_source', + source='.openhands/microagents/test_repo_agent.md', type=MicroagentType.REPO_KNOWLEDGE, ) @@ -128,7 +128,7 @@ def mock_knowledge_microagent(): ] ), ), - source='test_source', + source='.openhands/microagents/test_knowledge_agent.md', type=MicroagentType.KNOWLEDGE, triggers=['test', 'knowledge', 'search'], ) @@ -283,7 +283,72 @@ class TestGetRepositoryMicroagents: mock_result.stderr = '' mock_subprocess_run.return_value = mock_result - mock_load_microagents.return_value = mock_microagents_data + # 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_mkdtemp.return_value = temp_microagents_dir # Execute test @@ -308,6 +373,8 @@ 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') @@ -323,6 +390,10 @@ 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') @@ -555,8 +626,38 @@ class TestGetRepositoryMicroagents: microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents' microagents_dir.mkdir(parents=True, exist_ok=True) - # Mock load_microagents_from_dir - mock_repo_agents = {'test_repo_agent': mock_repo_microagent} + # 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_knowledge_agents = {} mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents) mock_mkdtemp.return_value = temp_dir @@ -574,6 +675,8 @@ 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) @@ -634,8 +737,38 @@ class TestGetRepositoryMicroagents: microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents' microagents_dir.mkdir(parents=True, exist_ok=True) - # Mock load_microagents_from_dir - mock_repo_agents = {'test_repo_agent': mock_repo_microagent} + # 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_knowledge_agents = {} mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents) mock_mkdtemp.return_value = temp_dir @@ -652,6 +785,8 @@ 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) @@ -748,6 +883,33 @@ class TestGetRepositoryMicroagents: 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 microagent without MCP tools repo_microagent = RepoMicroagent( name='simple_agent', @@ -758,10 +920,65 @@ class TestGetRepositoryMicroagents: inputs=[], mcp_tools=None, ), - source='test_source', + 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 = {} + 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) == 1 + assert data[0]['name'] == 'simple_agent' + assert data[0]['tools'] == [] + 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', @@ -789,9 +1006,46 @@ class TestGetRepositoryMicroagents: 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 = {'simple_agent': repo_microagent} - mock_knowledge_agents = {} + 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 @@ -802,11 +1056,118 @@ class TestGetRepositoryMicroagents: # Assertions assert response.status_code == 200 data = response.json() - assert len(data) == 1 - assert data[0]['name'] == 'simple_agent' - assert data[0]['tools'] == [] - assert 'created_at' in data[0] - assert 'git_provider' in data[0] - assert data[0]['git_provider'] == 'github' + 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)