Compare commits

..

1 Commits

Author SHA1 Message Date
openhands 1f7335fc15 feat: add notifications scope to GitHub OAuth defaultScope
Add the 'notifications' scope to the GitHub identity provider's
defaultScope in the Keycloak realm configuration. This enables
agents to read and manage GitHub notifications via the API
(list notifications, mark as read/done).

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 23:34:45 +00:00
33 changed files with 364 additions and 518 deletions
@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
+2 -2
View File
@@ -192,7 +192,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: playwright-report
path: tests/e2e/test-results/
@@ -200,7 +200,7 @@ jobs:
- name: Upload OpenHands logs
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: openhands-logs
path: |
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
working-directory: ./frontend
run: npx playwright test --project=chromium
- name: Upload Playwright report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
+5 -5
View File
@@ -73,7 +73,7 @@ jobs:
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -111,7 +111,7 @@ jobs:
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -169,7 +169,7 @@ jobs:
context: containers/runtime
- name: Upload runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
@@ -196,7 +196,7 @@ jobs:
driver-opts: network=host
- name: Login to GHCR
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -204,7 +204,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ghcr.io/openhands/enterprise-server
tags: |
+1 -1
View File
@@ -269,7 +269,7 @@ jobs:
fi
- name: Upload output.jsonl as artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
if: always() # Upload even if the previous steps fail
with:
name: resolver-output
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
echo "is_fork=false" >> $GITHUB_OUTPUT
fi
- uses: actions/checkout@v6
- uses: actions/checkout@v5
if: steps.check-fork.outputs.is_fork == 'false'
with:
ref: ${{ github.event.pull_request.head.ref }}
@@ -93,7 +93,7 @@ jobs:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Check for .pr/ directory
id: check
+2 -2
View File
@@ -51,7 +51,7 @@ jobs:
# Always checkout main branch for security - cannot test script changes in PRs
- name: Checkout extensions repository
if: steps.check-trace.outputs.trace_exists == 'true'
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
repository: OpenHands/extensions
path: extensions
@@ -77,7 +77,7 @@ jobs:
--trace-file trace-info/laminar_trace_info.json
- name: Upload evaluation logs
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v5
if: always() && steps.check-trace.outputs.trace_exists == 'true'
with:
name: pr-review-evaluation-${{ github.event.pull_request.number }}
+2 -2
View File
@@ -65,7 +65,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: coverage-openhands
path: |
@@ -97,7 +97,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: coverage-enterprise
path: ".coverage.enterprise.${{ matrix.python_version }}"
@@ -1729,7 +1729,7 @@
"syncMode": "IMPORT",
"clientSecret": "$GITHUB_APP_CLIENT_SECRET",
"caseSensitiveOriginalUsername": "false",
"defaultScope": "openid email profile",
"defaultScope": "openid email profile notifications",
"baseUrl": "$GITHUB_BASE_URL"
}
},
@@ -1,12 +1,4 @@
"""Shared Event router for OpenHands Server.
All endpoints in this router are unauthenticated — shared conversations are
public. To avoid returning internal system state that the viewer does not
need, ``ConversationStateUpdateEvent`` instances are filtered out before the
response is sent. The shared-conversation frontend only renders messages,
actions, observations, errors, and hook-execution events; state snapshots
are consumed exclusively by the authenticated WebSocket path.
"""
"""Shared Event router for OpenHands Server."""
from datetime import datetime
from typing import Annotated
@@ -21,15 +13,9 @@ from server.sharing.shared_event_service import (
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.sdk import Event
from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
from openhands.utils.environment import StorageProvider, get_storage_provider
def _is_viewable(event: Event) -> bool:
"""Return True if *event* should be included in public shared responses."""
return not isinstance(event, ConversationStateUpdateEvent)
def get_shared_event_service_injector() -> SharedEventServiceInjector:
"""Get the appropriate SharedEventServiceInjector based on configuration.
@@ -102,7 +88,7 @@ async def search_shared_events(
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> EventPage:
"""Search / List events for a shared conversation."""
page = await shared_event_service.search_shared_events(
return await shared_event_service.search_shared_events(
conversation_id=UUID(conversation_id),
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
@@ -111,10 +97,6 @@ async def search_shared_events(
page_id=page_id,
limit=limit,
)
return EventPage(
items=[e for e in page.items if _is_viewable(e)],
next_page_id=page.next_page_id,
)
@router.get('/count')
@@ -165,7 +147,7 @@ async def batch_get_shared_events(
events = await shared_event_service.batch_get_shared_events(
UUID(conversation_id), event_ids
)
return [e if e is not None and _is_viewable(e) else None for e in events]
return events
@router.get('/{conversation_id}/{event_id}')
@@ -175,9 +157,6 @@ async def get_shared_event(
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> Event | None:
"""Get a single event from a shared conversation by conversation_id and event_id."""
event = await shared_event_service.get_shared_event(
return await shared_event_service.get_shared_event(
UUID(conversation_id), UUID(event_id)
)
if event is not None and not _is_viewable(event):
return None
return event
@@ -1,190 +0,0 @@
"""Tests for ConversationStateUpdateEvent filtering in shared_event_router.
The shared-events endpoints are unauthenticated, so internal system state
(ConversationStateUpdateEvent) must not be returned. The frontend shared-
conversation viewer never renders these events — it only uses messages,
actions, observations, errors, and hook-execution events.
"""
from __future__ import annotations
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from server.sharing.shared_event_router import (
_is_viewable,
batch_get_shared_events,
get_shared_event,
search_shared_events,
)
from openhands.agent_server.models import EventPage
from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
from openhands.sdk.event.llm_convertible import MessageEvent
from openhands.sdk.llm import Message, TextContent
# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------
def _make_message_event() -> MessageEvent:
return MessageEvent(
source='user',
llm_message=Message(role='user', content=[TextContent(text='Hello')]),
)
def _make_state_event(
key: str = 'full_state', value: dict | str = 'idle'
) -> ConversationStateUpdateEvent:
return ConversationStateUpdateEvent(key=key, value=value)
# ---------------------------------------------------------------------------
# _is_viewable
# ---------------------------------------------------------------------------
class TestIsViewable:
def test_message_event_is_viewable(self):
assert _is_viewable(_make_message_event()) is True
def test_full_state_event_is_not_viewable(self):
assert _is_viewable(_make_state_event('full_state', {'agent': {}})) is False
def test_execution_status_event_is_not_viewable(self):
assert _is_viewable(_make_state_event('execution_status', 'running')) is False
def test_stats_event_is_not_viewable(self):
assert _is_viewable(_make_state_event('stats', {})) is False
# ---------------------------------------------------------------------------
# search_shared_events
# ---------------------------------------------------------------------------
class TestSearchSharedEvents:
@pytest.mark.asyncio
async def test_filters_out_state_events(self):
msg = _make_message_event()
state = _make_state_event()
mock_service = AsyncMock()
mock_service.search_shared_events.return_value = EventPage(
items=[msg, state, msg], next_page_id=None
)
result = await search_shared_events(
conversation_id=uuid4().hex,
shared_event_service=mock_service,
)
assert len(result.items) == 2
assert all(
not isinstance(e, ConversationStateUpdateEvent) for e in result.items
)
@pytest.mark.asyncio
async def test_preserves_next_page_id(self):
mock_service = AsyncMock()
mock_service.search_shared_events.return_value = EventPage(
items=[_make_state_event()], next_page_id='abc'
)
result = await search_shared_events(
conversation_id=uuid4().hex,
shared_event_service=mock_service,
)
assert result.next_page_id == 'abc'
assert result.items == []
@pytest.mark.asyncio
async def test_empty_page_unchanged(self):
mock_service = AsyncMock()
mock_service.search_shared_events.return_value = EventPage(
items=[], next_page_id=None
)
result = await search_shared_events(
conversation_id=uuid4().hex,
shared_event_service=mock_service,
)
assert result.items == []
assert result.next_page_id is None
# ---------------------------------------------------------------------------
# batch_get_shared_events
# ---------------------------------------------------------------------------
class TestBatchGetSharedEvents:
@pytest.mark.asyncio
async def test_replaces_state_events_with_none(self):
msg = _make_message_event()
state = _make_state_event()
mock_service = AsyncMock()
mock_service.batch_get_shared_events.return_value = [msg, state, None]
result = await batch_get_shared_events(
conversation_id=uuid4().hex,
id=[uuid4().hex, uuid4().hex, uuid4().hex],
shared_event_service=mock_service,
)
assert len(result) == 3
assert result[0] is msg
assert result[1] is None # state event replaced with None
assert result[2] is None # originally None stays None
# ---------------------------------------------------------------------------
# get_shared_event
# ---------------------------------------------------------------------------
class TestGetSharedEvent:
@pytest.mark.asyncio
async def test_returns_message_event(self):
msg = _make_message_event()
mock_service = AsyncMock()
mock_service.get_shared_event.return_value = msg
result = await get_shared_event(
conversation_id=uuid4().hex,
event_id=uuid4().hex,
shared_event_service=mock_service,
)
assert result is msg
@pytest.mark.asyncio
async def test_returns_none_for_state_event(self):
state = _make_state_event()
mock_service = AsyncMock()
mock_service.get_shared_event.return_value = state
result = await get_shared_event(
conversation_id=uuid4().hex,
event_id=uuid4().hex,
shared_event_service=mock_service,
)
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_not_found(self):
mock_service = AsyncMock()
mock_service.get_shared_event.return_value = None
result = await get_shared_event(
conversation_id=uuid4().hex,
event_id=uuid4().hex,
shared_event_service=mock_service,
)
assert result is None
@@ -90,15 +90,14 @@ describe("RepoConnector", () => {
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the search function that's used by the dropdown
vi.spyOn(GitService, "searchGitRepositories").mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
});
vi.spyOn(GitService, "searchGitRepositories").mockResolvedValue(
MOCK_RESPOSITORIES,
);
renderRepoConnector();
@@ -128,8 +127,8 @@ describe("RepoConnector", () => {
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
@@ -139,11 +138,14 @@ describe("RepoConnector", () => {
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
items: [
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
next_page_id: null,
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
// First select the provider
@@ -197,8 +199,8 @@ describe("RepoConnector", () => {
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
@@ -244,8 +246,8 @@ describe("RepoConnector", () => {
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
@@ -288,8 +290,8 @@ describe("RepoConnector", () => {
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
@@ -345,8 +347,8 @@ describe("RepoConnector", () => {
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
@@ -361,11 +363,14 @@ describe("RepoConnector", () => {
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
items: [
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
next_page_id: null,
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
// First select the provider
@@ -422,17 +427,20 @@ describe("RepoConnector", () => {
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
items: [
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
next_page_id: null,
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
renderRepoConnector();
@@ -187,7 +187,7 @@ describe("RepositorySelectionForm", () => {
},
];
mockUseGitRepositories.mockReturnValue({
data: { pages: [{ items: MOCK_REPOS }] },
data: { pages: [{ data: MOCK_REPOS }] },
isLoading: false,
isError: false,
hasNextPage: false,
@@ -229,7 +229,7 @@ describe("RepositorySelectionForm", () => {
// Create a spy on the API call
const searchGitReposSpy = vi.spyOn(GitService, "searchGitRepositories");
searchGitReposSpy.mockResolvedValue({ items: MOCK_SEARCH_REPOS, next_page_id: null });
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
mockUseGitRepositories.mockReturnValue({
data: { pages: [] },
@@ -267,7 +267,7 @@ describe("RepositorySelectionForm", () => {
];
mockUseGitRepositories.mockReturnValue({
data: { pages: [{ items: MOCK_SEARCH_REPOS }] },
data: { pages: [{ data: MOCK_SEARCH_REPOS }] },
isLoading: false,
isError: false,
hasNextPage: false,
@@ -115,8 +115,8 @@ describe("TaskCard", () => {
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
data: MOCK_RESPOSITORIES,
nextPage: null,
});
});
+21 -12
View File
@@ -228,17 +228,20 @@ describe("HomeScreen", () => {
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
items: [
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
next_page_id: null,
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
renderHomeScreen();
@@ -269,17 +272,20 @@ describe("HomeScreen", () => {
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
items: [
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
next_page_id: null,
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
renderHomeScreen();
@@ -326,11 +332,14 @@ describe("HomeScreen", () => {
// Mock the repository branches API call
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
items: [
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
next_page_id: null,
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
// Select a repository to enable the repo launch button
@@ -362,8 +371,8 @@ describe("HomeScreen", () => {
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
items: MOCK_RESPOSITORIES,
next_page_id: null,
data: MOCK_RESPOSITORIES,
nextPage: null,
});
});
@@ -0,0 +1,9 @@
import { test, expect } from "vitest";
import { isNumber } from "../../src/utils/is-number";
test("isNumber", () => {
expect(isNumber(1)).toBe(true);
expect(isNumber(0)).toBe(true);
expect(isNumber("3")).toBe(true);
expect(isNumber("0")).toBe(true);
});
+24
View File
@@ -1,4 +1,9 @@
import { describe, it, expect, vi, test } from "vitest";
import {
formatTimestamp,
getExtension,
removeApiKey,
} from "../../src/utils/utils";
import { getStatusText } from "#/utils/utils";
import { AgentState } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
@@ -17,6 +22,25 @@ const t = (key: string) => {
return translations[key] || key;
};
test("removeApiKey", () => {
const data = [{ args: { LLM_API_KEY: "key", LANGUAGE: "en" } }];
expect(removeApiKey(data)).toEqual([{ args: { LANGUAGE: "en" } }]);
});
test("getExtension", () => {
expect(getExtension("main.go")).toBe("go");
expect(getExtension("get-extension.test.ts")).toBe("ts");
expect(getExtension("directory")).toBe("");
});
test("formatTimestamp", () => {
const morningDate = new Date("2021-10-10T10:10:10.000").toISOString();
expect(formatTimestamp(morningDate)).toBe("10/10/2021, 10:10:10");
const eveningDate = new Date("2021-10-10T22:10:10.000").toISOString();
expect(formatTimestamp(eveningDate)).toBe("10/10/2021, 22:10:10");
});
describe("getStatusText", () => {
it("returns STOPPING when pausing", () => {
const result = getStatusText({
+94 -95
View File
@@ -1,5 +1,7 @@
import { openHands } from "../open-hands-axios";
import { RepositoryPage, BranchPage, InstallationPage } from "#/types/git";
import { Provider } from "#/types/settings";
import { GitRepository, PaginatedBranchesResponse, Branch } from "#/types/git";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { GitChange, GitChangeDiff } from "../open-hands.types";
import ConversationService from "../conversation-service/conversation-service.api";
@@ -10,122 +12,130 @@ class GitService {
/**
* Search for Git repositories
* @param query Search query
* @param provider Git provider to search in (required)
* @param limit Number of results per page
* @param pageId Cursor for pagination
* @param installationId Filter by installation ID
* @param sortOrder Sort order (asc or desc)
* @returns Paginated repository response
* @param per_page Number of results per page
* @param selected_provider Git provider to search in
* @returns List of matching repositories
*/
static async searchGitRepositories(
query: string,
provider: string,
limit = 100,
pageId?: string,
installationId?: string,
): Promise<RepositoryPage> {
const { data } = await openHands.get<RepositoryPage>(
"/api/v1/git/repositories/search",
per_page = 100,
selected_provider?: Provider,
): Promise<GitRepository[]> {
const response = await openHands.get<GitRepository[]>(
"/api/user/search/repositories",
{
params: {
provider,
query,
limit,
page_id: pageId,
installation_id: installationId,
per_page,
selected_provider,
},
},
);
return data;
return response.data;
}
/**
* Retrieve user's Git repositories
* @param provider Git provider
* @param pageId Cursor for pagination
* @param limit Number of results per page
* @param installationId Filter by installation ID
* @param sortOrder Sort order (asc or desc)
* @param selected_provider Git provider
* @param page Page number
* @param per_page Number of results per page
* @returns User's repositories with pagination info
*/
static async retrieveUserGitRepositories(
provider: string,
pageId?: string,
limit = 30,
installationId?: string,
): Promise<RepositoryPage> {
const { data } = await openHands.get<RepositoryPage>(
"/api/v1/git/repositories/search",
selected_provider: Provider,
page = 1,
per_page = 30,
) {
const { data } = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
provider,
limit,
page_id: pageId,
installation_id: installationId,
selected_provider,
sort: "pushed",
page,
per_page,
},
},
);
return data;
const link =
data.length > 0 && data[0].link_header ? data[0].link_header : "";
const nextPage = extractNextPageFromLink(link);
return { data, nextPage };
}
/**
* Retrieve repositories from a specific installation
* @param provider Git provider
* @param selected_provider Git provider
* @param installationIndex Current installation index
* @param installations List of installation IDs
* @param pageId Cursor for pagination
* @param limit Number of results per page
* @param page Page number
* @param per_page Number of results per page
* @returns Installation repositories with pagination info
*/
static async retrieveInstallationRepositories(
provider: string,
selected_provider: Provider,
installationIndex: number,
installations: string[],
pageId?: string,
limit = 30,
): Promise<RepositoryPage> {
page = 1,
per_page = 30,
) {
const installationId = installations[installationIndex];
const { data } = await openHands.get<RepositoryPage>(
"/api/v1/git/repositories/search",
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
provider,
limit,
page_id: pageId,
selected_provider,
sort: "pushed",
page,
per_page,
installation_id: installationId,
},
},
);
return data;
const link =
response.data.length > 0 && response.data[0].link_header
? response.data[0].link_header
: "";
const nextPage = extractNextPageFromLink(link);
let nextInstallation: number | null;
if (nextPage) {
nextInstallation = installationIndex;
} else if (installationIndex + 1 < installations.length) {
nextInstallation = installationIndex + 1;
} else {
nextInstallation = null;
}
return {
data: response.data,
nextPage,
installationIndex: nextInstallation,
};
}
/**
* Get repository branches
* @param repository Repository name
* @param provider Git provider (required)
* @param query Search query (required - can be empty string)
* @param pageId Cursor for pagination
* @param limit Number of results per page
* @param page Page number
* @param perPage Number of results per page
* @returns Paginated branches response
*/
static async getRepositoryBranches(
repository: string,
provider: string,
query: string = "",
pageId?: string,
limit = 30,
): Promise<BranchPage> {
const { data } = await openHands.get<BranchPage>(
"/api/v1/git/branches/search",
page: number = 1,
perPage: number = 30,
selectedProvider?: Provider,
): Promise<PaginatedBranchesResponse> {
const { data } = await openHands.get<PaginatedBranchesResponse>(
`/api/user/repository/branches`,
{
params: {
provider,
repository,
query,
page_id: pageId,
limit,
page,
per_page: perPage,
selected_provider: selectedProvider,
},
},
);
@@ -135,51 +145,40 @@ class GitService {
/**
* Search repository branches
* @deprecated Use getRepositoryBranches instead - this method is identical
* @param repository Repository name
* @param provider Git provider (required)
* @param query Search query
* @param pageId Cursor for pagination
* @param limit Number of results per page
* @param perPage Number of results per page
* @param selectedProvider Git provider
* @returns List of matching branches
*/
static async searchRepositoryBranches(
repository: string,
provider: string,
query: string,
pageId?: string,
limit = 30,
): Promise<BranchPage> {
return this.getRepositoryBranches(
repository,
provider,
query,
pageId,
limit,
perPage: number = 30,
selectedProvider?: Provider,
): Promise<Branch[]> {
const { data } = await openHands.get<Branch[]>(
`/api/user/search/branches`,
{
params: {
repository,
query,
per_page: perPage,
selected_provider: selectedProvider,
},
},
);
return data;
}
/**
* Get the user installation IDs
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
* @param pageId Cursor for pagination
* @param limit Max number of results
* @returns Paginated installation response
* @returns List of installation IDs
*/
static async getUserInstallations(
provider: string,
pageId?: string,
limit = 100,
): Promise<InstallationPage> {
const { data } = await openHands.get<InstallationPage>(
"/api/v1/git/installations/search",
{
params: {
provider,
page_id: pageId,
limit,
},
},
static async getUserInstallationIds(provider: Provider): Promise<string[]> {
const { data } = await openHands.get<string[]>(
`/api/user/installations?provider=${provider}`,
);
return data;
}
@@ -9,7 +9,7 @@ class SettingsService {
* Get the settings from the server or use the default settings if not found
*/
static async getSettings(): Promise<Settings> {
const { data } = await openHands.get<Settings>("/api/v1/settings");
const { data } = await openHands.get<Settings>("/api/settings");
return data;
}
@@ -18,7 +18,7 @@ class SettingsService {
* @param settings - the settings to save
*/
static async saveSettings(settings: Partial<Settings>): Promise<boolean> {
const data = await openHands.post("/api/v1/settings", settings);
const data = await openHands.post("/api/settings", settings);
return data.status === 200;
}
}
@@ -38,7 +38,7 @@ export function useRepositoryData(
// Combine all repositories from paginated data
const allRepositories = useMemo(
() => repoData?.pages?.flatMap((page) => page.items) || [],
() => repoData?.pages?.flatMap((page) => page.data) || [],
[repoData],
);
@@ -18,11 +18,11 @@ export function useUrlSearch(inputValue: string, provider: Provider) {
try {
const repositories = await GitService.searchGitRepositories(
repoName,
provider,
3,
provider,
);
setUrlSearchResults(repositories.items);
setUrlSearchResults(repositories);
} catch {
setUrlSearchResults([]);
} finally {
@@ -6,9 +6,6 @@ import { useUserProviders } from "../use-user-providers";
import { Provider } from "#/types/settings";
import { shouldUseInstallationRepos } from "#/utils/utils";
/**
* Get the first page of app installations for the provider given.
*/
export const useAppInstallations = (selectedProvider: Provider | null) => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
@@ -16,7 +13,7 @@ export const useAppInstallations = (selectedProvider: Provider | null) => {
return useQuery({
queryKey: ["installations", providers || [], selectedProvider],
queryFn: () => GitService.getUserInstallations(selectedProvider!),
queryFn: () => GitService.getUserInstallationIds(selectedProvider!),
enabled:
userIsAuthenticated &&
!!selectedProvider &&
+4 -6
View File
@@ -30,11 +30,9 @@ export function useBranchData(
provider,
);
// Combine all branches from paginated data - use .items for V1 response
// Combine all branches from paginated data
const allBranches = useMemo(
() =>
branchData?.pages?.flatMap((page: { items: Branch[] }) => page.items) ||
[],
() => branchData?.pages?.flatMap((page) => page.branches) || [],
[branchData],
);
@@ -42,7 +40,7 @@ export function useBranchData(
const defaultBranchInLoaded = useMemo(
() =>
defaultBranch
? allBranches.find((branch: Branch) => branch.name === defaultBranch)
? allBranches.find((branch) => branch.name === defaultBranch)
: null,
[allBranches, defaultBranch],
);
@@ -77,7 +75,7 @@ export function useBranchData(
if (defaultBranch) {
// Use the already computed defaultBranchInLoaded or check in current branches
let defaultBranchObj = shouldUseSearch
? branchesToUse.find((branch: Branch) => branch.name === defaultBranch)
? branchesToUse.find((branch) => branch.name === defaultBranch)
: defaultBranchInLoaded;
// If not found in current branches, check if we have it from the default branch search
@@ -1,8 +1,8 @@
import { useInfiniteQuery, InfiniteData } from "@tanstack/react-query";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useUserProviders } from "../use-user-providers";
import { useAppInstallations } from "./use-app-installations";
import { RepositoryPage } from "../../types/git";
import { GitRepository } from "../../types/git";
import { Provider } from "../../types/settings";
import GitService from "#/api/git-service/git-service.api";
import { shouldUseInstallationRepos } from "#/utils/utils";
@@ -13,27 +13,29 @@ interface UseGitRepositoriesOptions {
enabled?: boolean;
}
type InstallationCursor = { installationIndex: number; pageId: string | null };
type UserCursor = string | null;
type Cursor = InstallationCursor | UserCursor;
interface UserRepositoriesResponse {
data: GitRepository[];
nextPage: number | null;
}
interface InstallationRepositoriesResponse {
data: GitRepository[];
nextPage: number | null;
installationIndex: number | null;
}
export function useGitRepositories(options: UseGitRepositoriesOptions) {
const { provider, pageSize = 30, enabled = true } = options;
const { providers } = useUserProviders();
const { data: config } = useConfig();
const { data: page } = useAppInstallations(provider);
const installations = page?.items;
const { data: installations } = useAppInstallations(provider);
const useInstallationRepos = provider
? shouldUseInstallationRepos(provider, config?.app_mode)
: false;
const repos = useInfiniteQuery<
RepositoryPage,
Error,
InfiniteData<RepositoryPage>,
[string, string[], Provider | null, boolean, number, ...unknown[]],
Cursor
UserRepositoriesResponse | InstallationRepositoriesResponse
>({
queryKey: [
"repositories",
@@ -49,52 +51,56 @@ export function useGitRepositories(options: UseGitRepositoriesOptions) {
}
if (useInstallationRepos) {
const { repoPage, installationIndex } = pageParam as {
installationIndex: number | null;
repoPage: number | null;
};
if (!installations) {
throw new Error("Missing installation list");
}
const cursor = pageParam as InstallationCursor;
const result = await GitService.retrieveInstallationRepositories(
return GitService.retrieveInstallationRepositories(
provider,
cursor.installationIndex,
installationIndex || 0,
installations,
cursor.pageId ?? undefined,
repoPage || 1,
pageSize,
);
return result;
}
const cursor = pageParam as UserCursor;
const result = await GitService.retrieveUserGitRepositories(
return GitService.retrieveUserGitRepositories(
provider,
cursor ?? undefined,
pageParam as number,
pageSize,
);
return result;
},
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (useInstallationRepos && installations) {
// Installation-based pagination
const currentCursor = lastPageParam as InstallationCursor;
if (lastPage.next_page_id) {
getNextPageParam: (lastPage) => {
if (useInstallationRepos) {
const installationPage = lastPage as InstallationRepositoriesResponse;
if (installationPage.nextPage) {
return {
installationIndex: currentCursor.installationIndex,
pageId: lastPage.next_page_id,
installationIndex: installationPage.installationIndex,
repoPage: installationPage.nextPage,
};
}
// Advance to next installation
const nextInstallationIndex = currentCursor.installationIndex + 1;
if (nextInstallationIndex < installations.length) {
return { installationIndex: nextInstallationIndex, pageId: null };
if (installationPage.installationIndex !== null) {
return {
installationIndex: installationPage.installationIndex,
repoPage: 1,
};
}
return undefined;
return null;
}
// User repositories pagination
return lastPage.next_page_id;
const userPage = lastPage as UserRepositoriesResponse;
return userPage.nextPage;
},
initialPageParam: useInstallationRepos
? { installationIndex: 0, pageId: null }
: null,
? { installationIndex: 0, repoPage: 1 }
: 1,
enabled:
enabled &&
(providers || []).length > 0 &&
@@ -1,20 +1,35 @@
import { useInfiniteQuery, InfiniteData } from "@tanstack/react-query";
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
import GitService from "#/api/git-service/git-service.api";
import { BranchPage } from "#/types/git";
import { Branch, PaginatedBranchesResponse } from "#/types/git";
import { Provider } from "#/types/settings";
export const useRepositoryBranches = (
repository: string | null,
selectedProvider?: Provider,
) =>
useQuery<Branch[]>({
queryKey: ["repository", repository, "branches", selectedProvider],
queryFn: async () => {
if (!repository) return [];
const response = await GitService.getRepositoryBranches(
repository,
1,
30,
selectedProvider,
);
// Ensure we return an array even if the response is malformed
return Array.isArray(response.branches) ? response.branches : [];
},
enabled: !!repository,
staleTime: 1000 * 60 * 5, // 5 minutes
});
export const useRepositoryBranchesPaginated = (
repository: string | null,
perPage: number = 30,
selectedProvider?: Provider,
) => {
const result = useInfiniteQuery<
BranchPage,
Error,
InfiniteData<BranchPage>,
[string, string | null, ...unknown[]],
string | null
>({
) =>
useInfiniteQuery<PaginatedBranchesResponse, Error>({
queryKey: [
"repository",
repository,
@@ -23,29 +38,27 @@ export const useRepositoryBranchesPaginated = (
perPage,
selectedProvider,
],
queryFn: async ({ pageParam }) => {
if (!repository || !selectedProvider) {
queryFn: async ({ pageParam = 1 }) => {
if (!repository) {
return {
items: [],
next_page_id: null,
branches: [],
has_next_page: false,
current_page: 1,
per_page: perPage,
total_count: 0,
};
}
return GitService.getRepositoryBranches(
repository,
selectedProvider,
"", // query (empty = list all)
pageParam ?? undefined,
pageParam as number,
perPage,
selectedProvider,
);
},
enabled: !!repository && !!selectedProvider,
enabled: !!repository,
staleTime: 1000 * 60 * 5, // 5 minutes
getNextPageParam: (lastPage) =>
lastPage.next_page_id ? lastPage.next_page_id : undefined,
initialPageParam: null,
// Use the has_next_page flag from the API response
lastPage.has_next_page ? lastPage.current_page + 1 : undefined,
initialPageParam: 1,
});
return {
...result,
};
};
@@ -20,17 +20,15 @@ export function useSearchBranches(
selectedProvider,
],
queryFn: async () => {
if (!repository || !query || !selectedProvider) return [];
const response = await GitService.searchRepositoryBranches(
if (!repository || !query) return [];
return GitService.searchRepositoryBranches(
repository,
selectedProvider,
query,
undefined, // pageId
perPage,
selectedProvider,
);
return response.items;
},
enabled: !!repository && !!query && !!selectedProvider,
enabled: !!repository && !!query,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 15,
});
@@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import GitService from "#/api/git-service/git-service.api";
import { GitRepository } from "#/types/git";
import { Provider } from "#/types/settings";
export function useSearchRepositories(
@@ -9,20 +8,14 @@ export function useSearchRepositories(
disabled?: boolean,
pageSize: number = 100,
) {
// For backward compatibility, return the items array directly
return useQuery<GitRepository[]>({
return useQuery({
queryKey: ["repositories", "search", query, selectedProvider, pageSize],
queryFn: async () => {
if (!selectedProvider) {
return [];
}
const response = await GitService.searchGitRepositories(
queryFn: () =>
GitService.searchGitRepositories(
query,
selectedProvider, // provider (required)
pageSize,
);
return response.items;
},
selectedProvider || undefined,
),
enabled: !!query && !!selectedProvider && !disabled,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
+6 -2
View File
@@ -106,6 +106,10 @@ export const SETTINGS_HANDLERS = [
}),
),
http.get("/api/options/agents", async () =>
HttpResponse.json(["CodeActAgent", "CoActAgent"]),
),
http.get("/api/options/security-analyzers", async () =>
HttpResponse.json(["llm", "none"]),
),
@@ -141,7 +145,7 @@ export const SETTINGS_HANDLERS = [
return HttpResponse.json(config);
}),
http.get("/api/v1/settings", async () => {
http.get("/api/settings", async () => {
await delay();
const { settings } = MOCK_USER_PREFERENCES;
@@ -150,7 +154,7 @@ export const SETTINGS_HANDLERS = [
return HttpResponse.json(settings);
}),
http.post("/api/v1/settings", async ({ request }) => {
http.post("/api/settings", async ({ request }) => {
await delay();
const body = await request.json();
-24
View File
@@ -30,30 +30,6 @@ interface PaginatedBranchesResponse {
total_count?: number;
}
/**
* V1 API response for paginated branch search (cursor-based)
*/
interface BranchPage {
items: Branch[];
next_page_id: string | null;
}
/**
* V1 API response for paginated repository search (cursor-based)
*/
interface RepositoryPage {
items: GitRepository[];
next_page_id: string | null;
}
/**
* V1 API response for paginated installation search (cursor-based)
*/
interface InstallationPage {
items: string[];
next_page_id: string | null;
}
interface GitRepository {
id: string;
full_name: string;
+2
View File
@@ -0,0 +1,2 @@
export const isNumber = (value: string | number): boolean =>
!Number.isNaN(Number(value));
+41
View File
@@ -146,6 +146,28 @@ export const removeUnwantedKeys = (
});
};
export const removeApiKey = (
data: EventActionHistory[],
): EventActionHistory[] =>
data.map((item) => {
// Create a shallow copy of item
const newItem = { ...item };
// Check if LLM_API_KEY exists and delete it from a new args object
if (newItem.args?.LLM_API_KEY) {
const newArgs = { ...newItem.args };
delete newArgs.LLM_API_KEY;
newItem.args = newArgs;
}
return newItem;
});
export const getExtension = (code: string) => {
if (code.includes(".")) return code.split(".").pop() || "";
return "";
};
/**
* Get file extension from file name in uppercase format
* @param fileName The file name to extract extension from
@@ -161,6 +183,25 @@ export const getFileExtension = (fileName: string): string => {
return extension || "FILE";
};
/**
* Format a timestamp to a human-readable format
* @param timestamp The timestamp to format (ISO 8601)
* @returns The formatted timestamp
*
* @example
* formatTimestamp("2021-10-10T10:10:10.000") // "10/10/2021, 10:10:10"
* formatTimestamp("2021-10-10T22:10:10.000") // "10/10/2021, 22:10:10"
*/
export const formatTimestamp = (timestamp: string) =>
new Date(timestamp).toLocaleString("en-GB", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
export const shouldUseInstallationRepos = (
provider: Provider,
app_mode: "saas" | "oss" | undefined,
+2 -5
View File
@@ -105,11 +105,8 @@ def get_default_permitted_cors_origins() -> list[str]:
def get_openhands_provider_base_url() -> str | None:
"""Return the base URL for the OpenHands provider, if configured.
Falls back to LLM_BASE_URL for backward compatibility.
"""
return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or os.getenv('LLM_BASE_URL') or None
"""Return the base URL for the OpenHands provider, if configured."""
return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or None
def _get_default_lifespan():
+7 -24
View File
@@ -241,30 +241,13 @@ async def search_branches(
if decoded_page_id is not None:
page = decoded_page_id
if query:
if page != 1:
# TODO(#13883): Support pagination for branch search after refactoring.
# The search_branches method does not support paging in the same way as
# get_branches - those should be merged into a single paginated method
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Pagination not yet supported for branch search queries. Use empty query to list all branches with pagination.',
)
# Get search results - we'll handle pagination ourselves
branches: list[Branch] = await client.search_branches(
selected_provider=provider,
repository=repository,
query=query,
per_page=limit + 1,
)
else:
current_page = await client.get_branches(
repository=repository,
specified_provider=provider,
page=page,
per_page=limit + 1,
)
branches = current_page.branches
# Get search results - we'll handle pagination ourselves
branches: list[Branch] = await client.search_branches(
selected_provider=provider,
repository=repository,
query=query,
per_page=limit + 1, # We'll handle pagination ourselves
)
next_page_id = None
if len(branches) > limit: