mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
chore: Move GH requests to the server (#6217)
This commit is contained in:
parent
295c6fd629
commit
bbd31b32f3
@ -1,47 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { retrieveLatestGitHubCommit } from "../../src/api/github";
|
||||
|
||||
describe("retrieveLatestGitHubCommit", () => {
|
||||
const { githubGetMock } = vi.hoisted(() => ({
|
||||
githubGetMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/api/github-axios-instance", () => ({
|
||||
github: {
|
||||
get: githubGetMock,
|
||||
},
|
||||
}));
|
||||
|
||||
it("should return the latest commit when repository has commits", async () => {
|
||||
const mockCommit = {
|
||||
sha: "123abc",
|
||||
commit: {
|
||||
message: "Initial commit",
|
||||
},
|
||||
};
|
||||
|
||||
githubGetMock.mockResolvedValueOnce({
|
||||
data: [mockCommit],
|
||||
});
|
||||
|
||||
const result = await retrieveLatestGitHubCommit("user/repo");
|
||||
expect(result).toEqual(mockCommit);
|
||||
});
|
||||
|
||||
it("should return null when repository is empty", async () => {
|
||||
const error = new Error("Repository is empty");
|
||||
(error as any).response = { status: 409 };
|
||||
githubGetMock.mockRejectedValueOnce(error);
|
||||
|
||||
const result = await retrieveLatestGitHubCommit("user/empty-repo");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should throw error for other error cases", async () => {
|
||||
const error = new Error("Network error");
|
||||
(error as any).response = { status: 500 };
|
||||
githubGetMock.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(retrieveLatestGitHubCommit("user/repo")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { GitHubRepositorySelector } from "#/components/features/github/github-repo-selector";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import * as GitHubAPI from "#/api/github";
|
||||
|
||||
describe("GitHubRepositorySelector", () => {
|
||||
const onInputChangeMock = vi.fn();
|
||||
@ -60,8 +59,8 @@ describe("GitHubRepositorySelector", () => {
|
||||
];
|
||||
|
||||
const searchPublicRepositoriesSpy = vi.spyOn(
|
||||
GitHubAPI,
|
||||
"searchPublicRepositories",
|
||||
OpenHands,
|
||||
"searchGitHubRepositories",
|
||||
);
|
||||
searchPublicRepositoriesSpy.mockResolvedValue(mockSearchedRepos);
|
||||
|
||||
|
||||
@ -1,19 +1,6 @@
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { github } from "./github-axios-instance";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
|
||||
/**
|
||||
* Given the user, retrieves app installations IDs for OpenHands Github App
|
||||
* Uses user access token for Github App
|
||||
*/
|
||||
export const retrieveGitHubAppInstallations = async (): Promise<number[]> => {
|
||||
const response = await github.get<GithubAppInstallation>(
|
||||
"/user/installations",
|
||||
);
|
||||
|
||||
return response.data.installations.map((installation) => installation.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves repositories where OpenHands Github App has been installed
|
||||
* @param installationIndex Pagination cursor position for app installation IDs
|
||||
@ -82,72 +69,3 @@ export const retrieveGitHubUserRepositories = async (
|
||||
|
||||
return { data: response.data, nextPage };
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a GitHub token, retrieves the authenticated user
|
||||
* @returns The authenticated user or an error response
|
||||
*/
|
||||
export const retrieveGitHubUser = async () => {
|
||||
const response = await github.get<GitHubUser>("/user");
|
||||
|
||||
const { data } = response;
|
||||
|
||||
const user: GitHubUser = {
|
||||
id: data.id,
|
||||
login: data.login,
|
||||
avatar_url: data.avatar_url,
|
||||
company: data.company,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
};
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export const searchPublicRepositories = async (
|
||||
query: string,
|
||||
per_page = 5,
|
||||
sort: "" | "updated" | "stars" | "forks" = "stars",
|
||||
order: "desc" | "asc" = "desc",
|
||||
): Promise<GitHubRepository[]> => {
|
||||
const response = await github.get<{ items: GitHubRepository[] }>(
|
||||
"/search/repositories",
|
||||
{
|
||||
params: {
|
||||
q: query,
|
||||
per_page,
|
||||
sort,
|
||||
order,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const retrieveLatestGitHubCommit = async (
|
||||
repository: string,
|
||||
): Promise<GitHubCommit | null> => {
|
||||
try {
|
||||
const response = await github.get<GitHubCommit[]>(
|
||||
`/repos/${repository}/commits`,
|
||||
{
|
||||
params: {
|
||||
per_page: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data[0] || null;
|
||||
} catch (error) {
|
||||
if (!error || typeof error !== "object") {
|
||||
throw new Error("Unknown error occurred");
|
||||
}
|
||||
const axiosError = error as { response?: { status: number } };
|
||||
if (axiosError.response?.status === 409) {
|
||||
// Repository is empty, no commits yet
|
||||
return null;
|
||||
}
|
||||
throw new Error(
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -315,6 +315,45 @@ class OpenHands {
|
||||
const data = await openHands.post("/api/settings", settings);
|
||||
return data.status === 200;
|
||||
}
|
||||
|
||||
static async getGitHubUser(): Promise<GitHubUser> {
|
||||
const response = await openHands.get<GitHubUser>("/api/github/user");
|
||||
|
||||
const { data } = response;
|
||||
|
||||
const user: GitHubUser = {
|
||||
id: data.id,
|
||||
login: data.login,
|
||||
avatar_url: data.avatar_url,
|
||||
company: data.company,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
};
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
static async getGitHubUserInstallationIds(): Promise<number[]> {
|
||||
const response = await openHands.get<number[]>("/api/github/installations");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async searchGitHubRepositories(
|
||||
query: string,
|
||||
per_page = 5,
|
||||
): Promise<GitHubRepository[]> {
|
||||
const response = await openHands.get<{ items: GitHubRepository[] }>(
|
||||
"/api/github/search/repositories",
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
per_page,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.items;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@ -59,7 +59,7 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
<GitHubRepositorySelector
|
||||
onInputChange={setSearchQuery}
|
||||
onSelect={handleSubmit}
|
||||
publicRepositories={searchedRepos}
|
||||
publicRepositories={searchedRepos || []}
|
||||
userRepositories={repositories}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
import { retrieveGitHubAppInstallations } from "#/api/github";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useAppInstallations = () => {
|
||||
const { data: config } = useConfig();
|
||||
@ -9,10 +9,7 @@ export const useAppInstallations = () => {
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["installations", gitHubToken, config?.GITHUB_CLIENT_ID],
|
||||
queryFn: async () => {
|
||||
const data = await retrieveGitHubAppInstallations();
|
||||
return data;
|
||||
},
|
||||
queryFn: OpenHands.getGitHubUserInstallationIds,
|
||||
enabled:
|
||||
!!gitHubToken &&
|
||||
!!config?.GITHUB_CLIENT_ID &&
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { retrieveGitHubUser } from "#/api/github";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useGitHubUser = () => {
|
||||
const { gitHubToken, setUserId } = useAuth();
|
||||
@ -11,7 +11,7 @@ export const useGitHubUser = () => {
|
||||
|
||||
const user = useQuery({
|
||||
queryKey: ["user", gitHubToken],
|
||||
queryFn: retrieveGitHubUser,
|
||||
queryFn: OpenHands.getGitHubUser,
|
||||
enabled: !!gitHubToken && !!config?.APP_MODE,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { retrieveLatestGitHubCommit } from "#/api/github";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
interface UseLatestRepoCommitConfig {
|
||||
repository: string | null;
|
||||
}
|
||||
|
||||
export const useLatestRepoCommit = (config: UseLatestRepoCommitConfig) => {
|
||||
const { gitHubToken } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["latest_commit", gitHubToken, config.repository],
|
||||
queryFn: () => retrieveLatestGitHubCommit(config.repository!),
|
||||
enabled: !!gitHubToken && !!config.repository,
|
||||
});
|
||||
};
|
||||
@ -1,12 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { searchPublicRepositories } from "#/api/github";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export function useSearchRepositories(query: string) {
|
||||
return useQuery({
|
||||
queryKey: ["repositories", query],
|
||||
queryFn: () => searchPublicRepositories(query, 3),
|
||||
queryFn: () => OpenHands.searchGitHubRepositories(query, 3),
|
||||
enabled: !!query,
|
||||
select: (data) => data.map((repo) => ({ ...repo, is_public: true })),
|
||||
initialData: [],
|
||||
});
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ from fastapi import (
|
||||
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
|
||||
from openhands.server.middleware import (
|
||||
AttachConversationMiddleware,
|
||||
GitHubTokenMiddleware,
|
||||
InMemoryRateLimiter,
|
||||
LocalhostCORSMiddleware,
|
||||
NoCacheMiddleware,
|
||||
@ -44,6 +45,7 @@ app.add_middleware(
|
||||
allow_headers=['*'],
|
||||
)
|
||||
|
||||
app.add_middleware(GitHubTokenMiddleware)
|
||||
app.add_middleware(NoCacheMiddleware)
|
||||
app.add_middleware(
|
||||
RateLimitMiddleware, rate_limiter=InMemoryRateLimiter(requests=10, seconds=1)
|
||||
|
||||
@ -166,3 +166,16 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
|
||||
await self._detach_session(request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class GitHubTokenMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path.startswith('/api/github'):
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not github_token:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={'error': 'Missing X-GitHub-Token header'},
|
||||
)
|
||||
request.state.github_token = github_token
|
||||
return await call_next(request)
|
||||
|
||||
@ -5,10 +5,10 @@ from fastapi.responses import JSONResponse
|
||||
from openhands.server.shared import openhands_config
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
app = APIRouter(prefix='/api/github')
|
||||
|
||||
|
||||
@app.get('/github/repositories')
|
||||
@app.get('/repositories')
|
||||
async def get_github_repositories(
|
||||
request: Request,
|
||||
page: int = 1,
|
||||
@ -16,11 +16,6 @@ async def get_github_repositories(
|
||||
sort: str = 'pushed',
|
||||
installation_id: int | None = None,
|
||||
):
|
||||
# Extract the GitHub token from the headers
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not github_token:
|
||||
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
|
||||
|
||||
openhands_config.verify_github_repo_list(installation_id)
|
||||
|
||||
# Add query parameters
|
||||
@ -38,10 +33,7 @@ async def get_github_repositories(
|
||||
params['sort'] = sort
|
||||
|
||||
# Set the authorization header with the GitHub token
|
||||
headers = {
|
||||
'Authorization': f'Bearer {github_token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
headers = generate_github_headers(request.state.github_token)
|
||||
|
||||
# Fetch repositories from GitHub
|
||||
try:
|
||||
@ -64,3 +56,88 @@ async def get_github_repositories(
|
||||
json_response.headers['Link'] = response.headers['Link']
|
||||
|
||||
return json_response
|
||||
|
||||
|
||||
@app.get('/user')
|
||||
async def get_github_user(request: Request):
|
||||
headers = generate_github_headers(request.state.github_token)
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
requests.get, 'https://api.github.com/user', headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code if response else 500,
|
||||
detail=f'Error fetching user: {str(e)}',
|
||||
)
|
||||
|
||||
json_response = JSONResponse(content=response.json())
|
||||
response.close()
|
||||
|
||||
return json_response
|
||||
|
||||
|
||||
@app.get('/installations')
|
||||
async def get_github_installation_ids(request: Request):
|
||||
headers = generate_github_headers(request.state.github_token)
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
requests.get, 'https://api.github.com/user/installations', headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code if response else 500,
|
||||
detail=f'Error fetching installations: {str(e)}',
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
ids = [installation['id'] for installation in data['installations']]
|
||||
json_response = JSONResponse(content=ids)
|
||||
response.close()
|
||||
|
||||
return json_response
|
||||
|
||||
|
||||
@app.get('/search/repositories')
|
||||
async def search_github_repositories(
|
||||
request: Request,
|
||||
query: str,
|
||||
per_page: int = 5,
|
||||
sort: str = 'stars',
|
||||
order: str = 'desc',
|
||||
):
|
||||
headers = generate_github_headers(request.state.github_token)
|
||||
params = {
|
||||
'q': query,
|
||||
'per_page': per_page,
|
||||
'sort': sort,
|
||||
'order': order,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
requests.get,
|
||||
'https://api.github.com/search/repositories',
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code if response else 500,
|
||||
detail=f'Error searching repositories: {str(e)}',
|
||||
)
|
||||
|
||||
json_response = JSONResponse(content=response.json())
|
||||
response.close()
|
||||
|
||||
return json_response
|
||||
|
||||
|
||||
def generate_github_headers(token: str) -> dict[str, str]:
|
||||
return {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user