diff --git a/enterprise/integrations/github/github_view.py b/enterprise/integrations/github/github_view.py index 97ad8a9b98..91d125b30d 100644 --- a/enterprise/integrations/github/github_view.py +++ b/enterprise/integrations/github/github_view.py @@ -231,6 +231,29 @@ class GithubIssue(ResolverViewInterface): conversation_instructions=conversation_instructions, ) + async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str: + """Build the initial user message for V1 resolver conversations. + + For "issue opened" events (no specific comment body), we can simply + concatenate the user prompt and the rendered issue context. + + Subclasses that represent comment-driven events (issue comments, PR review + comments, inline review comments) override this method to control ordering + (e.g., context first, then the triggering comment, then previous comments). + """ + + user_instructions, conversation_instructions = await self._get_instructions( + jinja_env + ) + + parts: list[str] = [] + if user_instructions.strip(): + parts.append(user_instructions.strip()) + if conversation_instructions.strip(): + parts.append(conversation_instructions.strip()) + + return '\n\n'.join(parts) + async def _create_v1_conversation( self, jinja_env: Environment, @@ -240,13 +263,11 @@ class GithubIssue(ResolverViewInterface): """Create conversation using the new V1 app conversation system.""" logger.info('[GitHub V1]: Creating V1 conversation') - user_instructions, conversation_instructions = await self._get_instructions( - jinja_env - ) + initial_user_text = await self._get_v1_initial_user_message(jinja_env) # Create the initial message request initial_message = SendMessageRequest( - role='user', content=[TextContent(text=user_instructions)] + role='user', content=[TextContent(text=initial_user_text)] ) # Create the GitHub V1 callback processor @@ -258,7 +279,9 @@ class GithubIssue(ResolverViewInterface): # Create the V1 conversation start request with the callback processor start_request = AppConversationStartRequest( conversation_id=UUID(conversation_metadata.conversation_id), - system_message_suffix=conversation_instructions, + # NOTE: Resolver instructions are intended to be lower priority than the + # system prompt, so we inject them into the initial user message. + system_message_suffix=None, initial_message=initial_message, selected_repository=self.full_repo_name, selected_branch=self._get_branch_name(), @@ -329,6 +352,17 @@ class GithubIssueComment(GithubIssue): return user_instructions, conversation_instructions + async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str: + await self._load_resolver_context() + template = jinja_env.get_template('issue_comment_initial_message.j2') + return template.render( + issue_number=self.issue_number, + issue_title=self.title, + issue_body=self.description, + issue_comment=self.comment_body, + previous_comments=self.previous_comments, + ).strip() + @dataclass class GithubPRComment(GithubIssueComment): @@ -355,6 +389,18 @@ class GithubPRComment(GithubIssueComment): return user_instructions, conversation_instructions + async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str: + await self._load_resolver_context() + template = jinja_env.get_template('pr_update_initial_message.j2') + return template.render( + pr_number=self.issue_number, + branch_name=self.branch_name, + pr_title=self.title, + pr_body=self.description, + pr_comment=self.comment_body, + comments=self.previous_comments, + ).strip() + @dataclass class GithubInlinePRComment(GithubPRComment): @@ -401,6 +447,20 @@ class GithubInlinePRComment(GithubPRComment): return user_instructions, conversation_instructions + async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str: + await self._load_resolver_context() + template = jinja_env.get_template('pr_update_initial_message.j2') + return template.render( + pr_number=self.issue_number, + branch_name=self.branch_name, + pr_title=self.title, + pr_body=self.description, + file_location=self.file_location, + line_number=self.line_number, + pr_comment=self.comment_body, + comments=self.previous_comments, + ).strip() + def _create_github_v1_callback_processor(self): """Create a V1 callback processor for GitHub integration.""" from integrations.github.github_v1_callback_processor import ( diff --git a/enterprise/tests/unit/integrations/github/test_github_view_v1_initial_user_message.py b/enterprise/tests/unit/integrations/github/test_github_view_v1_initial_user_message.py new file mode 100644 index 0000000000..64a9f64388 --- /dev/null +++ b/enterprise/tests/unit/integrations/github/test_github_view_v1_initial_user_message.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from integrations.github.github_view import ( + GithubInlinePRComment, + GithubIssueComment, + GithubPRComment, +) +from integrations.types import UserData +from jinja2 import Environment, FileSystemLoader + +from openhands.app_server.app_conversation.app_conversation_models import ( + AppConversationStartTaskStatus, +) +from openhands.storage.data_models.conversation_metadata import ConversationMetadata + + +@pytest.fixture +def jinja_env() -> Environment: + repo_root = Path(__file__).resolve().parents[5] + return Environment( + loader=FileSystemLoader( + str(repo_root / 'openhands/integrations/templates/resolver/github') + ) + ) + + +@asynccontextmanager +async def _fake_app_conversation_service_ctx(fake_service): + yield fake_service + + +class _FakeAppConversationService: + def __init__(self): + self.requests = [] + + async def start_app_conversation(self, request): + self.requests.append(request) + yield MagicMock(status=AppConversationStartTaskStatus.READY, detail=None) + + +def _build_conversation_metadata() -> ConversationMetadata: + return ConversationMetadata( + conversation_id=str(uuid4()), + selected_repository='test-owner/test-repo', + ) + + +def _build_user_data() -> UserData: + return UserData(user_id=1, username='test-user', keycloak_user_id='kc-user') + + +@pytest.mark.asyncio +class TestGithubViewV1InitialUserMessage: + @patch('integrations.github.github_view.get_app_conversation_service') + async def test_issue_comment_v1_injects_context_into_initial_user_message( + self, + mock_get_app_conversation_service, + jinja_env, + ): + view = GithubIssueComment( + installation_id=123, + issue_number=42, + full_repo_name='test-owner/test-repo', + is_public_repo=False, + user_info=_build_user_data(), + raw_payload=MagicMock(), + conversation_id='conv', + uuid=None, + should_extract=False, + send_summary_instruction=False, + title='ignored', + description='ignored', + previous_comments=[], + v1_enabled=True, + comment_body='please fix this', + comment_id=999, + ) + + async def _load_context(): + view.title = 'Issue title' + view.description = 'Issue body' + view.previous_comments = [MagicMock(author='alice', body='old comment 1')] + + view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign] + + fake_service = _FakeAppConversationService() + mock_get_app_conversation_service.return_value = ( + _fake_app_conversation_service_ctx(fake_service) + ) + + await view._create_v1_conversation( + jinja_env=jinja_env, + saas_user_auth=MagicMock(), + conversation_metadata=_build_conversation_metadata(), + ) + + assert len(fake_service.requests) == 1 + req = fake_service.requests[0] + assert req.system_message_suffix is None + + text = req.initial_message.content[0].text + assert 'Issue title' in text + assert 'Issue body' in text + assert 'please fix this' in text + assert 'old comment 1' in text + + @patch('integrations.github.github_view.get_app_conversation_service') + async def test_pr_comment_v1_injects_context_and_comment_into_initial_user_message( + self, + mock_get_app_conversation_service, + jinja_env, + ): + view = GithubPRComment( + installation_id=123, + issue_number=7, + full_repo_name='test-owner/test-repo', + is_public_repo=False, + user_info=_build_user_data(), + raw_payload=MagicMock(), + conversation_id='conv', + uuid=None, + should_extract=False, + send_summary_instruction=False, + title='ignored', + description='ignored', + previous_comments=[], + v1_enabled=True, + comment_body='nit: rename variable', + comment_id=1001, + branch_name='feature-branch', + ) + + async def _load_context(): + view.title = 'PR title' + view.description = 'PR body' + view.previous_comments = [ + MagicMock(author='bob', created_at='2026-01-01', body='old thread') + ] + + view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign] + + fake_service = _FakeAppConversationService() + mock_get_app_conversation_service.return_value = ( + _fake_app_conversation_service_ctx(fake_service) + ) + + await view._create_v1_conversation( + jinja_env=jinja_env, + saas_user_auth=MagicMock(), + conversation_metadata=_build_conversation_metadata(), + ) + + assert len(fake_service.requests) == 1 + req = fake_service.requests[0] + assert req.system_message_suffix is None + + text = req.initial_message.content[0].text + assert 'feature-branch' in text + assert 'PR title' in text + assert 'PR body' in text + assert 'nit: rename variable' in text + assert 'old thread' in text + + @patch('integrations.github.github_view.get_app_conversation_service') + async def test_inline_pr_comment_v1_includes_file_context( + self, mock_get_service, jinja_env + ): + view = GithubInlinePRComment( + installation_id=123, + issue_number=7, + full_repo_name='test-owner/test-repo', + is_public_repo=False, + user_info=_build_user_data(), + raw_payload=MagicMock(), + conversation_id='conv', + uuid=None, + should_extract=False, + send_summary_instruction=False, + title='ignored', + description='ignored', + previous_comments=[], + v1_enabled=True, + comment_body='please add a null check', + comment_id=1002, + branch_name='feature-branch', + file_location='src/app.py', + line_number=123, + comment_node_id='node', + ) + + async def _load_context(): + view.title = 'PR title' + view.description = 'PR body' + view.previous_comments = [] + + view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign] + + fake_service = _FakeAppConversationService() + mock_get_service.return_value = _fake_app_conversation_service_ctx(fake_service) + + await view._create_v1_conversation( + jinja_env=jinja_env, + saas_user_auth=MagicMock(), + conversation_metadata=_build_conversation_metadata(), + ) + + req = fake_service.requests[0] + assert req.system_message_suffix is None + text = req.initial_message.content[0].text + assert 'src/app.py' in text + assert '123' in text + assert 'please add a null check' in text diff --git a/openhands/integrations/templates/resolver/github/issue_comment_initial_message.j2 b/openhands/integrations/templates/resolver/github/issue_comment_initial_message.j2 new file mode 100644 index 0000000000..69b321aca4 --- /dev/null +++ b/openhands/integrations/templates/resolver/github/issue_comment_initial_message.j2 @@ -0,0 +1,31 @@ +You are requested to fix issue #{{ issue_number }}: "{{ issue_title }}" in a repository. +A comment on the issue has been addressed to you. + +# Issue Body +{{ issue_body }} + +# Comment +{{ issue_comment }} + +{% if previous_comments %} +# Previous Comments +For reference, here are the previous comments on the issue: + +{% for comment in previous_comments %} +- @{{ comment.author }} said: +{{ comment.body }} +{% if not loop.last %} + +{% endif %} +{% endfor %} +{% endif %} + +# Guidelines + +1. Review the task carefully. +2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed +3. Run the tests, and if they pass you are done! +4. You do NOT need to write new tests if there are only changes to documentation or configuration files. + +# Final Checklist +Re-read the issue title, body, and comments and make sure that you have successfully implemented all requirements. diff --git a/openhands/integrations/templates/resolver/github/pr_update_initial_message.j2 b/openhands/integrations/templates/resolver/github/pr_update_initial_message.j2 new file mode 100644 index 0000000000..5582c8ce8f --- /dev/null +++ b/openhands/integrations/templates/resolver/github/pr_update_initial_message.j2 @@ -0,0 +1,41 @@ +You are checked out to branch {{ branch_name }}, which has an open PR #{{ pr_number }}: "{{ pr_title }}". +A comment on the PR has been addressed to you. + +# PR Description +{{ pr_body }} + +{% if file_location %} +# Comment location +The comment is in the file `{{ file_location }}` on line #{{ line_number }}. +{% endif %} + +# Comment +{{ pr_comment }} + +{% if comments %} +# Previous Comments +You may find these other comments relevant: +{% for comment in comments %} +- @{{ comment.author }} said at {{ comment.created_at }}: +{{ comment.body }} +{% if not loop.last %} + +{% endif %} +{% endfor %} +{% endif %} + +# Steps to Handle the Comment + +## Understand the PR Context +Use the $GITHUB_TOKEN and GitHub API to: + 1. Retrieve the diff against the base branch (typically main) to understand the changes + 2. Fetch the PR body and the linked issue for context + +## Process the Comment +If it's a question, answer it. + +If it requests a code update: + 1. Modify the code accordingly in the current branch + 2. Commit your changes with a clear commit message + 3. Verify if the branch is on a fork, and make sure the remote is correct + 4. Push the changes to GitHub to update the PR.