diff --git a/frontend/__tests__/github-issues-prs.test.ts b/frontend/__tests__/github-issues-prs.test.ts new file mode 100644 index 0000000000..b28830a164 --- /dev/null +++ b/frontend/__tests__/github-issues-prs.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import { + useGitHubIssuesPRs, + useRefreshGitHubIssuesPRs, +} from "../src/hooks/query/use-github-issues-prs"; +import { useShouldShowUserFeatures } from "../src/hooks/use-should-show-user-features"; +import GitHubIssuesPRsService from "../src/api/github-service/github-issues-prs.api"; + +// Mock the dependencies +vi.mock("../src/hooks/use-should-show-user-features"); +vi.mock("../src/api/github-service/github-issues-prs.api", () => ({ + default: { + getGitHubItems: vi.fn(), + buildItemUrl: vi.fn(), + }, +})); + +const mockUseShouldShowUserFeatures = vi.mocked(useShouldShowUserFeatures); +const mockGetGitHubItems = vi.mocked(GitHubIssuesPRsService.getGitHubItems); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +describe("useGitHubIssuesPRs", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockUseShouldShowUserFeatures.mockReturnValue(false); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("should be disabled when useShouldShowUserFeatures returns false", () => { + mockUseShouldShowUserFeatures.mockReturnValue(false); + + const { result } = renderHook(() => useGitHubIssuesPRs(), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetching).toBe(false); + }); + + it("should be enabled when useShouldShowUserFeatures returns true", () => { + mockUseShouldShowUserFeatures.mockReturnValue(true); + mockGetGitHubItems.mockResolvedValue({ + items: [], + cached_at: new Date().toISOString(), + }); + + const { result } = renderHook(() => useGitHubIssuesPRs(), { + wrapper: createWrapper(), + }); + + // When enabled, the query should be loading/fetching + expect(result.current.isLoading).toBe(true); + }); + + it("should fetch and return GitHub items", async () => { + mockUseShouldShowUserFeatures.mockReturnValue(true); + const mockItems = [ + { + git_provider: "github" as const, + item_type: "issue" as const, + status: "OPEN_ISSUE" as const, + repo: "test/repo", + number: 1, + title: "Test Issue", + author: "testuser", + assignees: ["testuser"], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + url: "https://github.com/test/repo/issues/1", + }, + ]; + mockGetGitHubItems.mockResolvedValue({ + items: mockItems, + cached_at: new Date().toISOString(), + }); + + const { result } = renderHook(() => useGitHubIssuesPRs(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.items).toEqual(mockItems); + }); + + it("should filter by item type", async () => { + mockUseShouldShowUserFeatures.mockReturnValue(true); + const mockItems = [ + { + git_provider: "github" as const, + item_type: "issue" as const, + status: "OPEN_ISSUE" as const, + repo: "test/repo", + number: 1, + title: "Test Issue", + author: "testuser", + assignees: ["testuser"], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + url: "https://github.com/test/repo/issues/1", + }, + ]; + mockGetGitHubItems.mockResolvedValue({ + items: mockItems, + cached_at: new Date().toISOString(), + }); + + const { result } = renderHook( + () => useGitHubIssuesPRs({ itemType: "issues" }), + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetGitHubItems).toHaveBeenCalledWith({ itemType: "issues" }); + }); +}); + +describe("useRefreshGitHubIssuesPRs", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("should clear localStorage cache when called", () => { + // Set up some cached data + localStorage.setItem( + "github-issues-prs-cache", + JSON.stringify({ + data: { items: [], cached_at: new Date().toISOString() }, + timestamp: Date.now(), + }), + ); + + const { result } = renderHook(() => useRefreshGitHubIssuesPRs(), { + wrapper: createWrapper(), + }); + + // Call the refresh function + result.current(); + + // Check that localStorage was cleared + expect(localStorage.getItem("github-issues-prs-cache")).toBeNull(); + }); +}); + + diff --git a/frontend/__tests__/routes/github-issues-prs.test.tsx b/frontend/__tests__/routes/github-issues-prs.test.tsx new file mode 100644 index 0000000000..5c20609d41 --- /dev/null +++ b/frontend/__tests__/routes/github-issues-prs.test.tsx @@ -0,0 +1,264 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Routes, Route } from "react-router"; +import GitHubIssuesPRsPage from "#/routes/github-issues-prs"; +import GitHubIssuesPRsService from "#/api/github-service/github-issues-prs.api"; +import ConversationService from "#/api/conversation-service/conversation-service.api"; +import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features"; + +// Mock the services +vi.mock("#/api/github-service/github-issues-prs.api", () => ({ + default: { + getGitHubItems: vi.fn(), + buildItemUrl: vi.fn((provider, repo, number, type) => { + if (provider === "github") { + return `https://github.com/${repo}/${type === "issue" ? "issues" : "pull"}/${number}`; + } + return ""; + }), + }, +})); + +vi.mock("#/api/conversation-service/conversation-service.api", () => ({ + default: { + searchConversations: vi.fn(), + createConversation: vi.fn(), + }, +})); + +vi.mock("#/hooks/use-should-show-user-features", () => ({ + useShouldShowUserFeatures: vi.fn(), +})); + +// Mock react-i18next to return the key as the translation +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +const mockGetGitHubItems = vi.mocked(GitHubIssuesPRsService.getGitHubItems); +const mockSearchConversations = vi.mocked( + ConversationService.searchConversations, +); +const mockUseShouldShowUserFeatures = vi.mocked(useShouldShowUserFeatures); + +const renderGitHubIssuesPRsPage = () => + render( + + + + } /> + } + /> + + + , + ); + +const MOCK_GITHUB_ITEMS = [ + { + git_provider: "github" as const, + item_type: "issue" as const, + status: "OPEN_ISSUE" as const, + repo: "test/repo", + number: 1, + title: "Test Issue", + author: "testuser", + assignees: ["testuser"], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + url: "https://github.com/test/repo/issues/1", + }, + { + git_provider: "github" as const, + item_type: "pr" as const, + status: "OPEN_PR" as const, + repo: "test/repo", + number: 2, + title: "Test PR", + author: "testuser", + assignees: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + url: "https://github.com/test/repo/pull/2", + }, +]; + +describe("GitHubIssuesPRsPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + + mockUseShouldShowUserFeatures.mockReturnValue(true); + + mockGetGitHubItems.mockResolvedValue({ + items: MOCK_GITHUB_ITEMS, + cached_at: new Date().toISOString(), + }); + + mockSearchConversations.mockResolvedValue([]); + }); + + it("should render the page title", async () => { + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + expect(screen.getByText("GITHUB_ISSUES_PRS$TITLE")).toBeInTheDocument(); + }); + }); + + it("should render the view type selector", async () => { + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + // The view label has a colon after it + expect(screen.getByText("GITHUB_ISSUES_PRS$VIEW:")).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + }); + + it("should render filter checkboxes", async () => { + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + expect( + screen.getByText("GITHUB_ISSUES_PRS$ASSIGNED_TO_ME"), + ).toBeInTheDocument(); + expect( + screen.getByText("GITHUB_ISSUES_PRS$AUTHORED_BY_ME"), + ).toBeInTheDocument(); + }); + }); + + it("should render the refresh button", async () => { + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + expect(screen.getByText("GITHUB_ISSUES_PRS$REFRESH")).toBeInTheDocument(); + }); + }); + + it("should display GitHub items when loaded", async () => { + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + expect(screen.getByText("Test Issue")).toBeInTheDocument(); + expect(screen.getByText("Test PR")).toBeInTheDocument(); + }); + }); + + it("should display item status badges", async () => { + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + expect( + screen.getByText("GITHUB_ISSUES_PRS$OPEN_ISSUE"), + ).toBeInTheDocument(); + expect(screen.getByText("GITHUB_ISSUES_PRS$OPEN_PR")).toBeInTheDocument(); + }); + }); + + it("should display Start Session buttons for items without related conversations", async () => { + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + const startButtons = screen.getAllByText( + "GITHUB_ISSUES_PRS$START_SESSION", + ); + expect(startButtons.length).toBe(2); + }); + }); + + it("should filter items when view type is changed to issues", async () => { + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + expect(screen.getByText("Test Issue")).toBeInTheDocument(); + expect(screen.getByText("Test PR")).toBeInTheDocument(); + }); + + // Change view type to issues + const select = screen.getByRole("combobox"); + await userEvent.selectOptions(select, "issues"); + + await waitFor(() => { + expect(screen.getByText("Test Issue")).toBeInTheDocument(); + expect(screen.queryByText("Test PR")).not.toBeInTheDocument(); + }); + }); + + it("should filter items when view type is changed to PRs", async () => { + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + expect(screen.getByText("Test Issue")).toBeInTheDocument(); + expect(screen.getByText("Test PR")).toBeInTheDocument(); + }); + + // Change view type to PRs + const select = screen.getByRole("combobox"); + await userEvent.selectOptions(select, "prs"); + + await waitFor(() => { + expect(screen.queryByText("Test Issue")).not.toBeInTheDocument(); + expect(screen.getByText("Test PR")).toBeInTheDocument(); + }); + }); + + it("should display Resume Session button when a related conversation exists", async () => { + mockSearchConversations.mockResolvedValue([ + { + conversation_id: "conv-1", + title: "Working on #1", + selected_repository: "test/repo", + selected_branch: "main", + git_provider: "github", + pr_number: [1], + created_at: "2024-01-01T00:00:00Z", + last_updated_at: "2024-01-01T00:00:00Z", + status: "RUNNING", + runtime_status: null, + url: null, + session_api_key: null, + }, + ]); + + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + expect( + screen.getByText("GITHUB_ISSUES_PRS$RESUME_SESSION"), + ).toBeInTheDocument(); + }); + }); + + it("should show empty state when no items are found", async () => { + mockGetGitHubItems.mockResolvedValue({ + items: [], + cached_at: new Date().toISOString(), + }); + + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + expect( + screen.getByText("GITHUB_ISSUES_PRS$NO_ITEMS"), + ).toBeInTheDocument(); + }); + }); + + it("should display View on GitHub links", async () => { + renderGitHubIssuesPRsPage(); + + await waitFor(() => { + const viewLinks = screen.getAllByText("GITHUB_ISSUES_PRS$VIEW_ON_GITHUB"); + expect(viewLinks.length).toBe(2); + }); + }); +}); diff --git a/frontend/__tests__/services/github-issues-prs-service.test.ts b/frontend/__tests__/services/github-issues-prs-service.test.ts new file mode 100644 index 0000000000..09956549a0 --- /dev/null +++ b/frontend/__tests__/services/github-issues-prs-service.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import GitHubIssuesPRsService from "../../src/api/github-service/github-issues-prs.api"; + +describe("GitHubIssuesPRsService", () => { + describe("buildItemUrl", () => { + it("should build correct GitHub issue URL", () => { + const url = GitHubIssuesPRsService.buildItemUrl( + "github", + "owner/repo", + 123, + "issue", + ); + expect(url).toBe("https://github.com/owner/repo/issues/123"); + }); + + it("should build correct GitHub PR URL", () => { + const url = GitHubIssuesPRsService.buildItemUrl( + "github", + "owner/repo", + 456, + "pr", + ); + expect(url).toBe("https://github.com/owner/repo/pull/456"); + }); + + it("should build correct GitLab issue URL", () => { + const url = GitHubIssuesPRsService.buildItemUrl( + "gitlab", + "owner/repo", + 123, + "issue", + ); + expect(url).toBe("https://gitlab.com/owner/repo/-/issues/123"); + }); + + it("should build correct GitLab MR URL", () => { + const url = GitHubIssuesPRsService.buildItemUrl( + "gitlab", + "owner/repo", + 456, + "pr", + ); + expect(url).toBe("https://gitlab.com/owner/repo/-/merge_requests/456"); + }); + + it("should build correct Bitbucket issue URL", () => { + const url = GitHubIssuesPRsService.buildItemUrl( + "bitbucket", + "owner/repo", + 123, + "issue", + ); + expect(url).toBe("https://bitbucket.org/owner/repo/issues/123"); + }); + + it("should build correct Bitbucket PR URL", () => { + const url = GitHubIssuesPRsService.buildItemUrl( + "bitbucket", + "owner/repo", + 456, + "pr", + ); + expect(url).toBe("https://bitbucket.org/owner/repo/pull-requests/456"); + }); + + it("should return empty string for unknown provider", () => { + const url = GitHubIssuesPRsService.buildItemUrl( + "unknown" as any, + "owner/repo", + 123, + "issue", + ); + expect(url).toBe(""); + }); + }); +}); diff --git a/frontend/src/api/github-service/github-issues-prs.api.ts b/frontend/src/api/github-service/github-issues-prs.api.ts new file mode 100644 index 0000000000..7805c1327c --- /dev/null +++ b/frontend/src/api/github-service/github-issues-prs.api.ts @@ -0,0 +1,124 @@ +import { openHands } from "../open-hands-axios"; +import { Provider } from "#/types/settings"; + +export type GitHubItemType = "issue" | "pr"; + +export type GitHubItemStatus = + | "MERGE_CONFLICTS" + | "FAILING_CHECKS" + | "UNRESOLVED_COMMENTS" + | "OPEN_ISSUE" + | "OPEN_PR"; + +export interface GitHubItem { + git_provider: Provider; + item_type: GitHubItemType; + status: GitHubItemStatus; + repo: string; + number: number; + title: string; + author: string; + assignees: string[]; + created_at: string; + updated_at: string; + url: string; +} + +export interface GitHubItemsFilter { + itemType?: "issues" | "prs" | "all"; + assignedToMe?: boolean; + authoredByMe?: boolean; +} + +export interface GitHubItemsResponse { + items: GitHubItem[]; + cached_at?: string; +} + +/** + * GitHub Issues/PRs Service - Handles fetching GitHub issues and pull requests + */ +class GitHubIssuesPRsService { + /** + * Get GitHub issues and PRs for the authenticated user + * This uses the existing suggested-tasks endpoint and transforms the data + */ + static async getGitHubItems( + filter?: GitHubItemsFilter, + ): Promise { + const { data } = await openHands.get< + Array<{ + git_provider: Provider; + task_type: GitHubItemStatus; + repo: string; + issue_number: number; + title: string; + }> + >("/api/user/suggested-tasks"); + + // Transform the suggested tasks into GitHubItems + const items: GitHubItem[] = data.map((task) => ({ + git_provider: task.git_provider, + item_type: task.task_type === "OPEN_ISSUE" ? "issue" : "pr", + status: task.task_type, + repo: task.repo, + number: task.issue_number, + title: task.title, + author: "", // Not available from suggested-tasks endpoint + assignees: [], // Not available from suggested-tasks endpoint + created_at: new Date().toISOString(), // Not available from suggested-tasks endpoint + updated_at: new Date().toISOString(), // Not available from suggested-tasks endpoint + url: GitHubIssuesPRsService.buildItemUrl( + task.git_provider, + task.repo, + task.issue_number, + task.task_type === "OPEN_ISSUE" ? "issue" : "pr", + ), + })); + + // Apply filters + let filteredItems = items; + + if (filter?.itemType === "issues") { + filteredItems = filteredItems.filter( + (item) => item.item_type === "issue", + ); + } else if (filter?.itemType === "prs") { + filteredItems = filteredItems.filter((item) => item.item_type === "pr"); + } + + // Note: assignedToMe and authoredByMe filters would require additional API data + // For now, the suggested-tasks endpoint already returns: + // - PRs authored by the user + // - Issues assigned to the user + // So these filters are implicitly applied by the backend + + return { + items: filteredItems, + cached_at: new Date().toISOString(), + }; + } + + /** + * Build the URL for a GitHub item + */ + static buildItemUrl( + provider: Provider, + repo: string, + number: number, + itemType: GitHubItemType, + ): string { + if (provider === "github") { + return `https://github.com/${repo}/${itemType === "issue" ? "issues" : "pull"}/${number}`; + } + if (provider === "gitlab") { + return `https://gitlab.com/${repo}/-/${itemType === "issue" ? "issues" : "merge_requests"}/${number}`; + } + if (provider === "bitbucket") { + return `https://bitbucket.org/${repo}/${itemType === "issue" ? "issues" : "pull-requests"}/${number}`; + } + return ""; + } +} + +export default GitHubIssuesPRsService; diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index ea352d046b..d27671c2b0 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -13,6 +13,7 @@ import { useLogout } from "#/hooks/mutation/use-logout"; import { useConfig } from "#/hooks/query/use-config"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; import { MicroagentManagementButton } from "#/components/shared/buttons/microagent-management-button"; +import { GitHubIssuesPRsButton } from "#/components/shared/buttons/github-issues-prs-button"; import { cn } from "#/utils/utils"; export function Sidebar() { @@ -85,6 +86,9 @@ export function Sidebar() { +
diff --git a/frontend/src/components/shared/buttons/github-issues-prs-button.tsx b/frontend/src/components/shared/buttons/github-issues-prs-button.tsx new file mode 100644 index 0000000000..890f7cc208 --- /dev/null +++ b/frontend/src/components/shared/buttons/github-issues-prs-button.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { TooltipButton } from "./tooltip-button"; +import PRIcon from "#/icons/u-pr.svg?react"; + +interface GitHubIssuesPRsButtonProps { + disabled?: boolean; +} + +export function GitHubIssuesPRsButton({ + disabled = false, +}: GitHubIssuesPRsButtonProps) { + const { t } = useTranslation(); + + const tooltip = t(I18nKey.SIDEBAR$GITHUB_ISSUES_PRS); + + return ( + + + + ); +} diff --git a/frontend/src/hooks/query/use-github-issues-prs.ts b/frontend/src/hooks/query/use-github-issues-prs.ts new file mode 100644 index 0000000000..5f8df055c1 --- /dev/null +++ b/frontend/src/hooks/query/use-github-issues-prs.ts @@ -0,0 +1,125 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import React from "react"; +import GitHubIssuesPRsService, { + GitHubItemsFilter, + GitHubItemsResponse, +} from "#/api/github-service/github-issues-prs.api"; +import { useShouldShowUserFeatures } from "../use-should-show-user-features"; + +const CACHE_KEY = "github-issues-prs-cache"; +const CACHE_DURATION_MS = 60 * 1000; // 1 minute + +interface CachedData { + data: GitHubItemsResponse; + timestamp: number; +} + +/** + * Get cached data from localStorage + */ +function getCachedData(): CachedData | null { + try { + const cached = localStorage.getItem(CACHE_KEY); + if (cached) { + const parsed = JSON.parse(cached) as CachedData; + return parsed; + } + } catch { + // Ignore parse errors + } + return null; +} + +/** + * Save data to localStorage cache + */ +function setCachedData(data: GitHubItemsResponse): void { + try { + const cacheEntry: CachedData = { + data, + timestamp: Date.now(), + }; + localStorage.setItem(CACHE_KEY, JSON.stringify(cacheEntry)); + } catch { + // Ignore storage errors + } +} + +/** + * Check if cached data is still valid + */ +function isCacheValid(cached: CachedData | null): boolean { + if (!cached) return false; + return Date.now() - cached.timestamp < CACHE_DURATION_MS; +} + +/** + * Hook to fetch GitHub issues and PRs with local storage caching + */ +export const useGitHubIssuesPRs = (filter?: GitHubItemsFilter) => { + const shouldShowUserFeatures = useShouldShowUserFeatures(); + const queryClient = useQueryClient(); + + // Set up auto-refresh interval + React.useEffect(() => { + if (!shouldShowUserFeatures) return undefined; + + const interval = setInterval(() => { + queryClient.invalidateQueries({ queryKey: ["github-issues-prs"] }); + }, CACHE_DURATION_MS); + + return () => clearInterval(interval); + }, [shouldShowUserFeatures, queryClient]); + + return useQuery({ + queryKey: ["github-issues-prs", filter], + queryFn: async () => { + // Check localStorage cache first + const cached = getCachedData(); + if (isCacheValid(cached)) { + // Return cached data but still fetch in background + return cached!.data; + } + + // Fetch fresh data + const response = await GitHubIssuesPRsService.getGitHubItems(filter); + + // Save to localStorage + setCachedData(response); + + return response; + }, + enabled: shouldShowUserFeatures, + staleTime: CACHE_DURATION_MS, + gcTime: CACHE_DURATION_MS * 5, + // Use cached data as initial data for faster loading + initialData: () => { + const cached = getCachedData(); + if (cached) { + return cached.data; + } + return undefined; + }, + initialDataUpdatedAt: () => { + const cached = getCachedData(); + if (cached) { + return cached.timestamp; + } + return undefined; + }, + }); +}; + +/** + * Hook to manually refresh GitHub issues and PRs data + */ +export const useRefreshGitHubIssuesPRs = () => { + const queryClient = useQueryClient(); + + return React.useCallback(() => { + // Clear localStorage cache + localStorage.removeItem(CACHE_KEY); + // Invalidate React Query cache + queryClient.invalidateQueries({ queryKey: ["github-issues-prs"] }); + }, [queryClient]); +}; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 1b330730d9..f8b33a2af7 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -960,4 +960,25 @@ export enum I18nKey { OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY", CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS", SKILLS_MODAL$TITLE = "SKILLS_MODAL$TITLE", + GITHUB_ISSUES_PRS$TITLE = "GITHUB_ISSUES_PRS$TITLE", + GITHUB_ISSUES_PRS$REFRESH = "GITHUB_ISSUES_PRS$REFRESH", + GITHUB_ISSUES_PRS$VIEW = "GITHUB_ISSUES_PRS$VIEW", + GITHUB_ISSUES_PRS$ALL = "GITHUB_ISSUES_PRS$ALL", + GITHUB_ISSUES_PRS$ISSUES = "GITHUB_ISSUES_PRS$ISSUES", + GITHUB_ISSUES_PRS$PRS = "GITHUB_ISSUES_PRS$PRS", + GITHUB_ISSUES_PRS$ASSIGNED_TO_ME = "GITHUB_ISSUES_PRS$ASSIGNED_TO_ME", + GITHUB_ISSUES_PRS$AUTHORED_BY_ME = "GITHUB_ISSUES_PRS$AUTHORED_BY_ME", + GITHUB_ISSUES_PRS$LAST_UPDATED = "GITHUB_ISSUES_PRS$LAST_UPDATED", + GITHUB_ISSUES_PRS$ERROR_LOADING = "GITHUB_ISSUES_PRS$ERROR_LOADING", + GITHUB_ISSUES_PRS$TRY_AGAIN = "GITHUB_ISSUES_PRS$TRY_AGAIN", + GITHUB_ISSUES_PRS$NO_ITEMS = "GITHUB_ISSUES_PRS$NO_ITEMS", + GITHUB_ISSUES_PRS$MERGE_CONFLICTS = "GITHUB_ISSUES_PRS$MERGE_CONFLICTS", + GITHUB_ISSUES_PRS$FAILING_CHECKS = "GITHUB_ISSUES_PRS$FAILING_CHECKS", + GITHUB_ISSUES_PRS$UNRESOLVED_COMMENTS = "GITHUB_ISSUES_PRS$UNRESOLVED_COMMENTS", + GITHUB_ISSUES_PRS$OPEN_ISSUE = "GITHUB_ISSUES_PRS$OPEN_ISSUE", + GITHUB_ISSUES_PRS$OPEN_PR = "GITHUB_ISSUES_PRS$OPEN_PR", + GITHUB_ISSUES_PRS$VIEW_ON_GITHUB = "GITHUB_ISSUES_PRS$VIEW_ON_GITHUB", + GITHUB_ISSUES_PRS$START_SESSION = "GITHUB_ISSUES_PRS$START_SESSION", + GITHUB_ISSUES_PRS$RESUME_SESSION = "GITHUB_ISSUES_PRS$RESUME_SESSION", + SIDEBAR$GITHUB_ISSUES_PRS = "SIDEBAR$GITHUB_ISSUES_PRS", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index a421de5ddf..761ee73f31 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -15358,5 +15358,341 @@ "es": "Habilidades disponibles", "tr": "Kullanılabilir yetenekler", "uk": "Доступні навички" + }, + "GITHUB_ISSUES_PRS$TITLE": { + "en": "GitHub Issues & Pull Requests", + "ja": "GitHub Issues & Pull Requests", + "zh-CN": "GitHub Issues & Pull Requests", + "zh-TW": "GitHub Issues & Pull Requests", + "ko-KR": "GitHub Issues & Pull Requests", + "no": "GitHub Issues & Pull Requests", + "it": "GitHub Issues & Pull Requests", + "pt": "GitHub Issues & Pull Requests", + "es": "GitHub Issues & Pull Requests", + "ar": "GitHub Issues & Pull Requests", + "fr": "GitHub Issues & Pull Requests", + "tr": "GitHub Issues & Pull Requests", + "de": "GitHub Issues & Pull Requests", + "uk": "GitHub Issues & Pull Requests" + }, + "GITHUB_ISSUES_PRS$REFRESH": { + "en": "Refresh", + "ja": "更新", + "zh-CN": "刷新", + "zh-TW": "重新整理", + "ko-KR": "새로고침", + "no": "Oppdater", + "it": "Aggiorna", + "pt": "Atualizar", + "es": "Actualizar", + "ar": "تحديث", + "fr": "Actualiser", + "tr": "Yenile", + "de": "Aktualisieren", + "uk": "Оновити" + }, + "GITHUB_ISSUES_PRS$VIEW": { + "en": "View", + "ja": "表示", + "zh-CN": "查看", + "zh-TW": "檢視", + "ko-KR": "보기", + "no": "Vis", + "it": "Visualizza", + "pt": "Ver", + "es": "Ver", + "ar": "عرض", + "fr": "Voir", + "tr": "Görüntüle", + "de": "Ansicht", + "uk": "Перегляд" + }, + "GITHUB_ISSUES_PRS$ALL": { + "en": "All", + "ja": "すべて", + "zh-CN": "全部", + "zh-TW": "全部", + "ko-KR": "전체", + "no": "Alle", + "it": "Tutti", + "pt": "Todos", + "es": "Todos", + "ar": "الكل", + "fr": "Tous", + "tr": "Tümü", + "de": "Alle", + "uk": "Всі" + }, + "GITHUB_ISSUES_PRS$ISSUES": { + "en": "Issues", + "ja": "Issues", + "zh-CN": "Issues", + "zh-TW": "Issues", + "ko-KR": "Issues", + "no": "Issues", + "it": "Issues", + "pt": "Issues", + "es": "Issues", + "ar": "Issues", + "fr": "Issues", + "tr": "Issues", + "de": "Issues", + "uk": "Issues" + }, + "GITHUB_ISSUES_PRS$PRS": { + "en": "Pull Requests", + "ja": "Pull Requests", + "zh-CN": "Pull Requests", + "zh-TW": "Pull Requests", + "ko-KR": "Pull Requests", + "no": "Pull Requests", + "it": "Pull Requests", + "pt": "Pull Requests", + "es": "Pull Requests", + "ar": "Pull Requests", + "fr": "Pull Requests", + "tr": "Pull Requests", + "de": "Pull Requests", + "uk": "Pull Requests" + }, + "GITHUB_ISSUES_PRS$ASSIGNED_TO_ME": { + "en": "Assigned to me", + "ja": "自分に割り当て", + "zh-CN": "分配给我", + "zh-TW": "指派給我", + "ko-KR": "나에게 할당됨", + "no": "Tildelt meg", + "it": "Assegnato a me", + "pt": "Atribuído a mim", + "es": "Asignado a mí", + "ar": "مُعيَّن لي", + "fr": "Assigné à moi", + "tr": "Bana atanan", + "de": "Mir zugewiesen", + "uk": "Призначено мені" + }, + "GITHUB_ISSUES_PRS$AUTHORED_BY_ME": { + "en": "Authored by me", + "ja": "自分が作成", + "zh-CN": "我创建的", + "zh-TW": "我建立的", + "ko-KR": "내가 작성함", + "no": "Opprettet av meg", + "it": "Creato da me", + "pt": "Criado por mim", + "es": "Creado por mí", + "ar": "من إنشائي", + "fr": "Créé par moi", + "tr": "Benim tarafımdan oluşturulan", + "de": "Von mir erstellt", + "uk": "Створено мною" + }, + "GITHUB_ISSUES_PRS$LAST_UPDATED": { + "en": "Last updated", + "ja": "最終更新", + "zh-CN": "最后更新", + "zh-TW": "最後更新", + "ko-KR": "마지막 업데이트", + "no": "Sist oppdatert", + "it": "Ultimo aggiornamento", + "pt": "Última atualização", + "es": "Última actualización", + "ar": "آخر تحديث", + "fr": "Dernière mise à jour", + "tr": "Son güncelleme", + "de": "Zuletzt aktualisiert", + "uk": "Останнє оновлення" + }, + "GITHUB_ISSUES_PRS$ERROR_LOADING": { + "en": "Failed to load GitHub items", + "ja": "GitHubアイテムの読み込みに失敗しました", + "zh-CN": "加载GitHub项目失败", + "zh-TW": "載入GitHub項目失敗", + "ko-KR": "GitHub 항목 로드 실패", + "no": "Kunne ikke laste GitHub-elementer", + "it": "Impossibile caricare gli elementi GitHub", + "pt": "Falha ao carregar itens do GitHub", + "es": "Error al cargar elementos de GitHub", + "ar": "فشل في تحميل عناصر GitHub", + "fr": "Échec du chargement des éléments GitHub", + "tr": "GitHub öğeleri yüklenemedi", + "de": "GitHub-Elemente konnten nicht geladen werden", + "uk": "Не вдалося завантажити елементи GitHub" + }, + "GITHUB_ISSUES_PRS$TRY_AGAIN": { + "en": "Try Again", + "ja": "再試行", + "zh-CN": "重试", + "zh-TW": "重試", + "ko-KR": "다시 시도", + "no": "Prøv igjen", + "it": "Riprova", + "pt": "Tentar novamente", + "es": "Intentar de nuevo", + "ar": "حاول مرة أخرى", + "fr": "Réessayer", + "tr": "Tekrar dene", + "de": "Erneut versuchen", + "uk": "Спробувати знову" + }, + "GITHUB_ISSUES_PRS$NO_ITEMS": { + "en": "No issues or pull requests found", + "ja": "IssueまたはPull Requestが見つかりません", + "zh-CN": "未找到Issue或Pull Request", + "zh-TW": "未找到Issue或Pull Request", + "ko-KR": "Issue 또는 Pull Request를 찾을 수 없습니다", + "no": "Ingen issues eller pull requests funnet", + "it": "Nessun issue o pull request trovato", + "pt": "Nenhum issue ou pull request encontrado", + "es": "No se encontraron issues ni pull requests", + "ar": "لم يتم العثور على issues أو pull requests", + "fr": "Aucun issue ou pull request trouvé", + "tr": "Issue veya pull request bulunamadı", + "de": "Keine Issues oder Pull Requests gefunden", + "uk": "Issues або Pull Requests не знайдено" + }, + "GITHUB_ISSUES_PRS$MERGE_CONFLICTS": { + "en": "Merge Conflicts", + "ja": "マージコンフリクト", + "zh-CN": "合并冲突", + "zh-TW": "合併衝突", + "ko-KR": "병합 충돌", + "no": "Flettekonflikter", + "it": "Conflitti di merge", + "pt": "Conflitos de merge", + "es": "Conflictos de merge", + "ar": "تعارضات الدمج", + "fr": "Conflits de fusion", + "tr": "Birleştirme çakışmaları", + "de": "Merge-Konflikte", + "uk": "Конфлікти злиття" + }, + "GITHUB_ISSUES_PRS$FAILING_CHECKS": { + "en": "Failing Checks", + "ja": "失敗したチェック", + "zh-CN": "检查失败", + "zh-TW": "檢查失敗", + "ko-KR": "실패한 검사", + "no": "Mislykkede sjekker", + "it": "Controlli falliti", + "pt": "Verificações falhando", + "es": "Verificaciones fallidas", + "ar": "فحوصات فاشلة", + "fr": "Vérifications échouées", + "tr": "Başarısız kontroller", + "de": "Fehlgeschlagene Prüfungen", + "uk": "Невдалі перевірки" + }, + "GITHUB_ISSUES_PRS$UNRESOLVED_COMMENTS": { + "en": "Unresolved Comments", + "ja": "未解決のコメント", + "zh-CN": "未解决的评论", + "zh-TW": "未解決的評論", + "ko-KR": "해결되지 않은 댓글", + "no": "Uløste kommentarer", + "it": "Commenti non risolti", + "pt": "Comentários não resolvidos", + "es": "Comentarios sin resolver", + "ar": "تعليقات غير محلولة", + "fr": "Commentaires non résolus", + "tr": "Çözülmemiş yorumlar", + "de": "Ungelöste Kommentare", + "uk": "Невирішені коментарі" + }, + "GITHUB_ISSUES_PRS$OPEN_ISSUE": { + "en": "Open Issue", + "ja": "オープンIssue", + "zh-CN": "开放Issue", + "zh-TW": "開放Issue", + "ko-KR": "열린 Issue", + "no": "Åpen issue", + "it": "Issue aperto", + "pt": "Issue aberto", + "es": "Issue abierto", + "ar": "Issue مفتوح", + "fr": "Issue ouvert", + "tr": "Açık issue", + "de": "Offenes Issue", + "uk": "Відкритий Issue" + }, + "GITHUB_ISSUES_PRS$OPEN_PR": { + "en": "Open PR", + "ja": "オープンPR", + "zh-CN": "开放PR", + "zh-TW": "開放PR", + "ko-KR": "열린 PR", + "no": "Åpen PR", + "it": "PR aperta", + "pt": "PR aberto", + "es": "PR abierto", + "ar": "PR مفتوح", + "fr": "PR ouverte", + "tr": "Açık PR", + "de": "Offener PR", + "uk": "Відкритий PR" + }, + "GITHUB_ISSUES_PRS$VIEW_ON_GITHUB": { + "en": "View on GitHub", + "ja": "GitHubで表示", + "zh-CN": "在GitHub上查看", + "zh-TW": "在GitHub上檢視", + "ko-KR": "GitHub에서 보기", + "no": "Vis på GitHub", + "it": "Visualizza su GitHub", + "pt": "Ver no GitHub", + "es": "Ver en GitHub", + "ar": "عرض على GitHub", + "fr": "Voir sur GitHub", + "tr": "GitHub'da görüntüle", + "de": "Auf GitHub ansehen", + "uk": "Переглянути на GitHub" + }, + "GITHUB_ISSUES_PRS$START_SESSION": { + "en": "Start Session", + "ja": "セッションを開始", + "zh-CN": "开始会话", + "zh-TW": "開始會話", + "ko-KR": "세션 시작", + "no": "Start økt", + "it": "Avvia sessione", + "pt": "Iniciar sessão", + "es": "Iniciar sesión", + "ar": "بدء الجلسة", + "fr": "Démarrer la session", + "tr": "Oturumu başlat", + "de": "Sitzung starten", + "uk": "Почати сеанс" + }, + "GITHUB_ISSUES_PRS$RESUME_SESSION": { + "en": "Resume Session", + "ja": "セッションを再開", + "zh-CN": "恢复会话", + "zh-TW": "恢復會話", + "ko-KR": "세션 재개", + "no": "Gjenoppta økt", + "it": "Riprendi sessione", + "pt": "Retomar sessão", + "es": "Reanudar sesión", + "ar": "استئناف الجلسة", + "fr": "Reprendre la session", + "tr": "Oturumu devam ettir", + "de": "Sitzung fortsetzen", + "uk": "Відновити сеанс" + }, + "SIDEBAR$GITHUB_ISSUES_PRS": { + "en": "Issues & PRs", + "ja": "Issues & PRs", + "zh-CN": "Issues & PRs", + "zh-TW": "Issues & PRs", + "ko-KR": "Issues & PRs", + "no": "Issues & PRs", + "it": "Issues & PRs", + "pt": "Issues & PRs", + "es": "Issues & PRs", + "ar": "Issues & PRs", + "fr": "Issues & PRs", + "tr": "Issues & PRs", + "de": "Issues & PRs", + "uk": "Issues & PRs" } } diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index ecee511688..d2d6ec7cbd 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -21,6 +21,7 @@ export default [ ]), route("conversations/:conversationId", "routes/conversation.tsx"), route("microagent-management", "routes/microagent-management.tsx"), + route("github-issues-prs", "routes/github-issues-prs.tsx"), route("oauth/device/verify", "routes/device-verify.tsx"), ]), ] satisfies RouteConfig; diff --git a/frontend/src/routes/github-issues-prs.tsx b/frontend/src/routes/github-issues-prs.tsx new file mode 100644 index 0000000000..aa494533ea --- /dev/null +++ b/frontend/src/routes/github-issues-prs.tsx @@ -0,0 +1,404 @@ +import React from "react"; +import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { + useGitHubIssuesPRs, + useRefreshGitHubIssuesPRs, +} from "#/hooks/query/use-github-issues-prs"; +import { useSearchConversations } from "#/hooks/query/use-search-conversations"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { + GitHubItem, + GitHubItemsFilter, +} from "#/api/github-service/github-issues-prs.api"; +import { Conversation } from "#/api/open-hands.types"; +import { cn } from "#/utils/utils"; + +type ViewType = "all" | "issues" | "prs"; + +interface GitHubItemCardProps { + item: GitHubItem; + relatedConversation?: Conversation; + onStartSession: () => void; + onResumeSession: () => void; + isStarting: boolean; +} + +function GitHubItemCard({ + item, + relatedConversation, + onStartSession, + onResumeSession, + isStarting, +}: GitHubItemCardProps) { + const { t } = useTranslation(); + + const getStatusBadge = () => { + switch (item.status) { + case "MERGE_CONFLICTS": + return ( + + {t(I18nKey.GITHUB_ISSUES_PRS$MERGE_CONFLICTS)} + + ); + case "FAILING_CHECKS": + return ( + + {t(I18nKey.GITHUB_ISSUES_PRS$FAILING_CHECKS)} + + ); + case "UNRESOLVED_COMMENTS": + return ( + + {t(I18nKey.GITHUB_ISSUES_PRS$UNRESOLVED_COMMENTS)} + + ); + case "OPEN_ISSUE": + return ( + + {t(I18nKey.GITHUB_ISSUES_PRS$OPEN_ISSUE)} + + ); + case "OPEN_PR": + return ( + + {t(I18nKey.GITHUB_ISSUES_PRS$OPEN_PR)} + + ); + default: + return null; + } + }; + + return ( +
+
+
+
+ + {item.repo}#{item.number} + + {getStatusBadge()} +
+

+ {item.title} +

+ + {t(I18nKey.GITHUB_ISSUES_PRS$VIEW_ON_GITHUB)} + +
+
+ {relatedConversation ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function GitHubIssuesPRsPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + // Filter state + const [viewType, setViewType] = React.useState("all"); + const [assignedToMe, setAssignedToMe] = React.useState(true); + const [authoredByMe, setAuthoredByMe] = React.useState(true); + + // Build filter object + const filter: GitHubItemsFilter = React.useMemo( + () => ({ + itemType: viewType, + assignedToMe, + authoredByMe, + }), + [viewType, assignedToMe, authoredByMe], + ); + + // Fetch GitHub items + const { + data: githubData, + isLoading, + error, + isFetching, + } = useGitHubIssuesPRs(filter); + const refreshData = useRefreshGitHubIssuesPRs(); + + // Fetch conversations to find related ones + const { data: conversations } = useSearchConversations( + undefined, + undefined, + 100, + ); + + // Create conversation mutation + const { mutate: createConversation, isPending: isCreating } = + useCreateConversation(); + + // Track which item is being started + const [startingItemKey, setStartingItemKey] = React.useState( + null, + ); + + // Find conversation related to a GitHub item + const findRelatedConversation = React.useCallback( + (item: GitHubItem): Conversation | undefined => { + if (!conversations) return undefined; + + // Look for conversations that match the repository and have the PR number + return conversations.find((conv) => { + // Check if the conversation is for the same repository + if (conv.selected_repository !== item.repo) return false; + + // Check if the conversation has the same PR/issue number in metadata + if (conv.pr_number?.includes(item.number)) { + return true; + } + + // Check if the conversation title contains the issue/PR number + if (conv.title.includes(`#${item.number}`)) { + return true; + } + + return false; + }); + }, + [conversations], + ); + + // Handle starting a new session + const handleStartSession = React.useCallback( + (item: GitHubItem) => { + const itemKey = `${item.repo}-${item.number}`; + setStartingItemKey(itemKey); + + // Build the initial message based on item type + let initialMessage: string; + if (item.item_type === "issue") { + initialMessage = `Please help me resolve issue #${item.number} in the ${item.repo} repository. + +First, understand the issue context by reading the issue description and any comments. Then, work on resolving the issue. If you successfully resolve it, please open a draft PR with the fix. + +Issue: ${item.url}`; + } else { + initialMessage = `Please help me with PR #${item.number} in the ${item.repo} repository. + +First, read the PR description, comments, and check the CI results. Then, address any issues found: +- Fix failing CI checks +- Resolve merge conflicts if any +- Address review comments + +PR: ${item.url}`; + } + + createConversation( + { + query: initialMessage, + repository: { + name: item.repo, + gitProvider: item.git_provider, + }, + }, + { + onSuccess: (response) => { + setStartingItemKey(null); + navigate(`/conversations/${response.conversation_id}`); + }, + onError: () => { + setStartingItemKey(null); + }, + }, + ); + }, + [createConversation, navigate], + ); + + // Handle resuming an existing session + const handleResumeSession = React.useCallback( + (conversation: Conversation) => { + navigate(`/conversations/${conversation.conversation_id}`); + }, + [navigate], + ); + + // Filter items based on view type + const filteredItems = React.useMemo(() => { + if (!githubData?.items) return []; + + let { items } = githubData; + + if (viewType === "issues") { + items = items.filter((item) => item.item_type === "issue"); + } else if (viewType === "prs") { + items = items.filter((item) => item.item_type === "pr"); + } + + return items; + }, [githubData?.items, viewType]); + + return ( +
+ {/* Header */} +
+
+

+ {t(I18nKey.GITHUB_ISSUES_PRS$TITLE)} +

+ +
+ + {/* Filters */} +
+ {/* View type selector */} +
+ + {t(I18nKey.GITHUB_ISSUES_PRS$VIEW)}: + + +
+ + {/* Checkboxes */} + + + +
+ + {/* Cache info */} + {githubData?.cached_at && ( +
+ {t(I18nKey.GITHUB_ISSUES_PRS$LAST_UPDATED)}:{" "} + {new Date(githubData.cached_at).toLocaleTimeString()} +
+ )} +
+ + {/* Content */} +
+ {isLoading && !githubData && ( +
+ +
+ )} + {!isLoading && error && ( +
+

+ {t(I18nKey.GITHUB_ISSUES_PRS$ERROR_LOADING)} +

+ +
+ )} + {!isLoading && !error && filteredItems.length === 0 && ( +
+

+ {t(I18nKey.GITHUB_ISSUES_PRS$NO_ITEMS)} +

+
+ )} + {!isLoading && !error && filteredItems.length > 0 && ( +
+ {filteredItems.map((item) => { + const itemKey = `${item.repo}-${item.number}`; + const relatedConversation = findRelatedConversation(item); + + return ( + handleStartSession(item)} + onResumeSession={() => + relatedConversation && + handleResumeSession(relatedConversation) + } + isStarting={isCreating && startingItemKey === itemKey} + /> + ); + })} +
+ )} +
+
+ ); +} + +export default GitHubIssuesPRsPage;