diff --git a/frontend/__tests__/components/features/home/task-card.test.tsx b/frontend/__tests__/components/features/home/task-card.test.tsx index d71c7ab46b..c5776a889a 100644 --- a/frontend/__tests__/components/features/home/task-card.test.tsx +++ b/frontend/__tests__/components/features/home/task-card.test.tsx @@ -23,6 +23,7 @@ const MOCK_TASK_1: SuggestedTask = { repo: "repo1", title: "Task 1", task_type: "MERGE_CONFLICTS", + git_provider: "github", }; const MOCK_TASK_2: SuggestedTask = { @@ -30,6 +31,7 @@ const MOCK_TASK_2: SuggestedTask = { repo: "repo2", title: "Task 2", task_type: "FAILING_CHECKS", + git_provider: "github", }; const MOCK_TASK_3: SuggestedTask = { @@ -37,6 +39,7 @@ const MOCK_TASK_3: SuggestedTask = { repo: "repo3", title: "Task 3", task_type: "UNRESOLVED_COMMENTS", + git_provider: "gitlab", }; const MOCK_TASK_4: SuggestedTask = { @@ -44,6 +47,7 @@ const MOCK_TASK_4: SuggestedTask = { repo: "repo4", title: "Task 4", task_type: "OPEN_ISSUE", + git_provider: "gitlab", }; const MOCK_RESPOSITORIES: GitRepository[] = [ @@ -119,7 +123,11 @@ describe("TaskCard", () => { expect(createConversationSpy).toHaveBeenCalledWith( MOCK_RESPOSITORIES[0], - getMergeConflictPrompt(MOCK_TASK_1.issue_number, MOCK_TASK_1.repo), + getMergeConflictPrompt( + MOCK_TASK_1.git_provider, + MOCK_TASK_1.issue_number, + MOCK_TASK_1.repo, + ), [], undefined, ); @@ -135,7 +143,11 @@ describe("TaskCard", () => { expect(createConversationSpy).toHaveBeenCalledWith( MOCK_RESPOSITORIES[1], - getFailingChecksPrompt(MOCK_TASK_2.issue_number, MOCK_TASK_2.repo), + getFailingChecksPrompt( + MOCK_TASK_2.git_provider, + MOCK_TASK_2.issue_number, + MOCK_TASK_2.repo, + ), [], undefined, ); @@ -151,7 +163,11 @@ describe("TaskCard", () => { expect(createConversationSpy).toHaveBeenCalledWith( MOCK_RESPOSITORIES[2], - getUnresolvedCommentsPrompt(MOCK_TASK_3.issue_number, MOCK_TASK_3.repo), + getUnresolvedCommentsPrompt( + MOCK_TASK_3.git_provider, + MOCK_TASK_3.issue_number, + MOCK_TASK_3.repo, + ), [], undefined, ); @@ -167,7 +183,11 @@ describe("TaskCard", () => { expect(createConversationSpy).toHaveBeenCalledWith( MOCK_RESPOSITORIES[3], - getOpenIssuePrompt(MOCK_TASK_4.issue_number, MOCK_TASK_4.repo), + getOpenIssuePrompt( + MOCK_TASK_4.git_provider, + MOCK_TASK_4.issue_number, + MOCK_TASK_4.repo, + ), [], undefined, ); diff --git a/frontend/__tests__/utils/group-suggested-tasks.test.ts b/frontend/__tests__/utils/group-suggested-tasks.test.ts index 52837e079b..a2848120bb 100644 --- a/frontend/__tests__/utils/group-suggested-tasks.test.ts +++ b/frontend/__tests__/utils/group-suggested-tasks.test.ts @@ -11,30 +11,35 @@ const rawTasks: SuggestedTask[] = [ repo: "repo1", title: "Task 1", task_type: "MERGE_CONFLICTS", + git_provider: "github", }, { issue_number: 2, repo: "repo1", title: "Task 2", task_type: "FAILING_CHECKS", + git_provider: "github", }, { issue_number: 3, repo: "repo2", title: "Task 3", task_type: "UNRESOLVED_COMMENTS", + git_provider: "github", }, { issue_number: 4, repo: "repo2", title: "Task 4", task_type: "OPEN_ISSUE", + git_provider: "github", }, { issue_number: 5, repo: "repo3", title: "Task 5", task_type: "FAILING_CHECKS", + git_provider: "github", }, ]; @@ -47,12 +52,14 @@ const groupedTasks: SuggestedTaskGroup[] = [ repo: "repo1", title: "Task 1", task_type: "MERGE_CONFLICTS", + git_provider: "github", }, { issue_number: 2, repo: "repo1", title: "Task 2", task_type: "FAILING_CHECKS", + git_provider: "github", }, ], }, @@ -64,12 +71,14 @@ const groupedTasks: SuggestedTaskGroup[] = [ repo: "repo2", title: "Task 3", task_type: "UNRESOLVED_COMMENTS", + git_provider: "github", }, { issue_number: 4, repo: "repo2", title: "Task 4", task_type: "OPEN_ISSUE", + git_provider: "github", }, ], }, @@ -81,6 +90,7 @@ const groupedTasks: SuggestedTaskGroup[] = [ repo: "repo3", title: "Task 5", task_type: "FAILING_CHECKS", + git_provider: "github", }, ], }, diff --git a/frontend/src/components/features/home/tasks/get-prompt-for-query.ts b/frontend/src/components/features/home/tasks/get-prompt-for-query.ts index ec9167070b..f71520e21a 100644 --- a/frontend/src/components/features/home/tasks/get-prompt-for-query.ts +++ b/frontend/src/components/features/home/tasks/get-prompt-for-query.ts @@ -1,48 +1,94 @@ +import { Provider } from "#/types/settings"; import { SuggestedTaskType } from "./task.types"; +// Helper function to get provider-specific terminology +const getProviderTerms = (git_provider: Provider) => { + if (git_provider === "gitlab") { + return { + requestType: "Merge Request", + requestTypeShort: "MR", + apiName: "GitLab API", + tokenEnvVar: "GITLAB_TOKEN", + ciSystem: "CI pipelines", + ciProvider: "GitLab", + requestVerb: "merge request", + }; + } + return { + requestType: "Pull Request", + requestTypeShort: "PR", + apiName: "GitHub API", + tokenEnvVar: "GITHUB_TOKEN", + ciSystem: "GitHub Actions", + ciProvider: "GitHub", + requestVerb: "pull request", + }; +}; + export const getMergeConflictPrompt = ( + git_provider: Provider, issueNumber: number, repo: string, -) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the merge conflicts. -Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention. +) => { + const terms = getProviderTerms(git_provider); + + return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to fix the merge conflicts. +Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention. Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.`; +}; export const getFailingChecksPrompt = ( + git_provider: Provider, issueNumber: number, repo: string, -) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the failing CI checks. -Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention. -Then use the GitHub API to look at the GitHub Actions that are failing on the most recent commit. Try and reproduce the failure locally. -Get things working locally, then push your changes. Sleep for 30 seconds at a time until the GitHub actions have run again. If they are still failing, repeat the process.`; +) => { + const terms = getProviderTerms(git_provider); + + return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to fix the failing CI checks. +Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention. +Then use the ${terms.apiName} to look at the ${terms.ciSystem} that are failing on the most recent commit. Try and reproduce the failure locally. +Get things working locally, then push your changes. Sleep for 30 seconds at a time until the ${terms.ciProvider} ${terms.ciSystem.toLowerCase()} have run again. If they are still failing, repeat the process.`; +}; export const getUnresolvedCommentsPrompt = ( + git_provider: Provider, issueNumber: number, repo: string, -) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers. -Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention. -Then use the GitHub API to retrieve all the feedback on the PR so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`; +) => { + const terms = getProviderTerms(git_provider); + + return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers. +Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention. +Then use the ${terms.apiName} to retrieve all the feedback on the ${terms.requestTypeShort} so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`; +}; export const getOpenIssuePrompt = ( + git_provider: Provider, issueNumber: number, repo: string, -) => `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue -Use the GitHub API to retrieve the issue details and any comments on the issue. Then check out a new branch and investigate what changes will need to be made -Finally, make the required changes and open up a pull request. Be sure to reference the issue in the PR description`; +) => { + const terms = getProviderTerms(git_provider); + + return `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue. +Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the issue details and any comments on the issue. Then check out a new branch and investigate what changes will need to be made. +Finally, make the required changes and open up a ${terms.requestVerb}. Be sure to reference the issue in the ${terms.requestTypeShort} description.`; +}; export const getPromptForQuery = ( + git_provider: Provider, type: SuggestedTaskType, issueNumber: number, repo: string, ) => { switch (type) { case "MERGE_CONFLICTS": - return getMergeConflictPrompt(issueNumber, repo); + return getMergeConflictPrompt(git_provider, issueNumber, repo); case "FAILING_CHECKS": - return getFailingChecksPrompt(issueNumber, repo); + return getFailingChecksPrompt(git_provider, issueNumber, repo); case "UNRESOLVED_COMMENTS": - return getUnresolvedCommentsPrompt(issueNumber, repo); + return getUnresolvedCommentsPrompt(git_provider, issueNumber, repo); case "OPEN_ISSUE": - return getOpenIssuePrompt(issueNumber, repo); + return getOpenIssuePrompt(git_provider, issueNumber, repo); default: return ""; } diff --git a/frontend/src/components/features/home/tasks/task-card.tsx b/frontend/src/components/features/home/tasks/task-card.tsx index 8d17b1bf96..3318cfefa6 100644 --- a/frontend/src/components/features/home/tasks/task-card.tsx +++ b/frontend/src/components/features/home/tasks/task-card.tsx @@ -6,6 +6,7 @@ import { cn } from "#/utils/utils"; import { useUserRepositories } from "#/hooks/query/use-user-repositories"; import { getPromptForQuery } from "./get-prompt-for-query"; import { TaskIssueNumber } from "./task-issue-number"; +import { Provider } from "#/types/settings"; const getTaskTypeMap = ( t: (key: string) => string, @@ -26,18 +27,21 @@ export function TaskCard({ task }: TaskCardProps) { const isCreatingConversation = useIsCreatingConversation(); const { t } = useTranslation(); - const getRepo = (repo: string) => { + const getRepo = (repo: string, git_provider: Provider) => { const repositoriesList = repositories?.pages.flatMap((page) => page.data); const selectedRepo = repositoriesList?.find( - (repository) => repository.full_name === repo, + (repository) => + repository.full_name === repo && + repository.git_provider === git_provider, ); return selectedRepo; }; const handleLaunchConversation = () => { - const repo = getRepo(task.repo); + const repo = getRepo(task.repo, task.git_provider); const query = getPromptForQuery( + task.git_provider, task.task_type, task.issue_number, task.repo, @@ -49,8 +53,16 @@ export function TaskCard({ task }: TaskCardProps) { }); }; - const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull"; - const href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`; + // Determine the correct URL format based on git provider + let href: string; + if (task.git_provider === "gitlab") { + const issueType = + task.task_type === "OPEN_ISSUE" ? "issues" : "merge_requests"; + href = `https://gitlab.com/${task.repo}/-/${issueType}/${task.issue_number}`; + } else { + const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull"; + href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`; + } return (
  • diff --git a/frontend/src/components/features/home/tasks/task.types.ts b/frontend/src/components/features/home/tasks/task.types.ts index 40877fadc4..dd2a9e6962 100644 --- a/frontend/src/components/features/home/tasks/task.types.ts +++ b/frontend/src/components/features/home/tasks/task.types.ts @@ -1,3 +1,5 @@ +import { Provider } from "#/types/settings"; + export type SuggestedTaskType = | "MERGE_CONFLICTS" | "FAILING_CHECKS" @@ -5,6 +7,7 @@ export type SuggestedTaskType = | "OPEN_ISSUE"; // This is a task type identifier, not a UI string export interface SuggestedTask { + git_provider: Provider; issue_number: number; repo: string; title: string; diff --git a/frontend/src/mocks/task-suggestions-handlers.ts b/frontend/src/mocks/task-suggestions-handlers.ts index 79e7540013..4e5e2cef4b 100644 --- a/frontend/src/mocks/task-suggestions-handlers.ts +++ b/frontend/src/mocks/task-suggestions-handlers.ts @@ -7,6 +7,7 @@ const TASKS_1: SuggestedTask[] = [ title: "Fix merge conflicts", repo: "octocat/hello-world", task_type: "MERGE_CONFLICTS", + git_provider: "github", }, ]; @@ -16,54 +17,63 @@ const TASKS_2: SuggestedTask[] = [ title: "Fix broken CI checks", repo: "octocat/earth", task_type: "FAILING_CHECKS", + git_provider: "github", }, { issue_number: 281, title: "Fix issue", repo: "octocat/earth", task_type: "UNRESOLVED_COMMENTS", + git_provider: "github", }, { issue_number: 293, title: "Update documentation", repo: "octocat/earth", task_type: "OPEN_ISSUE", + git_provider: "github", }, { issue_number: 305, title: "Refactor user service", repo: "octocat/earth", task_type: "FAILING_CHECKS", + git_provider: "github", }, { issue_number: 312, title: "Fix styling bug", repo: "octocat/earth", task_type: "FAILING_CHECKS", + git_provider: "github", }, { issue_number: 327, title: "Add unit tests", repo: "octocat/earth", task_type: "FAILING_CHECKS", + git_provider: "github", }, { issue_number: 331, title: "Implement dark mode", repo: "octocat/earth", task_type: "FAILING_CHECKS", + git_provider: "github", }, { issue_number: 345, title: "Optimize build process", repo: "octocat/earth", task_type: "FAILING_CHECKS", + git_provider: "github", }, { issue_number: 352, title: "Update dependencies", repo: "octocat/earth", task_type: "FAILING_CHECKS", + git_provider: "github", }, ]; diff --git a/frontend/src/utils/group-suggested-tasks.ts b/frontend/src/utils/group-suggested-tasks.ts index c15d9b1153..19cd165b26 100644 --- a/frontend/src/utils/group-suggested-tasks.ts +++ b/frontend/src/utils/group-suggested-tasks.ts @@ -14,14 +14,16 @@ export function groupSuggestedTasks( const groupsMap: Record = {}; for (const task of tasks) { - if (!groupsMap[task.repo]) { - groupsMap[task.repo] = { - title: task.repo, + const groupKey = `${task.repo}`; + + if (!groupsMap[groupKey]) { + groupsMap[groupKey] = { + title: groupKey, tasks: [], }; } - groupsMap[task.repo].tasks.push(task); + groupsMap[groupKey].tasks.push(task); } return Object.values(groupsMap); diff --git a/openhands/integrations/github/github_service.py b/openhands/integrations/github/github_service.py index 0550d9a797..c6944a09a5 100644 --- a/openhands/integrations/github/github_service.py +++ b/openhands/integrations/github/github_service.py @@ -351,6 +351,7 @@ class GitHubService(BaseGitService, GitService): if task_type != TaskType.OPEN_PR: tasks.append( SuggestedTask( + git_provider=ProviderType.GITHUB, task_type=task_type, repo=repo_name, issue_number=pr['number'], @@ -363,6 +364,7 @@ class GitHubService(BaseGitService, GitService): repo_name = issue['repository']['nameWithOwner'] tasks.append( SuggestedTask( + git_provider=ProviderType.GITHUB, task_type=TaskType.OPEN_ISSUE, repo=repo_name, issue_number=issue['number'], diff --git a/openhands/integrations/gitlab/gitlab_service.py b/openhands/integrations/gitlab/gitlab_service.py index 7efc58e639..20df93fd18 100644 --- a/openhands/integrations/gitlab/gitlab_service.py +++ b/openhands/integrations/gitlab/gitlab_service.py @@ -10,6 +10,8 @@ from openhands.integrations.service_types import ( ProviderType, Repository, RequestMethod, + SuggestedTask, + TaskType, UnknownException, User, ) @@ -106,7 +108,7 @@ class GitLabService(BaseGitService, GitService): except httpx.HTTPError as e: raise self.handle_http_error(e) - async def execute_graphql_query(self, query: str, variables: dict[str, Any]) -> Any: + async def execute_graphql_query(self, query: str, variables: dict[str, Any] = {}) -> Any: """ Execute a GraphQL query against the GitLab GraphQL API @@ -244,6 +246,138 @@ class GitLabService(BaseGitService, GitService): for repo in all_repos ] + async def get_suggested_tasks(self) -> list[SuggestedTask]: + """Get suggested tasks for the authenticated user across all repositories. + + Returns: + - Merge requests authored by the user. + - Issues assigned to the user. + """ + # Get user info to use in queries + user = await self.get_user() + username = user.login + + # GraphQL query to get merge requests + query = """ + query GetUserTasks { + currentUser { + authoredMergeRequests(state: opened, sort: UPDATED_DESC, first: 100) { + nodes { + id + iid + title + project { + fullPath + } + conflicts + mergeStatus + pipelines(first: 1) { + nodes { + status + } + } + discussions(first: 100) { + nodes { + notes { + nodes { + resolvable + resolved + } + } + } + } + } + } + } + } + """ + + try: + tasks: list[SuggestedTask] = [] + + # Get merge requests using GraphQL + response = await self.execute_graphql_query(query) + data = response.get('currentUser', {}) + + # Process merge requests + merge_requests = data.get('authoredMergeRequests', {}).get('nodes', []) + for mr in merge_requests: + repo_name = mr.get('project', {}).get('fullPath', '') + mr_number = mr.get('iid') + title = mr.get('title', '') + + # Start with default task type + task_type = TaskType.OPEN_PR + + # Check for specific states + if mr.get('conflicts'): + task_type = TaskType.MERGE_CONFLICTS + elif ( + mr.get('pipelines', {}).get('nodes', []) + and mr.get('pipelines', {}).get('nodes', [])[0].get('status') + == 'FAILED' + ): + task_type = TaskType.FAILING_CHECKS + else: + # Check for unresolved comments + has_unresolved_comments = False + for discussion in mr.get('discussions', {}).get('nodes', []): + for note in discussion.get('notes', {}).get('nodes', []): + if note.get('resolvable') and not note.get('resolved'): + has_unresolved_comments = True + break + if has_unresolved_comments: + break + + if has_unresolved_comments: + task_type = TaskType.UNRESOLVED_COMMENTS + + # Only add the task if it's not OPEN_PR + if task_type != TaskType.OPEN_PR: + tasks.append( + SuggestedTask( + git_provider=ProviderType.GITLAB, + task_type=task_type, + repo=repo_name, + issue_number=mr_number, + title=title, + ) + ) + + # Get assigned issues using REST API + url = f"{self.BASE_URL}/issues" + params = { + "assignee_username": username, + "state": "opened", + "scope": "assigned_to_me" + } + + issues_response, _ = await self._make_request( + method=RequestMethod.GET, + url=url, + params=params + ) + + # Process issues + for issue in issues_response: + repo_name = issue.get('references', {}).get('full', '').split('#')[0].strip() + issue_number = issue.get('iid') + title = issue.get('title', '') + + tasks.append( + SuggestedTask( + git_provider=ProviderType.GITLAB, + task_type=TaskType.OPEN_ISSUE, + repo=repo_name, + issue_number=issue_number, + title=title, + ) + ) + + return tasks + except Exception: + return [] + gitlab_service_cls = os.environ.get( 'OPENHANDS_GITLAB_SERVICE_CLS', diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index 856605f3a0..d0551805e1 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -25,6 +25,7 @@ from openhands.integrations.service_types import ( GitService, ProviderType, Repository, + SuggestedTask, User, ) from openhands.server.types import AppMode @@ -227,7 +228,7 @@ class ProviderHandler: async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]: """ - Get repositories from a selected providers with pagination support + Get repositories from providers """ all_repos: list[Repository] = [] @@ -241,6 +242,21 @@ class ProviderHandler: return all_repos + async def get_suggested_tasks(self) -> list[SuggestedTask]: + """ + Get suggested tasks from providers + """ + tasks: list[SuggestedTask] = [] + for provider in self.provider_tokens: + try: + service = self._get_service(provider) + service_repos = await service.get_suggested_tasks() + tasks.extend(service_repos) + except Exception as e: + logger.warning(f'Error fetching repos from {provider}: {e}') + + return tasks + async def search_repositories( self, query: str, diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py index 590c5fbf38..147422832b 100644 --- a/openhands/integrations/service_types.py +++ b/openhands/integrations/service_types.py @@ -23,6 +23,7 @@ class TaskType(str, Enum): class SuggestedTask(BaseModel): + git_provider: ProviderType task_type: TaskType repo: str issue_number: int @@ -149,3 +150,7 @@ class GitService(Protocol): async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]: """Get repositories for the authenticated user""" ... + + async def get_suggested_tasks(self) -> list[SuggestedTask]: + """Get suggested tasks for the authenticated user across all repositories""" + ... diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py index f926737750..18e70bd2f5 100644 --- a/openhands/server/routes/git.py +++ b/openhands/server/routes/git.py @@ -139,12 +139,9 @@ async def get_suggested_tasks( - PRs owned by the user - Issues assigned to the user. """ - - if provider_tokens and ProviderType.GITHUB in provider_tokens: - token = provider_tokens[ProviderType.GITHUB] - - client = GithubServiceImpl( - user_id=token.user_id, external_auth_token=access_token, token=token.token + if provider_tokens: + client = ProviderHandler( + provider_tokens=provider_tokens, external_auth_token=access_token ) try: tasks: list[SuggestedTask] = await client.get_suggested_tasks() @@ -153,16 +150,16 @@ async def get_suggested_tasks( except AuthenticationError as e: return JSONResponse( content=str(e), - status_code=401, + status_code=status.HTTP_401_UNAUTHORIZED, ) except UnknownException as e: return JSONResponse( content=str(e), - status_code=500, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) return JSONResponse( - content='GitHub token required.', + content='No providers set.', status_code=status.HTTP_401_UNAUTHORIZED, - ) + ) \ No newline at end of file