[Feat]: Adding endpoint for suggested tasks Openhands could tackle (#6844)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Rohit Malhotra 2025-02-25 22:10:24 -05:00 committed by GitHub
parent ef62ccde36
commit e49b9243af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 314 additions and 0 deletions

View File

@ -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',

View File

@ -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

View File

@ -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,
)

View File

@ -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 = "*"

View 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'