diff --git a/pr_agent/git_providers/azuredevops_provider.py b/pr_agent/git_providers/azuredevops_provider.py index 80bf68c5..35165bdd 100644 --- a/pr_agent/git_providers/azuredevops_provider.py +++ b/pr_agent/git_providers/azuredevops_provider.py @@ -18,14 +18,10 @@ ADO_APP_CLIENT_DEFAULT_ID = "499b84ac-1321-427f-aa17-267ca6975798/.default" MAX_PR_DESCRIPTION_AZURE_LENGTH = 4000-1 try: - # noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences from azure.devops.connection import Connection # noinspection PyUnresolvedReferences - from azure.devops.v7_1.git.models import (Comment, CommentThread, - GitPullRequest, - GitPullRequestIterationChanges, - GitVersionDescriptor) + from azure.devops.released.git import (Comment, CommentThread, GitPullRequest, GitVersionDescriptor, GitClient, CommentThreadContext, CommentPosition) # noinspection PyUnresolvedReferences from azure.identity import DefaultAzureCredential from msrest.authentication import BasicAuthentication @@ -77,40 +73,13 @@ class AzureDevopsProvider(GitProvider): f"relevant_lines_start is {relevant_lines_start}") continue - if relevant_lines_end > relevant_lines_start: - post_parameters = { - "body": body, - "path": relevant_file, - "line": relevant_lines_end, - "start_line": relevant_lines_start, - "start_side": "RIGHT", - } - else: # API is different for single line comments - post_parameters = { - "body": body, - "path": relevant_file, - "line": relevant_lines_start, - "side": "RIGHT", - } - post_parameters_list.append(post_parameters) - if not post_parameters_list: - return False - - for post_parameters in post_parameters_list: + thread_context = CommentThreadContext( + file_path=relevant_file, + right_file_start=CommentPosition(offset=1, line=relevant_lines_start), + right_file_end=CommentPosition(offset=1, line=relevant_lines_end)) + comment = Comment(content=body, comment_type=1) + thread = CommentThread(comments=[comment], thread_context=thread_context) try: - comment = Comment(content=post_parameters["body"], comment_type=1) - thread = CommentThread(comments=[comment], - thread_context={ - "filePath": post_parameters["path"], - "rightFileStart": { - "line": post_parameters["start_line"], - "offset": 1, - }, - "rightFileEnd": { - "line": post_parameters["line"], - "offset": 1, - }, - }) self.azure_devops_client.create_thread( comment_thread=thread, project=self.workspace_slug, @@ -118,34 +87,36 @@ class AzureDevopsProvider(GitProvider): pull_request_id=self.pr_num ) except Exception as e: - get_logger().warning(f"Azure failed to publish code suggestion, error: {e}") + get_logger().error(f"Azure failed to publish code suggestion, error: {e}", suggestion=suggestion) return True - + def reply_to_comment_from_comment_id(self, comment_id: int, body: str, is_temporary: bool = False) -> Comment: + # comment_id is actually thread_id + return self.reply_to_thread(comment_id, body, is_temporary) def get_pr_description_full(self) -> str: return self.pr.description - def edit_comment(self, comment, body: str): + def edit_comment(self, comment: Comment, body: str): try: self.azure_devops_client.update_comment( repository_id=self.repo_slug, pull_request_id=self.pr_num, - thread_id=comment["thread_id"], - comment_id=comment["comment_id"], + thread_id=comment.thread_id, + comment_id=comment.id, comment=Comment(content=body), project=self.workspace_slug, ) except Exception as e: get_logger().exception(f"Failed to edit comment, error: {e}") - def remove_comment(self, comment): + def remove_comment(self, comment: Comment): try: self.azure_devops_client.delete_comment( repository_id=self.repo_slug, pull_request_id=self.pr_num, - thread_id=comment["thread_id"], - comment_id=comment["comment_id"], + thread_id=comment.thread_id, + comment_id=comment.id, project=self.workspace_slug, ) except Exception as e: @@ -176,10 +147,6 @@ class AzureDevopsProvider(GitProvider): return [] def is_supported(self, capability: str) -> bool: - if capability in [ - "get_issue_comments", - ]: - return False return True def set_pr(self, pr_url: str): @@ -378,22 +345,30 @@ class AzureDevopsProvider(GitProvider): get_logger().exception(f"Failed to get diff files, error: {e}") return [] - def publish_comment(self, pr_comment: str, is_temporary: bool = False, thread_context=None): + def publish_comment(self, pr_comment: str, is_temporary: bool = False, thread_context=None) -> Comment: if is_temporary and not get_settings().config.publish_output_progress: get_logger().debug(f"Skipping publish_comment for temporary comment: {pr_comment}") return None comment = Comment(content=pr_comment) - thread = CommentThread(comments=[comment], thread_context=thread_context, status=1) + thread = CommentThread(comments=[comment], thread_context=thread_context, status="closed") thread_response = self.azure_devops_client.create_thread( comment_thread=thread, project=self.workspace_slug, repository_id=self.repo_slug, pull_request_id=self.pr_num, ) - response = {"thread_id": thread_response.id, "comment_id": thread_response.comments[0].id} + created_comment = thread_response.comments[0] + created_comment.thread_id = thread_response.id if is_temporary: - self.temp_comments.append(response) - return response + self.temp_comments.append(created_comment) + return created_comment + + def publish_persistent_comment(self, pr_comment: str, + initial_header: str, + update_header: bool = True, + name='review', + final_update_message=True): + return self.publish_persistent_comment_full(pr_comment, initial_header, update_header, name, final_update_message) def publish_description(self, pr_title: str, pr_body: str): if len(pr_body) > MAX_PR_DESCRIPTION_AZURE_LENGTH: @@ -438,7 +413,6 @@ class AzureDevopsProvider(GitProvider): def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None): self.publish_inline_comments([self.create_inline_comment(body, relevant_file, relevant_line_in_file)]) - def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, absolute_position: int = None): position, absolute_position = find_line_number_of_relevant_line_in_file(self.get_diff_files(), @@ -522,7 +496,7 @@ class AzureDevopsProvider(GitProvider): def get_user_id(self): return 0 - def get_issue_comments(self): + def get_issue_comments(self) -> list[Comment]: threads = self.azure_devops_client.get_threads(repository_id=self.repo_slug, pull_request_id=self.pr_num, project=self.workspace_slug) threads.reverse() comment_list = [] @@ -540,6 +514,36 @@ class AzureDevopsProvider(GitProvider): def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: return True + def set_like(self, thread_id: int, comment_id: int, create: bool = True): + if create: + self.azure_devops_client.create_like(self.repo_slug, self.pr_num, thread_id, comment_id, project=self.workspace_slug) + else: + self.azure_devops_client.delete_like(self.repo_slug, self.pr_num, thread_id, comment_id, project=self.workspace_slug) + + def set_thread_status(self, thread_id: int, status: str): + try: + self.azure_devops_client.update_thread(CommentThread(status=status), self.repo_slug, self.pr_num, thread_id, self.workspace_slug) + except Exception as e: + get_logger().exception(f"Failed to set thread status, error: {e}") + + def reply_to_thread(self, thread_id: int, body: str, is_temporary: bool = False) -> Comment: + try: + comment = Comment(content=body) + response = self.azure_devops_client.create_comment(comment, self.repo_slug, self.pr_num, thread_id, self.workspace_slug) + response.thread_id = thread_id + if is_temporary: + self.temp_comments.append(response) + return response + except Exception as e: + get_logger().exception(f"Failed to reply to thread, error: {e}") + + def get_thread_context(self, thread_id: int) -> CommentThreadContext: + try: + thread = self.azure_devops_client.get_pull_request_thread(self.repo_slug, self.pr_num, thread_id, self.workspace_slug) + return thread.thread_context + except Exception as e: + get_logger().exception(f"Failed to set thread status, error: {e}") + @staticmethod def _parse_pr_url(pr_url: str) -> Tuple[str, str, int]: parsed_url = urlparse(pr_url) @@ -562,7 +566,7 @@ class AzureDevopsProvider(GitProvider): return workspace_slug, repo_slug, pr_number @staticmethod - def _get_azure_devops_client(): + def _get_azure_devops_client() -> GitClient: org = get_settings().azure_devops.get("org", None) pat = get_settings().azure_devops.get("pat", None) @@ -622,3 +626,13 @@ class AzureDevopsProvider(GitProvider): def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_line_end: int = None) -> str: return self.pr_url+f"?_a=files&path={relevant_file}" + + def get_comment_url(self, comment) -> str: + return self.pr_url + "?discussionId=" + str(comment.thread_id) + + def get_latest_commit_url(self) -> str: + commits = self.azure_devops_client.get_pull_request_commits(self.repo_slug, self.pr_num, self.workspace_slug) + last = commits[0] + url = self.azure_devops_client.normalized_url + "/" + self.workspace_slug + "/_git/" + self.repo_slug + "/commit/" + last.commit_id + return url + \ No newline at end of file diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index 2895bd55..dfb5b224 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -228,7 +228,7 @@ class GitProvider(ABC): update_header: bool = True, name='review', final_update_message=True): - self.publish_comment(pr_comment) + return self.publish_comment(pr_comment) def publish_persistent_comment_full(self, pr_comment: str, initial_header: str, @@ -250,14 +250,13 @@ class GitProvider(ABC): # response = self.mr.notes.update(comment.id, {'body': pr_comment_updated}) self.edit_comment(comment, pr_comment_updated) if final_update_message: - self.publish_comment( + return self.publish_comment( f"**[Persistent {name}]({comment_url})** updated to latest commit {latest_commit_url}") - return + return comment except Exception as e: get_logger().exception(f"Failed to update persistent review, error: {e}") pass - self.publish_comment(pr_comment) - + return self.publish_comment(pr_comment) @abstractmethod def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None): diff --git a/pr_agent/servers/azuredevops_server_webhook.py b/pr_agent/servers/azuredevops_server_webhook.py index bb97b839..8eacbf66 100644 --- a/pr_agent/servers/azuredevops_server_webhook.py +++ b/pr_agent/servers/azuredevops_server_webhook.py @@ -22,40 +22,73 @@ from starlette_context.middleware import RawContextMiddleware from pr_agent.agent.pr_agent import PRAgent, command2class from pr_agent.algo.utils import update_settings_from_args from pr_agent.config_loader import get_settings +from pr_agent.git_providers import get_git_provider_with_context +from pr_agent.git_providers.azuredevops_provider import AzureDevopsProvider from pr_agent.git_providers.utils import apply_repo_settings from pr_agent.log import LoggingFormat, get_logger, setup_logger setup_logger(fmt=LoggingFormat.JSON, level=get_settings().get("CONFIG.LOG_LEVEL", "DEBUG")) -security = HTTPBasic() +security = HTTPBasic(auto_error=False) router = APIRouter() available_commands_rgx = re.compile(r"^\/(" + "|".join(command2class.keys()) + r")\s*") azure_devops_server = get_settings().get("azure_devops_server") -WEBHOOK_USERNAME = azure_devops_server.get("webhook_username") -WEBHOOK_PASSWORD = azure_devops_server.get("webhook_password") +WEBHOOK_USERNAME = azure_devops_server.get("webhook_username", None) +WEBHOOK_PASSWORD = azure_devops_server.get("webhook_password", None) -async def handle_request_comment( url: str, body: str, log_context: dict -): +async def handle_request_comment(url: str, body: str, thread_id: int, comment_id: int, log_context: dict): log_context["action"] = body log_context["api_url"] = url - try: with get_logger().contextualize(**log_context): - await PRAgent().handle_request(url, body) + agent = PRAgent() + provider = get_git_provider_with_context(pr_url=url) + body = handle_line_comment(body, thread_id, provider) + handled = await agent.handle_request(url, body, notify=lambda: provider.reply_to_thread(thread_id, "On it! ⏳", True)) + # mark command comment as closed + if handled: + provider.set_thread_status(thread_id, "closed") + provider.remove_initial_comment() except Exception as e: get_logger().exception(f"Failed to handle webhook", artifact={"url": url, "body": body}, error=str(e)) +def handle_line_comment(body: str, thread_id: int, provider: AzureDevopsProvider): + body = body.strip() + if not body.startswith('/ask '): + return body + thread_context = provider.get_thread_context(thread_id) + if not thread_context: + return body + + path = thread_context.file_path + if thread_context.left_file_end or thread_context.left_file_start: + start_line = thread_context.left_file_start.line + end_line = thread_context.left_file_end.line + side = "left" + elif thread_context.right_file_end or thread_context.right_file_start: + start_line = thread_context.right_file_start.line + end_line = thread_context.right_file_end.line + side = "right" + else: + get_logger().info("No line range found in thread context", artifact={"thread_context": thread_context}) + return body + + question = body[5:].lstrip() # remove 4 chars: '/ask ' + return f"/ask_line --line_start={start_line} --line_end={end_line} --side={side} --file_name={path} --comment_id={thread_id} {question}" # currently only basic auth is supported with azure webhooks # for this reason, https must be enabled to ensure the credentials are not sent in clear text def authorize(credentials: HTTPBasicCredentials = Depends(security)): - is_user_ok = secrets.compare_digest(credentials.username, WEBHOOK_USERNAME) - is_pass_ok = secrets.compare_digest(credentials.password, WEBHOOK_PASSWORD) - if not (is_user_ok and is_pass_ok): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail='Incorrect username or password.', - headers={'WWW-Authenticate': 'Basic'}, - ) + if WEBHOOK_USERNAME is None or WEBHOOK_PASSWORD is None: + return + + is_user_ok = secrets.compare_digest(credentials.username, WEBHOOK_USERNAME) + is_pass_ok = secrets.compare_digest(credentials.password, WEBHOOK_PASSWORD) + if not (is_user_ok and is_pass_ok): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Incorrect username or password.', + headers={'WWW-Authenticate': 'Basic'}, + ) async def _perform_commands_azure(commands_conf: str, agent: PRAgent, api_url: str, log_context: dict): @@ -83,7 +116,6 @@ async def _perform_commands_azure(commands_conf: str, agent: PRAgent, api_url: s async def handle_request_azure(data, log_context): - actions = [] if data["eventType"] == "git.pullrequest.created": # API V1 (latest) pr_url = unquote(data["resource"]["_links"]["web"]["href"].replace("_apis/git/repositories", "_git")) @@ -95,11 +127,16 @@ async def handle_request_azure(data, log_context): content=jsonable_encoder({"message": "webhook triggered successfully"}) ) elif data["eventType"] == "ms.vss-code.git-pullrequest-comment-event" and "content" in data["resource"]["comment"]: - if available_commands_rgx.match(data["resource"]["comment"]["content"]): + comment = data["resource"]["comment"] + if available_commands_rgx.match(comment["content"]): if(data["resourceVersion"] == "2.0"): repo = data["resource"]["pullRequest"]["repository"]["webUrl"] pr_url = unquote(f'{repo}/pullrequest/{data["resource"]["pullRequest"]["pullRequestId"]}') - actions = [data["resource"]["comment"]["content"]] + action = comment["content"] + thread_url = comment["_links"]["threads"]["href"] + thread_id = int(thread_url.split("/")[-1]) + comment_id = int(comment["id"]) + pass else: # API V1 not supported as it does not contain the PR URL return JSONResponse( @@ -119,15 +156,14 @@ async def handle_request_azure(data, log_context): log_context["event"] = data["eventType"] log_context["api_url"] = pr_url - for action in actions: - try: - await handle_request_comment(pr_url, action, log_context) - except Exception as e: - get_logger().error("Azure DevOps Trigger failed. Error:" + str(e)) - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content=json.dumps({"message": "Internal server error"}), - ) + try: + await handle_request_comment(pr_url, action, thread_id, comment_id, log_context) + except Exception as e: + get_logger().error("Azure DevOps Trigger failed. Error:" + str(e)) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=json.dumps({"message": "Internal server error"}), + ) return JSONResponse( status_code=status.HTTP_202_ACCEPTED, content=jsonable_encoder({"message": "webhook triggered successfully"}) ) diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index c742aa06..0dea2e70 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -267,14 +267,6 @@ class PRCodeSuggestions: up_to_commit_txt = f" up to commit {match.group(0)[4:-3].strip()}" return up_to_commit_txt - if isinstance(git_provider, AzureDevopsProvider): # get_latest_commit_url is not supported yet - if progress_response: - git_provider.edit_comment(progress_response, pr_comment) - new_comment = progress_response - else: - new_comment = git_provider.publish_comment(pr_comment) - return new_comment - history_header = f"#### Previous suggestions\n" last_commit_num = git_provider.get_latest_commit_url().split('/')[-1][:7] if only_fold: # A user clicked on the 'self-review' checkbox