mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
[Feat]: Adding endpoint for suggested tasks Openhands could tackle (#6844)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
ef62ccde36
commit
e49b9243af
@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
@ -9,6 +10,8 @@ from openhands.integrations.github.github_types import (
|
||||
GHUnknownException,
|
||||
GitHubRepository,
|
||||
GitHubUser,
|
||||
SuggestedTask,
|
||||
TaskType,
|
||||
)
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
@ -136,6 +139,144 @@ class GitHubService:
|
||||
|
||||
return repos
|
||||
|
||||
async def execute_graphql_query(
|
||||
self, query: str, variables: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a GraphQL query against the GitHub API."""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
github_headers = await self._get_github_headers()
|
||||
response = await client.post(
|
||||
f'{self.BASE_URL}/graphql',
|
||||
headers=github_headers,
|
||||
json={'query': query, 'variables': variables},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
if 'errors' in result:
|
||||
raise GHUnknownException(
|
||||
f"GraphQL query error: {json.dumps(result['errors'])}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 401:
|
||||
raise GhAuthenticationError('Invalid Github token')
|
||||
raise GHUnknownException('Unknown error')
|
||||
|
||||
except httpx.HTTPError:
|
||||
raise GHUnknownException('Unknown error')
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""
|
||||
Get suggested tasks for the authenticated user across all repositories.
|
||||
Returns:
|
||||
- PRs authored by the user
|
||||
- Issues assigned to the user
|
||||
"""
|
||||
# Get user info to use in queries
|
||||
user = await self.get_user()
|
||||
login = user.login
|
||||
|
||||
query = """
|
||||
query GetUserTasks($login: String!) {
|
||||
user(login: $login) {
|
||||
pullRequests(first: 100, states: [OPEN], orderBy: {field: UPDATED_AT, direction: DESC}) {
|
||||
nodes {
|
||||
number
|
||||
title
|
||||
repository {
|
||||
nameWithOwner
|
||||
}
|
||||
mergeable
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
statusCheckRollup {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reviews(first: 100, states: [CHANGES_REQUESTED, COMMENTED]) {
|
||||
nodes {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
issues(first: 100, states: [OPEN], filterBy: {assignee: $login}, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
||||
nodes {
|
||||
number
|
||||
title
|
||||
repository {
|
||||
nameWithOwner
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
variables = {'login': login}
|
||||
|
||||
try:
|
||||
response = await self.execute_graphql_query(query, variables)
|
||||
data = response['data']['user']
|
||||
tasks: list[SuggestedTask] = []
|
||||
|
||||
# Process pull requests
|
||||
for pr in data['pullRequests']['nodes']:
|
||||
repo_name = pr['repository']['nameWithOwner']
|
||||
|
||||
# Always add open PRs
|
||||
task_type = TaskType.OPEN_PR
|
||||
|
||||
# Check for specific states
|
||||
if pr['mergeable'] == 'CONFLICTING':
|
||||
task_type = TaskType.MERGE_CONFLICTS
|
||||
elif (
|
||||
pr['commits']['nodes']
|
||||
and pr['commits']['nodes'][0]['commit']['statusCheckRollup']
|
||||
and pr['commits']['nodes'][0]['commit']['statusCheckRollup'][
|
||||
'state'
|
||||
]
|
||||
== 'FAILURE'
|
||||
):
|
||||
task_type = TaskType.FAILING_CHECKS
|
||||
elif any(
|
||||
review['state'] in ['CHANGES_REQUESTED', 'COMMENTED']
|
||||
for review in pr['reviews']['nodes']
|
||||
):
|
||||
task_type = TaskType.UNRESOLVED_COMMENTS
|
||||
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
task_type=task_type,
|
||||
repo=repo_name,
|
||||
issue_number=pr['number'],
|
||||
title=pr['title'],
|
||||
)
|
||||
)
|
||||
|
||||
# Process issues
|
||||
for issue in data['issues']['nodes']:
|
||||
repo_name = issue['repository']['nameWithOwner']
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
task_type=TaskType.OPEN_ISSUE,
|
||||
repo=repo_name,
|
||||
issue_number=issue['number'],
|
||||
title=issue['title'],
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
github_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITHUB_SERVICE_CLS',
|
||||
|
||||
@ -1,6 +1,23 @@
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TaskType(str, Enum):
|
||||
MERGE_CONFLICTS = 'MERGE_CONFLICTS'
|
||||
FAILING_CHECKS = 'FAILING_CHECKS'
|
||||
UNRESOLVED_COMMENTS = 'UNRESOLVED_COMMENTS'
|
||||
OPEN_ISSUE = 'OPEN_ISSUE'
|
||||
OPEN_PR = 'OPEN_PR'
|
||||
|
||||
|
||||
class SuggestedTask(BaseModel):
|
||||
task_type: TaskType
|
||||
repo: str
|
||||
issue_number: int
|
||||
title: str
|
||||
|
||||
|
||||
class GitHubUser(BaseModel):
|
||||
id: int
|
||||
login: str
|
||||
|
||||
@ -8,6 +8,7 @@ from openhands.integrations.github.github_types import (
|
||||
GHUnknownException,
|
||||
GitHubRepository,
|
||||
GitHubUser,
|
||||
SuggestedTask,
|
||||
)
|
||||
from openhands.server.auth import get_github_token, get_user_id
|
||||
|
||||
@ -116,3 +117,32 @@ async def search_github_repositories(
|
||||
content=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@app.get('/suggested-tasks')
|
||||
async def get_suggested_tasks(
|
||||
github_user_id: str | None = Depends(get_user_id),
|
||||
github_user_token: SecretStr | None = Depends(get_github_token),
|
||||
):
|
||||
"""
|
||||
Get suggested tasks for the authenticated user across their most recently pushed repositories.
|
||||
Returns:
|
||||
- PRs owned by the user
|
||||
- Issues assigned to the user
|
||||
"""
|
||||
client = GithubServiceImpl(user_id=github_user_id, token=github_user_token)
|
||||
try:
|
||||
tasks: list[SuggestedTask] = await client.get_suggested_tasks()
|
||||
return tasks
|
||||
|
||||
except GhAuthenticationError as e:
|
||||
return JSONResponse(
|
||||
content=str(e),
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
except GHUnknownException as e:
|
||||
return JSONResponse(
|
||||
content=str(e),
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
@ -109,6 +109,7 @@ reportlab = "*"
|
||||
[tool.coverage.run]
|
||||
concurrency = ["gevent"]
|
||||
|
||||
|
||||
[tool.poetry.group.runtime.dependencies]
|
||||
jupyterlab = "*"
|
||||
notebook = "*"
|
||||
@ -137,6 +138,7 @@ ignore = ["D1"]
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
|
||||
[tool.poetry.group.evaluation.dependencies]
|
||||
streamlit = "*"
|
||||
whatthepatch = "*"
|
||||
|
||||
124
tests/unit/test_suggested_tasks.py
Normal file
124
tests/unit/test_suggested_tasks.py
Normal file
@ -0,0 +1,124 @@
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.integrations.github.github_service import GitHubService
|
||||
from openhands.integrations.github.github_types import GitHubUser, TaskType
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_suggested_tasks():
|
||||
# Mock responses
|
||||
mock_user = GitHubUser(
|
||||
id=1,
|
||||
login='test-user',
|
||||
avatar_url='https://example.com/avatar.jpg',
|
||||
name='Test User',
|
||||
)
|
||||
|
||||
# Mock GraphQL response
|
||||
mock_graphql_response = {
|
||||
'data': {
|
||||
'user': {
|
||||
'pullRequests': {
|
||||
'nodes': [
|
||||
{
|
||||
'number': 1,
|
||||
'title': 'PR with conflicts',
|
||||
'repository': {'nameWithOwner': 'test-org/repo-1'},
|
||||
'mergeable': 'CONFLICTING',
|
||||
'commits': {
|
||||
'nodes': [{'commit': {'statusCheckRollup': None}}]
|
||||
},
|
||||
'reviews': {'nodes': []},
|
||||
},
|
||||
{
|
||||
'number': 2,
|
||||
'title': 'PR with failing checks',
|
||||
'repository': {'nameWithOwner': 'test-org/repo-1'},
|
||||
'mergeable': 'MERGEABLE',
|
||||
'commits': {
|
||||
'nodes': [
|
||||
{
|
||||
'commit': {
|
||||
'statusCheckRollup': {'state': 'FAILURE'}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
'reviews': {'nodes': []},
|
||||
},
|
||||
{
|
||||
'number': 4,
|
||||
'title': 'PR with comments',
|
||||
'repository': {'nameWithOwner': 'test-user/repo-2'},
|
||||
'mergeable': 'MERGEABLE',
|
||||
'commits': {
|
||||
'nodes': [
|
||||
{
|
||||
'commit': {
|
||||
'statusCheckRollup': {'state': 'SUCCESS'}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
'reviews': {'nodes': [{'state': 'CHANGES_REQUESTED'}]},
|
||||
},
|
||||
]
|
||||
},
|
||||
'issues': {
|
||||
'nodes': [
|
||||
{
|
||||
'number': 3,
|
||||
'title': 'Assigned issue 1',
|
||||
'repository': {'nameWithOwner': 'test-org/repo-1'},
|
||||
},
|
||||
{
|
||||
'number': 5,
|
||||
'title': 'Assigned issue 2',
|
||||
'repository': {'nameWithOwner': 'test-user/repo-2'},
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Create service instance with mocked methods
|
||||
service = GitHubService()
|
||||
service.get_user = AsyncMock(return_value=mock_user)
|
||||
service.execute_graphql_query = AsyncMock(return_value=mock_graphql_response)
|
||||
|
||||
# Call the function
|
||||
tasks = await service.get_suggested_tasks()
|
||||
|
||||
# Verify the results
|
||||
assert len(tasks) == 5 # Should have 5 tasks total
|
||||
|
||||
# Verify each task type is present
|
||||
task_types = [task.task_type for task in tasks]
|
||||
assert TaskType.MERGE_CONFLICTS in task_types
|
||||
assert TaskType.FAILING_CHECKS in task_types
|
||||
assert TaskType.UNRESOLVED_COMMENTS in task_types
|
||||
assert TaskType.OPEN_ISSUE in task_types
|
||||
assert (
|
||||
len([t for t in task_types if t == TaskType.OPEN_ISSUE]) == 2
|
||||
) # Should have 2 open issues
|
||||
|
||||
# Verify repositories are correct
|
||||
repos = {task.repo for task in tasks}
|
||||
assert 'test-org/repo-1' in repos
|
||||
assert 'test-user/repo-2' in repos
|
||||
|
||||
# Verify specific tasks
|
||||
conflict_pr = next(t for t in tasks if t.task_type == TaskType.MERGE_CONFLICTS)
|
||||
assert conflict_pr.issue_number == 1
|
||||
assert conflict_pr.title == 'PR with conflicts'
|
||||
|
||||
failing_pr = next(t for t in tasks if t.task_type == TaskType.FAILING_CHECKS)
|
||||
assert failing_pr.issue_number == 2
|
||||
assert failing_pr.title == 'PR with failing checks'
|
||||
|
||||
commented_pr = next(t for t in tasks if t.task_type == TaskType.UNRESOLVED_COMMENTS)
|
||||
assert commented_pr.issue_number == 4
|
||||
assert commented_pr.title == 'PR with comments'
|
||||
Loading…
x
Reference in New Issue
Block a user