Compare commits

...

2 Commits

Author SHA1 Message Date
openhands
c7e00bd101 Fix TypeScript errors and run build 2025-01-08 18:21:24 +00:00
openhands
45ba481fdf Move GitHub API calls from frontend to backend 2025-01-08 18:01:03 +00:00
8 changed files with 177 additions and 148 deletions

View File

@@ -2,13 +2,13 @@ import { describe, expect, it, vi } from "vitest";
import { retrieveLatestGitHubCommit } from "../../src/api/github";
describe("retrieveLatestGitHubCommit", () => {
const { githubGetMock } = vi.hoisted(() => ({
githubGetMock: vi.fn(),
const { openHandsGetMock } = vi.hoisted(() => ({
openHandsGetMock: vi.fn(),
}));
vi.mock("../../src/api/github-axios-instance", () => ({
github: {
get: githubGetMock,
vi.mock("../../src/api/open-hands-axios", () => ({
openHands: {
get: openHandsGetMock,
},
}));
@@ -20,7 +20,7 @@ describe("retrieveLatestGitHubCommit", () => {
},
};
githubGetMock.mockResolvedValueOnce({
openHandsGetMock.mockResolvedValueOnce({
data: [mockCommit],
});
@@ -31,7 +31,7 @@ describe("retrieveLatestGitHubCommit", () => {
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);
openHandsGetMock.mockRejectedValueOnce(error);
const result = await retrieveLatestGitHubCommit("user/empty-repo");
expect(result).toBeNull();
@@ -40,7 +40,7 @@ describe("retrieveLatestGitHubCommit", () => {
it("should throw error for other error cases", async () => {
const error = new Error("Network error");
(error as any).response = { status: 500 };
githubGetMock.mockRejectedValueOnce(error);
openHandsGetMock.mockRejectedValueOnce(error);
await expect(retrieveLatestGitHubCommit("user/repo")).rejects.toThrow();
});

View File

@@ -1,103 +0,0 @@
import axios, { AxiosError } from "axios";
const github = axios.create({
baseURL: "https://api.github.com",
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
const setAuthTokenHeader = (token: string) => {
github.defaults.headers.common.Authorization = `Bearer ${token}`;
};
const removeAuthTokenHeader = () => {
if (github.defaults.headers.common.Authorization) {
delete github.defaults.headers.common.Authorization;
}
};
/**
* Checks if response has attributes to perform refresh
*/
const canRefresh = (error: unknown): boolean =>
!!(
error instanceof AxiosError &&
error.config &&
error.response &&
error.response.status
);
/**
* Checks if the data is a GitHub error response
* @param data The data to check
* @returns Boolean indicating if the data is a GitHub error response
*/
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
data: T | GitHubErrorReponse | null,
): data is GitHubErrorReponse =>
!!data && "message" in data && data.message !== undefined;
// Axios interceptor to handle token refresh
const setupAxiosInterceptors = (
refreshToken: () => Promise<boolean>,
logout: () => void,
) => {
github.interceptors.response.use(
// Pass successful responses through
(response) => {
const parsedData = response.data;
if (isGitHubErrorReponse(parsedData)) {
const error = new AxiosError(
"Failed",
"",
response.config,
response.request,
response,
);
throw error;
}
return response;
},
// Retry request exactly once if token is expired
async (error) => {
if (!canRefresh(error)) {
return Promise.reject(new Error("Failed to refresh token"));
}
const originalRequest = error.config;
// Check if the error is due to an expired token
if (
error.response.status === 401 &&
!originalRequest._retry // Prevent infinite retry loops
) {
originalRequest._retry = true;
try {
const refreshed = await refreshToken();
if (refreshed) {
return await github(originalRequest);
}
logout();
return await Promise.reject(new Error("Failed to refresh token"));
} catch (refreshError) {
// If token refresh fails, evict the user
logout();
return Promise.reject(refreshError);
}
}
// If the error is not due to an expired token, propagate the error
return Promise.reject(error);
},
);
};
export {
github,
setAuthTokenHeader,
removeAuthTokenHeader,
setupAxiosInterceptors,
};

View File

@@ -1,14 +1,18 @@
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { github } from "./github-axios-instance";
import { openHands } from "./open-hands-axios";
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
data: T | GitHubErrorReponse | null,
): data is GitHubErrorReponse =>
!!data && "message" in data && data.message !== undefined;
/**
* 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",
const response = await openHands.get<GithubAppInstallation>(
"/api/github/installations",
);
return response.data.installations.map((installation) => installation.id);
@@ -88,20 +92,8 @@ export const retrieveGitHubUserRepositories = async (
* @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;
const response = await openHands.get<GitHubUser>("/api/github/user");
return response.data;
};
export const searchPublicRepositories = async (
@@ -110,11 +102,11 @@ export const searchPublicRepositories = async (
sort: "" | "updated" | "stars" | "forks" = "stars",
order: "desc" | "asc" = "desc",
): Promise<GitHubRepository[]> => {
const response = await github.get<{ items: GitHubRepository[] }>(
"/search/repositories",
const response = await openHands.get<{ items: GitHubRepository[] }>(
"/api/github/search/repositories",
{
params: {
q: query,
query,
per_page,
sort,
order,
@@ -128,8 +120,9 @@ export const retrieveLatestGitHubCommit = async (
repository: string,
): Promise<GitHubCommit | null> => {
try {
const response = await github.get<GitHubCommit[]>(
`/repos/${repository}/commits`,
const [owner, repo] = repository.split("/");
const response = await openHands.get<GitHubCommit[]>(
`/api/github/repos/${owner}/${repo}/commits`,
{
params: {
per_page: 1,

View File

@@ -5,7 +5,7 @@ import { GitHubRepositorySelector } from "./github-repo-selector";
import { ModalButton } from "#/components/shared/buttons/modal-button";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
import { isGitHubErrorReponse } from "#/api/github";
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";

View File

@@ -2,14 +2,9 @@ import posthog from "posthog-js";
import React from "react";
import OpenHands from "#/api/open-hands";
import {
removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
setGitHubTokenHeader as setOpenHandsGitHubTokenHeader,
removeGitHubTokenHeader,
setGitHubTokenHeader,
} from "#/api/open-hands-axios";
import {
setAuthTokenHeader as setGitHubAuthTokenHeader,
removeAuthTokenHeader as removeGitHubAuthTokenHeader,
setupAxiosInterceptors as setupGithubAxiosInterceptors,
} from "#/api/github-axios-instance";
interface AuthContextType {
gitHubToken: string | null;
@@ -37,8 +32,7 @@ function AuthProvider({ children }: React.PropsWithChildren) {
localStorage.removeItem("ghToken");
localStorage.removeItem("userId");
removeOpenHandsGitHubTokenHeader();
removeGitHubAuthTokenHeader();
removeGitHubTokenHeader();
};
const setGitHubToken = (token: string | null) => {
@@ -46,8 +40,7 @@ function AuthProvider({ children }: React.PropsWithChildren) {
if (token) {
localStorage.setItem("ghToken", token);
setOpenHandsGitHubTokenHeader(token);
setGitHubAuthTokenHeader(token);
setGitHubTokenHeader(token);
} else {
clearGitHubToken();
}
@@ -87,7 +80,6 @@ function AuthProvider({ children }: React.PropsWithChildren) {
setGitHubToken(storedGitHubToken);
setUserId(userId);
setupGithubAxiosInterceptors(refreshToken, logout);
}, []);
const value = React.useMemo(

View File

@@ -9,7 +9,7 @@ import { RootState } from "#/store";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
import { useGitHubUser } from "../../../hooks/query/use-github-user";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
import { isGitHubErrorReponse } from "#/api/github";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
export const useHandleRuntimeActive = () => {

View File

@@ -1,5 +1,7 @@
from typing import Literal
import requests
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from openhands.server.shared import openhands_config
@@ -7,6 +9,9 @@ from openhands.utils.async_utils import call_sync_from_async
app = APIRouter(prefix='/api')
GITHUB_API_BASE = 'https://api.github.com'
GITHUB_API_VERSION = '2022-11-28'
@app.get('/github/repositories')
async def get_github_repositories(
@@ -64,3 +69,143 @@ async def get_github_repositories(
json_response.headers['Link'] = response.headers['Link']
return json_response
@app.get('/github/installations')
async def get_github_installations(request: Request):
"""Get GitHub App installations for the authenticated user"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
try:
response = await call_sync_from_async(
requests.get, f'{GITHUB_API_BASE}/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)}',
)
return JSONResponse(content=response.json())
@app.get('/github/user')
async def get_github_user(request: Request):
"""Get authenticated GitHub user information"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
try:
response = await call_sync_from_async(
requests.get, f'{GITHUB_API_BASE}/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)}',
)
data = response.json()
return JSONResponse(
content={
'id': data['id'],
'login': data['login'],
'avatar_url': data['avatar_url'],
'company': data.get('company'),
'name': data.get('name'),
'email': data.get('email'),
}
)
@app.get('/github/search/repositories')
async def search_github_repositories(
request: Request,
query: str,
per_page: int = Query(default=5, le=100),
sort: Literal['', 'updated', 'stars', 'forks'] = 'stars',
order: Literal['desc', 'asc'] = 'desc',
):
"""Search public GitHub repositories"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
params = {'q': query, 'per_page': str(per_page), 'sort': sort, 'order': order}
try:
response = await call_sync_from_async(
requests.get,
f'{GITHUB_API_BASE}/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)}',
)
return JSONResponse(content=response.json())
@app.get('/github/repos/{owner}/{repo}/commits')
async def get_github_commits(
request: Request, owner: str, repo: str, per_page: int = Query(default=1, le=100)
):
"""Get latest commits for a GitHub repository"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
params = {'per_page': str(per_page)}
try:
response = await call_sync_from_async(
requests.get,
f'{GITHUB_API_BASE}/repos/{owner}/{repo}/commits',
headers=headers,
params=params,
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
response = getattr(e, 'response', None)
if response and response.status_code == 409:
# Repository is empty, no commits yet
return JSONResponse(content=[])
raise HTTPException(
status_code=getattr(getattr(e, 'response', None), 'status_code', 500),
detail=f'Error fetching commits: {str(e)}',
)
return JSONResponse(content=response.json())

View File

@@ -101,6 +101,7 @@ reportlab = "*"
[tool.coverage.run]
concurrency = ["gevent"]
[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
@@ -129,6 +130,7 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"