mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
github-tok
...
move-githu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e00bd101 | ||
|
|
45ba481fdf |
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 = "*"
|
||||
|
||||
Reference in New Issue
Block a user