Merge pull request #1746 from twdkeule/feature/azure-devops-persistent-comment

Implement Azure Devops persistent comment
This commit is contained in:
Tal
2025-05-12 15:56:12 +03:00
committed by GitHub
4 changed files with 139 additions and 98 deletions

View File

@ -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

View File

@ -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):

View File

@ -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"})
)

View File

@ -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