mirror of
https://github.com/qodo-ai/pr-agent.git
synced 2025-07-02 11:50:37 +08:00
Merge pull request #1746 from twdkeule/feature/azure-devops-persistent-comment
Implement Azure Devops persistent comment
This commit is contained in:
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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"})
|
||||
)
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user