From fff52e9e261acd4f5497b0b15c9584561e5899ac Mon Sep 17 00:00:00 2001 From: "Hussam.lawen" Date: Thu, 15 Feb 2024 14:25:22 +0200 Subject: [PATCH 1/9] Add ask line feature --- pr_agent/agent/pr_agent.py | 2 + pr_agent/algo/git_patch_processing.py | 54 +++++++++ pr_agent/config_loader.py | 1 + pr_agent/git_providers/git_provider.py | 3 + pr_agent/git_providers/github_provider.py | 17 +++ pr_agent/servers/github_app.py | 20 ++++ .../settings/pr_line_questions_prompts.toml | 53 +++++++++ pr_agent/tools/pr_line_questions.py | 105 ++++++++++++++++++ 8 files changed, 255 insertions(+) create mode 100644 pr_agent/settings/pr_line_questions_prompts.toml create mode 100644 pr_agent/tools/pr_line_questions.py diff --git a/pr_agent/agent/pr_agent.py b/pr_agent/agent/pr_agent.py index 1593257f..ae390cd4 100644 --- a/pr_agent/agent/pr_agent.py +++ b/pr_agent/agent/pr_agent.py @@ -14,6 +14,7 @@ from pr_agent.tools.pr_config import PRConfig from pr_agent.tools.pr_description import PRDescription from pr_agent.tools.pr_generate_labels import PRGenerateLabels from pr_agent.tools.pr_information_from_user import PRInformationFromUser +from pr_agent.tools.pr_line_questions import PR_LineQuestions from pr_agent.tools.pr_questions import PRQuestions from pr_agent.tools.pr_reviewer import PRReviewer from pr_agent.tools.pr_similar_issue import PRSimilarIssue @@ -32,6 +33,7 @@ command2class = { "improve_code": PRCodeSuggestions, "ask": PRQuestions, "ask_question": PRQuestions, + "ask_line": PR_LineQuestions, "update_changelog": PRUpdateChangelog, "config": PRConfig, "settings": PRConfig, diff --git a/pr_agent/algo/git_patch_processing.py b/pr_agent/algo/git_patch_processing.py index 72c9aebf..d7d7a7e4 100644 --- a/pr_agent/algo/git_patch_processing.py +++ b/pr_agent/algo/git_patch_processing.py @@ -245,3 +245,57 @@ __old hunk__ patch_with_lines_str += f"{line_old}\n" return patch_with_lines_str.rstrip() + + +def extract_hunk_lines_from_patch(patch: str, file_name, line_start, line_end, side) -> tuple[str, str]: + + patch_with_lines_str = f"\n\n## file: '{file_name.strip()}'\n\n" + selected_lines = "" + patch_lines = patch.splitlines() + RE_HUNK_HEADER = re.compile( + r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)") + match = None + start1, size1, start2, size2 = -1, -1, -1, -1 + skip_hunk = False + selected_lines_num = 0 + for line in patch_lines: + if 'no newline at end of file' in line.lower(): + continue + + if line.startswith('@@'): + skip_hunk = False + selected_lines_num = 0 + header_line = line + patch_with_lines_str += f'\n{header_line}\n' + match = RE_HUNK_HEADER.match(line) + + res = list(match.groups()) + for i in range(len(res)): + if res[i] is None: + res[i] = 0 + try: + start1, size1, start2, size2 = map(int, res[:4]) + except: # '@@ -0,0 +1 @@' case + start1, size1, size2 = map(int, res[:3]) + start2 = 0 + + # check if line range is in this hunk + if side.lower() == 'left': + # check if line range is in this hunk + if not (start1 <= line_start <= start1 + size1): + skip_hunk = True + continue + elif side.lower() == 'right': + if not (start2 <= line_start <= start2 + size2): + skip_hunk = True + continue + + elif not skip_hunk: + if side.lower() == 'right' and line_start <= start2 + selected_lines_num <= line_end: + selected_lines += line + '\n' + if side.lower() == 'left' and start1 <= selected_lines_num + start1 <= line_end: + selected_lines += line + '\n' + patch_with_lines_str += line + '\n' + selected_lines_num += 1 + + return patch_with_lines_str.rstrip(), selected_lines.rstrip() \ No newline at end of file diff --git a/pr_agent/config_loader.py b/pr_agent/config_loader.py index a160bb1a..ad4d0ae7 100644 --- a/pr_agent/config_loader.py +++ b/pr_agent/config_loader.py @@ -18,6 +18,7 @@ global_settings = Dynaconf( "settings/language_extensions.toml", "settings/pr_reviewer_prompts.toml", "settings/pr_questions_prompts.toml", + "settings/pr_line_questions_prompts.toml", "settings/pr_description_prompts.toml", "settings/pr_code_suggestions_prompts.toml", "settings/pr_sort_code_suggestions_prompts.toml", diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index 565bc81b..69454f0e 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -44,6 +44,9 @@ class GitProvider(ABC): def edit_comment(self, comment, body: str): pass + def reply_to_comment_from_comment_id(self, comment_id: int, body: str): + pass + def get_pr_description(self, *, full: bool = True) -> str: from pr_agent.config_loader import get_settings from pr_agent.algo.utils import clip_tokens diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 121ddbc7..b4e530a0 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -384,6 +384,16 @@ class GithubProvider(GitProvider): def edit_comment(self, comment, body: str): comment.edit(body=body) + def reply_to_comment_from_comment_id(self, comment_id: int, body: str): + try: + # self.pr.get_issue_comment(comment_id).edit(body) + headers, data_patch = self.pr._requester.requestJsonAndCheck( + "POST", f"https://api.github.com/repos/{self.repo}/pulls/{self.pr_num}/comments/{comment_id}/replies", + input={"body": body} + ) + except Exception as e: + get_logger().exception(f"Failed to reply comment, error: {e}") + def remove_initial_comment(self): try: for comment in getattr(self.pr, 'comments_list', []): @@ -448,6 +458,13 @@ class GithubProvider(GitProvider): return reaction.id except Exception as e: get_logger().exception(f"Failed to add eyes reaction, error: {e}") + try: + headers, data_patch = self.pr._requester.requestJsonAndCheck( + "POST", f"https://api.github.com/repos/{self.repo}/pulls/comments/{issue_comment_id}/reactions", + input={"content": "eyes"} + ) + except: + pass return None def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: diff --git a/pr_agent/servers/github_app.py b/pr_agent/servers/github_app.py index 32b3305d..a465279c 100644 --- a/pr_agent/servers/github_app.py +++ b/pr_agent/servers/github_app.py @@ -100,6 +100,9 @@ async def handle_request(body: Dict[str, Any], event: str): api_url = body["issue"]["pull_request"]["url"] elif "comment" in body and "pull_request_url" in body["comment"]: api_url = body["comment"]["pull_request_url"] + if 'subject_type' in body["comment"] and body["comment"]["subject_type"] == "line": + comment_body = await handle_line_comments(action, body, comment_body, event) + else: return {} log_context["api_url"] = api_url @@ -190,6 +193,23 @@ async def handle_request(body: Dict[str, Any], event: str): return {} +async def handle_line_comments(action, body, comment_body, event): + # handle line comments + start_line = body["comment"]["start_line"] + end_line = body["comment"]["line"] + start_line = end_line if not start_line else start_line + question = comment_body.replace('/ask', '').strip() + diff_hunk = body["comment"]["diff_hunk"] + get_settings().set("ask_diff_hunk", diff_hunk) + path = body["comment"]["path"] + side = body["comment"]["side"] + comment_id = body["comment"]["id"] + if '/ask' in comment_body: + get_logger().info(f"Handling line comment because of event={event} and action={action}") + comment_body = f"/ask_line --line_start={start_line} --line_end={end_line} --side={side} --file_name={path} --comment_id={comment_id} {question}" + return comment_body + + def _check_pull_request_event(action: str, body: dict, log_context: dict, bot_user: str) -> Tuple[Dict[str, Any], str]: invalid_result = {}, "" pull_request = body.get("pull_request") diff --git a/pr_agent/settings/pr_line_questions_prompts.toml b/pr_agent/settings/pr_line_questions_prompts.toml new file mode 100644 index 00000000..7100d3fe --- /dev/null +++ b/pr_agent/settings/pr_line_questions_prompts.toml @@ -0,0 +1,53 @@ +[pr_line_questions_prompt] +system="""You are PR-Reviewer, a language model designed to answer questions about a Git Pull Request (PR). + +Your goal is to answer questions\\tasks about specific lines of code in the PR, and provide feedback. +Be informative, constructive, and give examples. Try to be as specific as possible. +Don't avoid answering the questions. You must answer the questions, as best as you can, without adding any unrelated content. + +Additional guidelines: +- When quoting variables or names from the code, use backticks (`) instead of single quote ('). +- If relevant, use bullet points. +- Be short and to the point. + +Example Hunk Structure: +====== +## file: 'src/file1.py' + +@@ -12,5 +12,5 @@ def func1(): +code line 1 that remained unchanged in the PR +code line 2 that remained unchanged in the PR +-code line that was removed in the PR ++code line added in the PR +code line 3 that remained unchanged in the PR +====== + +""" + +user="""PR Info: + +Title: '{{title}}' + +Branch: '{{branch}}' + + +Here is a context hunk from the PR diff: +====== +{{ full_hunk|trim }} +====== + + +Now focus on the selected lines from the hunk: +====== +{{ selected_lines|trim }} +====== +Note that lines in the diff body are prefixed with a symbol that represents the type of change: '-' for deletions, '+' for additions, and ' ' (a space) for unchanged lines + + +A question about the selected lines: +====== +{{ question|trim }} +====== + +Response to the question: +""" diff --git a/pr_agent/tools/pr_line_questions.py b/pr_agent/tools/pr_line_questions.py new file mode 100644 index 00000000..90e1562e --- /dev/null +++ b/pr_agent/tools/pr_line_questions.py @@ -0,0 +1,105 @@ +import argparse +import copy +from functools import partial + +from jinja2 import Environment, StrictUndefined + +from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler +from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler +from pr_agent.algo.git_patch_processing import convert_to_hunks_with_lines_numbers, \ + extract_hunk_lines_from_patch +from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models +from pr_agent.algo.token_handler import TokenHandler +from pr_agent.algo.utils import ModelType +from pr_agent.config_loader import get_settings +from pr_agent.git_providers import get_git_provider +from pr_agent.git_providers.git_provider import get_main_pr_language +from pr_agent.log import get_logger +from pr_agent.servers.ai_disclaimer import AiDisclaimer +from pr_agent.servers.help import HelpMessage + + +class PR_LineQuestions: + def __init__(self, pr_url: str, args=None, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler): + self.question_str = self.parse_args(args) + self.git_provider = get_git_provider()(pr_url) + + self.ai_handler = ai_handler() + + self.vars = { + "title": self.git_provider.pr.title, + "branch": self.git_provider.get_pr_branch(), + "diff": "", # empty diff for initial calculation + "question": self.question_str, + "full_hunk": "", + "selected_lines": "", + } + self.token_handler = TokenHandler(self.git_provider.pr, + self.vars, + get_settings().pr_line_questions_prompt.system, + get_settings().pr_line_questions_prompt.user) + self.patches_diff = None + self.prediction = None + + def parse_args(self, args): + if args and len(args) > 0: + question_str = " ".join(args) + else: + question_str = "" + return question_str + + + async def run(self): + get_logger().info('Answering a PR lines question...') + # if get_settings().config.publish_output: + # self.git_provider.publish_comment("Preparing answer...", is_temporary=True) + + self.patch_with_lines = "" + ask_diff = get_settings().get('ask_diff_hunk', "") + line_start = get_settings().get('line_start', '') + line_end = get_settings().get('line_end', '') + side = get_settings().get('side', 'RIGHT') + file_name = get_settings().get('file_name', '') + comment_id = get_settings().get('comment_id', '') + if ask_diff: + self.patch_with_lines, self.selected_lines = extract_hunk_lines_from_patch(ask_diff, + file_name, + line_start=line_start, + line_end=line_end, + side=side + ) + else: + diff_files = self.git_provider.get_diff_files() + for file in diff_files: + if file.filename == file_name: + self.patch_with_lines, self.selected_lines = extract_hunk_lines_from_patch(file.patch, file.filename, + line_start=line_start, + line_end=line_end, + side=side) + if self.patch_with_lines: + response = await retry_with_fallback_models(self._get_prediction, model_type=ModelType.TURBO) + + get_logger().info('Preparing answer...') + if comment_id: + self.git_provider.reply_to_comment_from_comment_id(comment_id, response) + else: + self.git_provider.publish_comment(response) + + return "" + + async def _get_prediction(self, model: str): + variables = copy.deepcopy(self.vars) + variables["full_hunk"] = self.patch_with_lines # update diff + variables["selected_lines"] = self.selected_lines + environment = Environment(undefined=StrictUndefined) + system_prompt = environment.from_string(get_settings().pr_line_questions_prompt.system).render(variables) + user_prompt = environment.from_string(get_settings().pr_line_questions_prompt.user).render(variables) + if get_settings().config.verbosity_level >= 2: + # get_logger().info(f"\nSystem prompt:\n{system_prompt}") + # get_logger().info(f"\nUser prompt:\n{user_prompt}") + print(f"\nSystem prompt:\n{system_prompt}") + print(f"\nUser prompt:\n{user_prompt}") + + response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2, + system=system_prompt, user=user_prompt) + return response From 8ed98c8a4f9139088cdd56d4e85efcb801d19b6b Mon Sep 17 00:00:00 2001 From: "Hussam.lawen" Date: Thu, 15 Feb 2024 15:20:15 +0200 Subject: [PATCH 2/9] Add Documentation --- docs/ASK.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ASK.md b/docs/ASK.md index 5e410e4a..b8eb43df 100644 --- a/docs/ASK.md +++ b/docs/ASK.md @@ -12,4 +12,13 @@ ___ ___ +## Ask lines +You can run `/ask` on specific lines of code in the PR from the PR's diff view. The tool will answer questions based on the code changes in the selected lines. +- Click on the + sign next to the line number to select the line. +- To select multiple lines, click on the + sign of the first line and then hold and drag to select the rest of the lines. +- write `/ask "..."` in the comment box and press `Add single comment` button. + + + + Note that the tool does not have "memory" of previous questions, and answers each question independently. \ No newline at end of file From 24dd57e5b7a8193c0354a061067109a335b8e43f Mon Sep 17 00:00:00 2001 From: "Hussam.lawen" Date: Thu, 15 Feb 2024 17:14:06 +0200 Subject: [PATCH 3/9] clean --- pr_agent/tools/pr_line_questions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pr_agent/tools/pr_line_questions.py b/pr_agent/tools/pr_line_questions.py index 90e1562e..e6386fd1 100644 --- a/pr_agent/tools/pr_line_questions.py +++ b/pr_agent/tools/pr_line_questions.py @@ -15,7 +15,6 @@ from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import get_main_pr_language from pr_agent.log import get_logger -from pr_agent.servers.ai_disclaimer import AiDisclaimer from pr_agent.servers.help import HelpMessage From cfe794947d92bfcce324c3ac3a9c8a137dee5af8 Mon Sep 17 00:00:00 2001 From: "Hussam.lawen" Date: Thu, 15 Feb 2024 21:35:51 +0200 Subject: [PATCH 4/9] Gitlab /ask line works --- pr_agent/git_providers/gitlab_provider.py | 5 +++++ pr_agent/servers/gitlab_webhook.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index c4a9b488..b08fd336 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -181,6 +181,11 @@ class GitLabProvider(GitProvider): def edit_comment(self, comment, body: str): self.mr.notes.update(comment.id,{'body': body} ) + def reply_to_comment_from_comment_id(self, comment_id: int, body: str): + discussion = self.mr.discussions.get(comment_id) + discussion.notes.create({'body': body}) + self.mr.notes.create({'body': body, 'in_reply_to_discussion_id': comment_id}) + def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str): edit_type, found, source_line_no, target_file, target_line_no = self.search_line(relevant_file, relevant_line_in_file) diff --git a/pr_agent/servers/gitlab_webhook.py b/pr_agent/servers/gitlab_webhook.py index a5d5a115..165cd0a3 100644 --- a/pr_agent/servers/gitlab_webhook.py +++ b/pr_agent/servers/gitlab_webhook.py @@ -64,7 +64,25 @@ async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request): mr = data['merge_request'] url = mr.get('url') body = data.get('object_attributes', {}).get('note') + if data.get('object_attributes', {}).get('type') == 'DiffNote' and '/ask' in body: + line_range_ = data['object_attributes']['position']['line_range'] + + # if line_range_['start']['type'] == 'new': + start_line = line_range_['start']['new_line'] + end_line = line_range_['end']['new_line'] + # else: + # start_line = line_range_['start']['old_line'] + # end_line = line_range_['end']['old_line'] + + question = body.replace('/ask', '').strip() + path = data['object_attributes']['position']['new_path'] + side = 'RIGHT'# if line_range_['start']['type'] == 'new' else 'LEFT' + comment_id = data['object_attributes']["discussion_id"] + get_logger().info(f"Handling line comment") + body = f"/ask_line --line_start={start_line} --line_end={end_line} --side={side} --file_name={path} --comment_id={comment_id} {question}" + handle_request(background_tasks, url, body, log_context) + return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"})) From 6712c0a7f861922e86ec0eff2005f8749ec47581 Mon Sep 17 00:00:00 2001 From: "Hussam.lawen" Date: Thu, 15 Feb 2024 21:43:25 +0200 Subject: [PATCH 5/9] remove unnecessary call --- pr_agent/git_providers/gitlab_provider.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index b08fd336..7e604c40 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -184,7 +184,6 @@ class GitLabProvider(GitProvider): def reply_to_comment_from_comment_id(self, comment_id: int, body: str): discussion = self.mr.discussions.get(comment_id) discussion.notes.create({'body': body}) - self.mr.notes.create({'body': body, 'in_reply_to_discussion_id': comment_id}) def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str): edit_type, found, source_line_no, target_file, target_line_no = self.search_line(relevant_file, From 3eef0a4ebd3eec130fa3da53f854499cbc83e84a Mon Sep 17 00:00:00 2001 From: "Hussam.lawen" Date: Thu, 15 Feb 2024 22:21:58 +0200 Subject: [PATCH 6/9] fix line selection, don't support line deletions --- pr_agent/algo/git_patch_processing.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pr_agent/algo/git_patch_processing.py b/pr_agent/algo/git_patch_processing.py index d7d7a7e4..383d488f 100644 --- a/pr_agent/algo/git_patch_processing.py +++ b/pr_agent/algo/git_patch_processing.py @@ -266,7 +266,7 @@ def extract_hunk_lines_from_patch(patch: str, file_name, line_start, line_end, s skip_hunk = False selected_lines_num = 0 header_line = line - patch_with_lines_str += f'\n{header_line}\n' + match = RE_HUNK_HEADER.match(line) res = list(match.groups()) @@ -289,6 +289,7 @@ def extract_hunk_lines_from_patch(patch: str, file_name, line_start, line_end, s if not (start2 <= line_start <= start2 + size2): skip_hunk = True continue + patch_with_lines_str += f'\n{header_line}\n' elif not skip_hunk: if side.lower() == 'right' and line_start <= start2 + selected_lines_num <= line_end: @@ -296,6 +297,7 @@ def extract_hunk_lines_from_patch(patch: str, file_name, line_start, line_end, s if side.lower() == 'left' and start1 <= selected_lines_num + start1 <= line_end: selected_lines += line + '\n' patch_with_lines_str += line + '\n' - selected_lines_num += 1 + if not line.startswith('-'): # currently we don't support /ask line for deleted lines + selected_lines_num += 1 return patch_with_lines_str.rstrip(), selected_lines.rstrip() \ No newline at end of file From 40fbd55da4b8aec5d9f7a71e6c0e3f0874a4ebe8 Mon Sep 17 00:00:00 2001 From: mrT23 Date: Fri, 16 Feb 2024 12:58:55 +0200 Subject: [PATCH 7/9] added github action support --- pr_agent/servers/github_action_runner.py | 8 ++++++++ pr_agent/servers/github_app.py | 10 ++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/pr_agent/servers/github_action_runner.py b/pr_agent/servers/github_action_runner.py index f536861a..b4004dd8 100644 --- a/pr_agent/servers/github_action_runner.py +++ b/pr_agent/servers/github_action_runner.py @@ -8,6 +8,7 @@ from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.utils import apply_repo_settings from pr_agent.log import get_logger +from pr_agent.servers.github_app import handle_line_comments from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions from pr_agent.tools.pr_description import PRDescription from pr_agent.tools.pr_reviewer import PRReviewer @@ -114,6 +115,13 @@ async def run_action(): is_pr = True else: url = event_payload.get("issue", {}).get("url") + try: + if 'subject_type' in event_payload["comment"] and event_payload["comment"]["subject_type"] == "line": + comment_body = handle_line_comments(event_payload, comment_body) + except Exception as e: + get_logger().error(f"Failed to handle line comments: {e}") + + if url: body = comment_body.strip().lower() comment_id = event_payload.get("comment", {}).get("id") diff --git a/pr_agent/servers/github_app.py b/pr_agent/servers/github_app.py index a465279c..91939ec0 100644 --- a/pr_agent/servers/github_app.py +++ b/pr_agent/servers/github_app.py @@ -100,8 +100,11 @@ async def handle_request(body: Dict[str, Any], event: str): api_url = body["issue"]["pull_request"]["url"] elif "comment" in body and "pull_request_url" in body["comment"]: api_url = body["comment"]["pull_request_url"] - if 'subject_type' in body["comment"] and body["comment"]["subject_type"] == "line": - comment_body = await handle_line_comments(action, body, comment_body, event) + try: + if 'subject_type' in body["comment"] and body["comment"]["subject_type"] == "line": + comment_body = handle_line_comments(body, comment_body) + except Exception as e: + get_logger().error(f"Failed to handle line comments: {e}") else: return {} @@ -193,7 +196,7 @@ async def handle_request(body: Dict[str, Any], event: str): return {} -async def handle_line_comments(action, body, comment_body, event): +def handle_line_comments(body, comment_body): # handle line comments start_line = body["comment"]["start_line"] end_line = body["comment"]["line"] @@ -205,7 +208,6 @@ async def handle_line_comments(action, body, comment_body, event): side = body["comment"]["side"] comment_id = body["comment"]["id"] if '/ask' in comment_body: - get_logger().info(f"Handling line comment because of event={event} and action={action}") comment_body = f"/ask_line --line_start={start_line} --line_end={end_line} --side={side} --file_name={path} --comment_id={comment_id} {question}" return comment_body From c98e736e3b2354d8bbe91c56fc3698a491c0f60b Mon Sep 17 00:00:00 2001 From: mrT23 Date: Fri, 16 Feb 2024 14:49:01 +0200 Subject: [PATCH 8/9] added github action support --- .../git_providers/azuredevops_provider.py | 2 +- pr_agent/git_providers/bitbucket_provider.py | 2 +- .../bitbucket_server_provider.py | 2 +- pr_agent/git_providers/codecommit_provider.py | 2 +- pr_agent/git_providers/gerrit_provider.py | 2 +- pr_agent/git_providers/git_provider.py | 2 +- pr_agent/git_providers/github_provider.py | 4 +++- pr_agent/git_providers/gitlab_provider.py | 2 +- pr_agent/servers/github_action_runner.py | 23 ++++++++++++------- pr_agent/servers/github_app.py | 8 +++++-- 10 files changed, 31 insertions(+), 18 deletions(-) diff --git a/pr_agent/git_providers/azuredevops_provider.py b/pr_agent/git_providers/azuredevops_provider.py index 75fa6001..0b7e76ee 100644 --- a/pr_agent/git_providers/azuredevops_provider.py +++ b/pr_agent/git_providers/azuredevops_provider.py @@ -444,7 +444,7 @@ class AzureDevopsProvider(GitProvider): "Azure DevOps provider does not support issue comments yet" ) - def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]: + def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]: return True def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index 342f592c..62ea46c7 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -298,7 +298,7 @@ class BitbucketProvider(GitProvider): "Bitbucket provider does not support issue comments yet" ) - def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]: + def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]: return True def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index c8ac30f2..3b8168ea 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -288,7 +288,7 @@ class BitbucketServerProvider(GitProvider): "Bitbucket provider does not support issue comments yet" ) - def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]: + def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]: return True def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: diff --git a/pr_agent/git_providers/codecommit_provider.py b/pr_agent/git_providers/codecommit_provider.py index 50398c17..cbaf75f1 100644 --- a/pr_agent/git_providers/codecommit_provider.py +++ b/pr_agent/git_providers/codecommit_provider.py @@ -297,7 +297,7 @@ class CodeCommitProvider(GitProvider): settings_filename = ".pr_agent.toml" return self.codecommit_client.get_file(self.repo_name, settings_filename, self.pr.source_commit, optional=True) - def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]: + def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]: get_logger().info("CodeCommit provider does not support eyes reaction yet") return True diff --git a/pr_agent/git_providers/gerrit_provider.py b/pr_agent/git_providers/gerrit_provider.py index a1491c78..5a6da1a2 100644 --- a/pr_agent/git_providers/gerrit_provider.py +++ b/pr_agent/git_providers/gerrit_provider.py @@ -212,7 +212,7 @@ class GerritProvider(GitProvider): raise NotImplementedError( 'Getting labels is not implemented for the gerrit provider') - def add_eyes_reaction(self, issue_comment_id: int): + def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False): raise NotImplementedError( 'Adding reactions is not implemented for the gerrit provider') diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index 69454f0e..35511382 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -162,7 +162,7 @@ class GitProvider(ABC): pass @abstractmethod - def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]: + def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]: pass @abstractmethod diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index b4e530a0..f7f1e529 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -452,7 +452,9 @@ class GithubProvider(GitProvider): except Exception: return "" - def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]: + def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]: + if disable_eyes: + return None try: reaction = self.pr.get_issue_comment(issue_comment_id).create_reaction("eyes") return reaction.id diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index 7e604c40..d0e8d575 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -368,7 +368,7 @@ class GitLabProvider(GitProvider): except Exception: return "" - def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]: + def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]: return True def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: diff --git a/pr_agent/servers/github_action_runner.py b/pr_agent/servers/github_action_runner.py index b4004dd8..d4278156 100644 --- a/pr_agent/servers/github_action_runner.py +++ b/pr_agent/servers/github_action_runner.py @@ -103,31 +103,38 @@ async def run_action(): await PRCodeSuggestions(pr_url).run() # Handle issue comment event - elif GITHUB_EVENT_NAME == "issue_comment": + elif GITHUB_EVENT_NAME == "issue_comment" or GITHUB_EVENT_NAME == "pull_request_review_comment": action = event_payload.get("action") if action in ["created", "edited"]: comment_body = event_payload.get("comment", {}).get("body") + try: + if GITHUB_EVENT_NAME == "pull_request_review_comment": + if '/ask' in comment_body: + comment_body = handle_line_comments(event_payload, comment_body) + except Exception as e: + get_logger().error(f"Failed to handle line comments: {e}") + return if comment_body: is_pr = False + disable_eyes = False # check if issue is pull request if event_payload.get("issue", {}).get("pull_request"): url = event_payload.get("issue", {}).get("pull_request", {}).get("url") is_pr = True + elif event_payload.get("comment", {}).get("pull_request_url"): # for 'pull_request_review_comment + url = event_payload.get("comment", {}).get("pull_request_url") + is_pr = True + disable_eyes = True else: url = event_payload.get("issue", {}).get("url") - try: - if 'subject_type' in event_payload["comment"] and event_payload["comment"]["subject_type"] == "line": - comment_body = handle_line_comments(event_payload, comment_body) - except Exception as e: - get_logger().error(f"Failed to handle line comments: {e}") - if url: body = comment_body.strip().lower() comment_id = event_payload.get("comment", {}).get("id") provider = get_git_provider()(pr_url=url) if is_pr: - await PRAgent().handle_request(url, body, notify=lambda: provider.add_eyes_reaction(comment_id)) + await PRAgent().handle_request(url, body, + notify=lambda: provider.add_eyes_reaction(comment_id, disable_eyes=disable_eyes)) else: await PRAgent().handle_request(url, body) diff --git a/pr_agent/servers/github_app.py b/pr_agent/servers/github_app.py index 91939ec0..a218f191 100644 --- a/pr_agent/servers/github_app.py +++ b/pr_agent/servers/github_app.py @@ -96,13 +96,16 @@ async def handle_request(body: Dict[str, Any], event: str): get_logger().info(f"Ignoring comment from {bot_user} user") return {} get_logger().info(f"Processing comment from {sender} user") + disable_eyes = False if "issue" in body and "pull_request" in body["issue"] and "url" in body["issue"]["pull_request"]: api_url = body["issue"]["pull_request"]["url"] elif "comment" in body and "pull_request_url" in body["comment"]: api_url = body["comment"]["pull_request_url"] try: - if 'subject_type' in body["comment"] and body["comment"]["subject_type"] == "line": + if ('/ask' in comment_body and + 'subject_type' in body["comment"] and body["comment"]["subject_type"] == "line"): comment_body = handle_line_comments(body, comment_body) + disable_eyes = True except Exception as e: get_logger().error(f"Failed to handle line comments: {e}") @@ -114,7 +117,8 @@ async def handle_request(body: Dict[str, Any], event: str): comment_id = body.get("comment", {}).get("id") provider = get_git_provider()(pr_url=api_url) with get_logger().contextualize(**log_context): - await agent.handle_request(api_url, comment_body, notify=lambda: provider.add_eyes_reaction(comment_id)) + await agent.handle_request(api_url, comment_body, + notify=lambda: provider.add_eyes_reaction(comment_id, disable_eyes=disable_eyes)) # handle pull_request event: # automatically review opened/reopened/ready_for_review PRs as long as they're not in draft, From 5918943959b34fac7d2fddb563a55c73187b60b8 Mon Sep 17 00:00:00 2001 From: mrT23 Date: Sun, 18 Feb 2024 08:07:12 +0200 Subject: [PATCH 9/9] readme --- docs/ASK.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ASK.md b/docs/ASK.md index b8eb43df..70126a0d 100644 --- a/docs/ASK.md +++ b/docs/ASK.md @@ -14,8 +14,8 @@ ___ ## Ask lines You can run `/ask` on specific lines of code in the PR from the PR's diff view. The tool will answer questions based on the code changes in the selected lines. -- Click on the + sign next to the line number to select the line. -- To select multiple lines, click on the + sign of the first line and then hold and drag to select the rest of the lines. +- Click on the '+' sign next to the line number to select the line. +- To select multiple lines, click on the '+' sign of the first line and then hold and drag to select the rest of the lines. - write `/ask "..."` in the comment box and press `Add single comment` button.