mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(frontend): Add GitHub Issues/PRs management page
This adds a new page to the OpenHands frontend for managing GitHub issues and pull requests. The page allows users to: 1. View issues and PRs from their connected GitHub account 2. Filter by type (issues, PRs, or all) 3. Filter by assignment (assigned to me, authored by me) 4. Start a new session to work on an issue or PR 5. Resume an existing session if one is already working on that item Features: - View type selector (All/Issues/Pull Requests) - Filter checkboxes for 'assigned to me' and 'authored by me' - Local storage caching with 1-minute refresh interval - Manual refresh button - Start Session button to create a new conversation for an issue/PR - Resume Session button to continue an existing conversation - Status badges showing item state (Open Issue, Open PR, etc.) - Links to view items on GitHub Technical implementation: - New route at /github-issues-prs - API service using existing /api/user/suggested-tasks endpoint - React Query hook with localStorage caching - Sidebar navigation button - Comprehensive unit tests (24 tests) - i18n translations for all UI text Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
6605070d05
commit
ad47b3d590
176
frontend/__tests__/github-issues-prs.test.ts
Normal file
176
frontend/__tests__/github-issues-prs.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
264
frontend/__tests__/routes/github-issues-prs.test.tsx
Normal file
264
frontend/__tests__/routes/github-issues-prs.test.tsx
Normal file
@ -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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MemoryRouter initialEntries={["/github-issues-prs"]}>
|
||||
<Routes>
|
||||
<Route path="/github-issues-prs" element={<GitHubIssuesPRsPage />} />
|
||||
<Route
|
||||
path="/conversations/:conversationId"
|
||||
element={<div data-testid="conversation-screen" />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
124
frontend/src/api/github-service/github-issues-prs.api.ts
Normal file
124
frontend/src/api/github-service/github-issues-prs.api.ts
Normal file
@ -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<GitHubItemsResponse> {
|
||||
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;
|
||||
@ -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() {
|
||||
<MicroagentManagementButton
|
||||
disabled={settings?.email_verified === false}
|
||||
/>
|
||||
<GitHubIssuesPRsButton
|
||||
disabled={settings?.email_verified === false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row md:flex-col md:items-center gap-[26px]">
|
||||
|
||||
@ -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 (
|
||||
<TooltipButton
|
||||
tooltip={tooltip}
|
||||
ariaLabel={tooltip}
|
||||
navLinkTo="/github-issues-prs"
|
||||
testId="github-issues-prs-button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PRIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
125
frontend/src/hooks/query/use-github-issues-prs.ts
Normal file
125
frontend/src/hooks/query/use-github-issues-prs.ts
Normal file
@ -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]);
|
||||
};
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
404
frontend/src/routes/github-issues-prs.tsx
Normal file
404
frontend/src/routes/github-issues-prs.tsx
Normal file
@ -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 (
|
||||
<span className="px-2 py-1 text-xs rounded bg-red-500/20 text-red-400">
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$MERGE_CONFLICTS)}
|
||||
</span>
|
||||
);
|
||||
case "FAILING_CHECKS":
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs rounded bg-orange-500/20 text-orange-400">
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$FAILING_CHECKS)}
|
||||
</span>
|
||||
);
|
||||
case "UNRESOLVED_COMMENTS":
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs rounded bg-yellow-500/20 text-yellow-400">
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$UNRESOLVED_COMMENTS)}
|
||||
</span>
|
||||
);
|
||||
case "OPEN_ISSUE":
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs rounded bg-green-500/20 text-green-400">
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$OPEN_ISSUE)}
|
||||
</span>
|
||||
);
|
||||
case "OPEN_PR":
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs rounded bg-blue-500/20 text-blue-400">
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$OPEN_PR)}
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 border border-[#525252] rounded-lg bg-[#25272D] hover:bg-[#2D2F36] transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-neutral-400">
|
||||
{item.repo}#{item.number}
|
||||
</span>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-white truncate mb-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline"
|
||||
>
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$VIEW_ON_GITHUB)}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{relatedConversation ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResumeSession}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||
>
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$RESUME_SESSION)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartSession}
|
||||
disabled={isStarting}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium rounded transition-colors",
|
||||
isStarting
|
||||
? "bg-neutral-600 text-neutral-400 cursor-not-allowed"
|
||||
: "bg-green-600 hover:bg-green-700 text-white",
|
||||
)}
|
||||
>
|
||||
{isStarting ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
t(I18nKey.GITHUB_ISSUES_PRS$START_SESSION)
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIssuesPRsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Filter state
|
||||
const [viewType, setViewType] = React.useState<ViewType>("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<string | null>(
|
||||
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 (
|
||||
<div className="h-full flex flex-col bg-transparent overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-[#525252]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-semibold text-white">
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$TITLE)}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshData}
|
||||
disabled={isFetching}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-medium rounded transition-colors",
|
||||
isFetching
|
||||
? "bg-neutral-600 text-neutral-400 cursor-not-allowed"
|
||||
: "bg-neutral-700 hover:bg-neutral-600 text-white",
|
||||
)}
|
||||
>
|
||||
{isFetching ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
t(I18nKey.GITHUB_ISSUES_PRS$REFRESH)
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* View type selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-neutral-400">
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$VIEW)}:
|
||||
</span>
|
||||
<select
|
||||
value={viewType}
|
||||
onChange={(e) => setViewType(e.target.value as ViewType)}
|
||||
className="px-3 py-1.5 text-sm rounded bg-[#25272D] border border-[#525252] text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="all">{t(I18nKey.GITHUB_ISSUES_PRS$ALL)}</option>
|
||||
<option value="issues">
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$ISSUES)}
|
||||
</option>
|
||||
<option value="prs">{t(I18nKey.GITHUB_ISSUES_PRS$PRS)}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes */}
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={assignedToMe}
|
||||
onChange={(e) => setAssignedToMe(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-[#525252] bg-[#25272D] text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
|
||||
/>
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$ASSIGNED_TO_ME)}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={authoredByMe}
|
||||
onChange={(e) => setAuthoredByMe(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-[#525252] bg-[#25272D] text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
|
||||
/>
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$AUTHORED_BY_ME)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Cache info */}
|
||||
{githubData?.cached_at && (
|
||||
<div className="mt-2 text-xs text-neutral-500">
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$LAST_UPDATED)}:{" "}
|
||||
{new Date(githubData.cached_at).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
{isLoading && !githubData && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && error && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<p className="text-red-400 mb-2">
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$ERROR_LOADING)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshData}
|
||||
className="px-4 py-2 text-sm font-medium rounded bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||
>
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$TRY_AGAIN)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && filteredItems.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<p className="text-neutral-400">
|
||||
{t(I18nKey.GITHUB_ISSUES_PRS$NO_ITEMS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && filteredItems.length > 0 && (
|
||||
<div className="grid gap-4 max-w-4xl mx-auto">
|
||||
{filteredItems.map((item) => {
|
||||
const itemKey = `${item.repo}-${item.number}`;
|
||||
const relatedConversation = findRelatedConversation(item);
|
||||
|
||||
return (
|
||||
<GitHubItemCard
|
||||
key={itemKey}
|
||||
item={item}
|
||||
relatedConversation={relatedConversation}
|
||||
onStartSession={() => handleStartSession(item)}
|
||||
onResumeSession={() =>
|
||||
relatedConversation &&
|
||||
handleResumeSession(relatedConversation)
|
||||
}
|
||||
isStarting={isCreating && startingItemKey === itemKey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GitHubIssuesPRsPage;
|
||||
Loading…
x
Reference in New Issue
Block a user