diff --git a/openhands/integrations/github/github_service.py b/openhands/integrations/github/github_service.py index cb3dbfa37123..c519e1ac3daa 100644 --- a/openhands/integrations/github/github_service.py +++ b/openhands/integrations/github/github_service.py @@ -165,21 +165,27 @@ async def execute_graphql_query(self, query: str, variables: dict[str, Any]) -> except httpx.HTTPError: raise GHUnknownException('Unknown error') - async def get_suggested_tasks(self, repo_full_name: str) -> list[SuggestedTask]: + async def get_suggested_tasks(self, repo_full_name: str | None = None) -> list[SuggestedTask]: """ - Get suggested tasks for a repository, including: - - PRs with merge conflicts - - PRs with failing GitHub Actions - - PRs with unresolved comments - - Issues with specific labels (help wanted, chore, documentation, good first issue) + 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 GetSuggestedTasks($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - pullRequests(first: 100, states: [OPEN]) { + 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 { @@ -197,14 +203,12 @@ async def get_suggested_tasks(self, repo_full_name: str) -> list[SuggestedTask]: } } } - issues(first: 100, states: [OPEN], labels: ["help wanted", "chore", "documentation", "good first issue"]) { + issues(first: 100, states: [OPEN], filterBy: {assignee: $login}, orderBy: {field: UPDATED_AT, direction: DESC}) { nodes { number title - labels(first: 10) { - nodes { - name - } + repository { + nameWithOwner } } } @@ -212,60 +216,57 @@ async def get_suggested_tasks(self, repo_full_name: str) -> list[SuggestedTask]: } """ - owner, name = repo_full_name.split("/") variables = { - "owner": owner, - "name": name + "login": login } - response = await self.execute_graphql_query(query, variables) - data = response["data"]["repository"] - tasks: list[SuggestedTask] = [] + try: + response = await self.execute_graphql_query(query, variables) + print(f"\nGraphQL Response:") + print(response) + + 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 - # Process pull requests - for pr in data["pullRequests"]["nodes"]: - # Check for merge conflicts - if pr["mergeable"] == "CONFLICTING": tasks.append(SuggestedTask( - task_type=TaskType.MERGE_CONFLICTS, - repo=repo_full_name, + task_type=task_type, + repo=repo_name, issue_number=pr["number"], title=pr["title"] )) - # Check for failing checks - if pr["commits"]["nodes"] and pr["commits"]["nodes"][0]["commit"]["statusCheckRollup"]: - if pr["commits"]["nodes"][0]["commit"]["statusCheckRollup"]["state"] == "FAILURE": - tasks.append(SuggestedTask( - task_type=TaskType.FAILING_CHECKS, - repo=repo_full_name, - issue_number=pr["number"], - title=pr["title"] - )) - - # Check for unresolved comments - has_unresolved_comments = any( - review["state"] in ["CHANGES_REQUESTED", "COMMENTED"] - for review in pr["reviews"]["nodes"] - ) - if has_unresolved_comments: + # Process issues + for issue in data["issues"]["nodes"]: + repo_name = issue["repository"]["nameWithOwner"] tasks.append(SuggestedTask( - task_type=TaskType.UNRESOLVED_COMMENTS, - repo=repo_full_name, - issue_number=pr["number"], - title=pr["title"] + task_type=TaskType.OPEN_ISSUE, + repo=repo_name, + issue_number=issue["number"], + title=issue["title"] )) - # Process issues - for issue in data["issues"]["nodes"]: - tasks.append(SuggestedTask( - task_type=TaskType.OPEN_ISSUE, - repo=repo_full_name, - issue_number=issue["number"], - title=issue["title"] - )) - - return tasks + return tasks + except Exception as e: + print(f"Error: {str(e)}") + return [] github_service_cls = os.environ.get( diff --git a/openhands/integrations/github/github_types.py b/openhands/integrations/github/github_types.py index 83b9e7281a6d..f325d28d7817 100644 --- a/openhands/integrations/github/github_types.py +++ b/openhands/integrations/github/github_types.py @@ -7,6 +7,7 @@ class TaskType(str, Enum): FAILING_CHECKS = "FAILING_CHECKS" UNRESOLVED_COMMENTS = "UNRESOLVED_COMMENTS" OPEN_ISSUE = "OPEN_ISSUE" + OPEN_PR = "OPEN_PR" class SuggestedTask(BaseModel): diff --git a/openhands/server/routes/github.py b/openhands/server/routes/github.py index 5be14e4393fa..1a40976fc0e7 100644 --- a/openhands/server/routes/github.py +++ b/openhands/server/routes/github.py @@ -119,22 +119,20 @@ async def search_github_repositories( ) -@app.get('/repository/suggested-tasks') -async def get_repository_suggested_tasks( - repository: str, +@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 a repository, including: - - PRs with merge conflicts - - PRs with failing GitHub Actions - - PRs with unresolved comments - - Issues with specific labels (help wanted, chore, documentation, good first issue) + 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(repository) + tasks: list[SuggestedTask] = await client.get_suggested_tasks() return tasks except GhAuthenticationError as e: diff --git a/tests/unit/test_suggested_tasks.py b/tests/unit/test_suggested_tasks.py new file mode 100644 index 000000000000..523b4e91de97 --- /dev/null +++ b/tests/unit/test_suggested_tasks.py @@ -0,0 +1,136 @@ +import pytest +from unittest.mock import AsyncMock, patch + +from openhands.integrations.github.github_service import GitHubService +from openhands.integrations.github.github_types import GitHubUser, GitHubRepository, 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_repos = [ + GitHubRepository( + id=1, + full_name="test-org/repo-1", + stargazers_count=10 + ), + GitHubRepository( + id=2, + full_name="test-user/repo-2", + stargazers_count=5 + ) + ] + + # Mock GraphQL response for each repository + mock_graphql_responses = [ + { + "data": { + "repository": { + "pullRequests": { + "nodes": [ + { + "number": 1, + "title": "PR with conflicts", + "mergeable": "CONFLICTING", + "commits": { + "nodes": [{"commit": {"statusCheckRollup": None}}] + }, + "reviews": {"nodes": []} + }, + { + "number": 2, + "title": "PR with failing checks", + "mergeable": "MERGEABLE", + "commits": { + "nodes": [{"commit": {"statusCheckRollup": {"state": "FAILURE"}}}] + }, + "reviews": {"nodes": []} + } + ] + }, + "issues": { + "nodes": [ + { + "number": 3, + "title": "Assigned issue 1" + } + ] + } + } + } + }, + { + "data": { + "repository": { + "pullRequests": { + "nodes": [ + { + "number": 4, + "title": "PR with comments", + "mergeable": "MERGEABLE", + "commits": { + "nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}] + }, + "reviews": { + "nodes": [{"state": "CHANGES_REQUESTED"}] + } + } + ] + }, + "issues": { + "nodes": [ + { + "number": 5, + "title": "Assigned issue 2" + } + ] + } + } + } + } + ] + + # Create service instance with mocked methods + service = GitHubService() + service.get_user = AsyncMock(return_value=mock_user) + service.get_repositories = AsyncMock(return_value=mock_repos) + service.execute_graphql_query = AsyncMock() + service.execute_graphql_query.side_effect = mock_graphql_responses + + # 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" \ No newline at end of file