Repository: qodo-ai/pr-agent
Pull request #1746: Implement Azure Devops persistent comment
Author: login: twdkeule
Labels: name: Review effort 2/5
Created: 2025-05-07T12:53:56Z | Merged: 2025-05-12T12:56:12Z

### **User description**
Adds `get_latest_commit_url()` for AzureDevops provider to implement persistent comments


___

### **PR Type**
Enhancement


___

### **Description**
- Add persistent comment support for Azure DevOps provider

- Show latest commit as markdown link in persistent comments

- Implement retrieval of latest commit URL for Azure DevOps

- Improve comment update logic to avoid unnecessary edits


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><table>
<tr>
  <td>
    <details>
      <summary><strong>azuredevops_provider.py</strong><dd><code>Add persistent comment and commit URL support for Azure DevOps</code></dd></summary>
<hr>

pr_agent/git_providers/azuredevops_provider.py

<li>Added <code>publish_persistent_comment</code> method for persistent comments<br> <li> Implemented <code>get_latest_commit_url</code> to retrieve latest commit URL<br> <li> Added <code>get_comment_url</code> for direct comment linking<br> <li> Updated comment editing to use object attributes instead of dict keys<br> <li> Minor code cleanup and removal of unsupported capabilities


</details>


  </td>
  <td><a href="https://github.com/qodo-ai/pr-agent/pull/1746/files#diff-1a90ea92bcfba3cf9f1dbc298d2e570566f4c439cb3650ee746cebdb02ca4a0b">+19/-9</a>&nbsp; &nbsp; </td>

</tr>

<tr>
  <td>
    <details>
      <summary><strong>git_provider.py</strong><dd><code>Show commit as markdown link in persistent comments</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

pr_agent/git_providers/git_provider.py

- Updated persistent comment update to show commit as markdown link


</details>


  </td>
  <td><a href="https://github.com/qodo-ai/pr-agent/pull/1746/files#diff-52d45f12b836f77ed1aef86e972e65404634ea4e2a6083fb71a9b0f9bb9e062f">+1/-1</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td>
    <details>
      <summary><strong>pr_code_suggestions.py</strong><dd><code>Improve persistent comment logic and commit link formatting</code></dd></summary>
<hr>

pr_agent/tools/pr_code_suggestions.py

<li>Use markdown link for latest commit in suggestion headers<br> <li> Avoid editing comments when no suggestion changes detected<br> <li> Refactored persistent comment update logic for clarity and efficiency


</details>


  </td>
  <td><a href="https://github.com/qodo-ai/pr-agent/pull/1746/files#diff-b57ba775e741d6f80bc4f8154b71330c011dae0ac43f3d0197e785b3e6b7117b">+16/-19</a>&nbsp; </td>

</tr>
</table></td></tr></tr></tbody></table>

___

> <details> <summary>  Need help?</summary><li>Type <code>/help how to ...</code> in the comments thread for any questions about Qodo Merge usage.</li><li>Check out the <a href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a> for more information.</li></details>


Diff:
diff --git a/pr_agent/git_providers/azuredevops_provider.py b/pr_agent/git_providers/azuredevops_provider.py
index 80bf68c56b..35165bdd55 100644
--- a/pr_agent/git_providers/azuredevops_provider.py
+++ b/pr_agent/git_providers/azuredevops_provider.py
@@ -18,14 +18,10 @@
 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 @@ def publish_code_suggestions(self, code_suggestions: list) -> bool:
                                        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 @@ def publish_code_suggestions(self, code_suggestions: list) -> bool:
                     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 @@ def get_pr_labels(self, update=False):
             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 @@ def get_diff_files(self) -> list[FilePatchInfo]:
             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 @@ def remove_initial_comment(self):
     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 @@ def get_pr_branch(self):
     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 @@ def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -
     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 @@ def _parse_pr_url(pr_url: str) -> Tuple[str, str, int]:
         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 @@ def publish_file_comments(self, file_comments: list) -> bool:
 
     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 2895bd5579..dfb5b22412 100644
--- a/pr_agent/git_providers/git_provider.py
+++ b/pr_agent/git_providers/git_provider.py
@@ -228,7 +228,7 @@ def publish_persistent_comment(self, pr_comment: str,
                                    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 @@ def publish_persistent_comment_full(self, pr_comment: str,
                     # 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 bb97b83962..8eacbf664c 100644
--- a/pr_agent/servers/azuredevops_server_webhook.py
+++ b/pr_agent/servers/azuredevops_server_webhook.py
@@ -22,40 +22,73 @@
 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 c742aa0647..0dea2e70ad 100644
--- a/pr_agent/tools/pr_code_suggestions.py
+++ b/pr_agent/tools/pr_code_suggestions.py
@@ -267,14 +267,6 @@ def _extract_link(comment_text: str):
                 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


Reviews:
user: login: mrT23
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T16:08:22Z
user: login: qodo-free-for-open-source-projects[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T16:11:52Z
user: login: mrT23
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T16:12:25Z
user: login: qodo-free-for-open-source-projects[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T16:12:42Z
user: login: twdkeule
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T09:29:19Z
user: login: twdkeule
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T09:31:45Z
user: login: mrT23
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T12:55:35Z

Review comments:
diff_hunk: @@ -251,7 +251,7 @@ def publish_persistent_comment_full(self, pr_comment: str,
                     self.edit_comment(comment, pr_comment_updated)
                     if final_update_message:
                         self.publish_comment(
-                            f"**[Persistent {name}]({comment_url})** updated to latest commit {latest_commit_url}")
+                            f"**[Persistent {name}]({comment_url})** updated to latest commit [{latest_commit_url.split("/")[-1]}]({latest_commit_url})")
path: pr_agent/git_providers/git_provider.py
user: login: mrT23
body: have you reviewed this on other git providers ? it's a shared function, that should not be edited without understanding the full implications.

far better to implement a specific one for azure if needed
created_at: 2025-05-08T16:08:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -131,8 +129,8 @@ def edit_comment(self, comment, body: str):
             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,
path: pr_agent/git_providers/azuredevops_provider.py
user: login: mrT23
body: are you sure this change is needed ?

did it not work before ?
created_at: 2025-05-08T16:09:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -623,3 +623,13 @@ def publish_file_comments(self, file_comments: list) -> bool:
 
     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
path: pr_agent/git_providers/azuredevops_provider.py
user: login: qodo-free-for-open-source-projects[bot]
body: **Suggestion:** Add null check for commits
```suggestion
    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)
        if not commits:
            get_logger().warning(f"No commits found for PR #{self.pr_num}")
            return self.pr_url
        last = commits[0]
        url = self.azure_devops_client.normalized_url + "/" + self.workspace_slug + "/_git/" + self.repo_slug + "/commit/" + last.commit_id
        return url
```

<!-- manually_applied -->
created_at: 2025-05-08T16:11:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -623,3 +623,13 @@ def publish_file_comments(self, file_comments: list) -> bool:
 
     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
path: pr_agent/git_providers/azuredevops_provider.py
user: login: qodo-free-for-open-source-projects[bot]
body: ## Code Implementation :hammer_and_wrench: 


**Implementation:** Add null check for commits in get_latest_commit_url() method to handle cases where no commits are found for a PR


```suggestion
    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)
        if not commits:
            get_logger().warning(f"No commits found for PR #{self.pr_num}")
            return self.pr_url
        last = commits[0]
        url = self.azure_devops_client.normalized_url + "/" + self.workspace_slug + "/_git/" + self.repo_slug + "/commit/" + last.commit_id
        return url
```
See review comment [here](https://github.com/qodo-ai/pr-agent/pull/1746#discussion_r2080039981)


created_at: 2025-05-08T16:12:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -251,7 +251,7 @@ def publish_persistent_comment_full(self, pr_comment: str,
                     self.edit_comment(comment, pr_comment_updated)
                     if final_update_message:
                         self.publish_comment(
-                            f"**[Persistent {name}]({comment_url})** updated to latest commit {latest_commit_url}")
+                            f"**[Persistent {name}]({comment_url})** updated to latest commit [{latest_commit_url.split("/")[-1]}]({latest_commit_url})")
path: pr_agent/git_providers/git_provider.py
user: login: twdkeule
body: This code is also in `pr_code_suggestions.py` for all code providers.
created_at: 2025-05-09T09:29:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -131,8 +129,8 @@ def edit_comment(self, comment, body: str):
             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,
path: pr_agent/git_providers/azuredevops_provider.py
user: login: twdkeule
body: `PRCodeSuggestions.publish_persistent_comment_with_history()` requires comments with body (from `git_provider.get_issue_comments()`). But the azure provider returns a dict(thread_id, comment_id).

I noticed that I should fix it everywhere, not only in this function
created_at: 2025-05-09T09:31:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: qodo-free-for-open-source-projects[bot]
created_at: 2025-05-07T12:54:17Z
body: ## PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

<table>
<tr><td>⏱️&nbsp;<strong>Estimated effort to review</strong>: 2 🔵🔵⚪⚪⚪</td></tr>
<tr><td>🧪&nbsp;<strong>No relevant tests</strong></td></tr>
<tr><td>🔒&nbsp;<strong>No security concerns identified</strong></td></tr>
<tr><td>⚡&nbsp;<strong>Recommended focus areas for review</strong><br><br>

<details><summary><a href='https://github.com/qodo-ai/pr-agent/pull/1746/files#diff-1a90ea92bcfba3cf9f1dbc298d2e570566f4c439cb3650ee746cebdb02ca4a0bR630-R634'><strong>Error Handling</strong></a>

The get_latest_commit_url method doesn't include error handling. If the API call fails or returns empty results, it could cause runtime errors.
</summary>

```python
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
```

</details>

<details><summary><a href='https://github.com/qodo-ai/pr-agent/pull/1746/files#diff-b57ba775e741d6f80bc4f8154b71330c011dae0ac43f3d0197e785b3e6b7117bR310-R311'><strong>Undefined Variable</strong></a>

The variable 'new_suggestion_table' is referenced before it's defined in the code flow where history_header is not in comment.body.
</summary>

```python
pr_comment_updated += f"{latest_suggestion_header}\n{new_suggestion_table}\n\n___\n\n"
pr_comment_updated += f"{history_header}{prev_suggestion_table}\n"
```

</details>

</td></tr>
</table>

author_association: CONTRIBUTOR
user: login: qodo-free-for-open-source-projects[bot]
created_at: 2025-05-07T12:54:34Z
body: ## PR Code Suggestions ✨
<!-- 4593e40 -->

Latest suggestions up to 4593e40
<table><thead><tr><td><strong>Category</strong></td><td align=left><strong>Suggestion&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </strong></td><td align=center><strong>Impact</strong></td></tr><tbody><tr><td rowspan=1>Possible issue</td>
<td>



<details><summary>Fix potential AttributeError<!-- not_implemented --></summary>

___


**The function doesn't handle the case where thread_context attributes might be <br>None. If <code>thread_context.left_file_start</code> exists but <br><code>thread_context.left_file_start.line</code> is None, this would cause an AttributeError.**

[pr_agent/servers/azuredevops_server_webhook.py [54-73]](https://github.com/qodo-ai/pr-agent/pull/1746/files#diff-4525cb1c9c5ffe691dee1db5501c614ab50388fdf711220ee18db18cf64a7358R54-R73)

```diff
 def handle_line_comment(body: str, thread_id: int, provider: AzureDevopsProvider):
     if not '/ask' in body:
         return body
     thread_context = provider.get_thread_context(thread_id)
     if not thread_context:
         return body
     
     question = body.replace('/ask', '').strip()
     path = thread_context.file_path
-    if thread_context.left_file_end or thread_context.left_file_start:
+    if thread_context.left_file_end and 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:
+    elif thread_context.right_file_end and 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
```

- [ ] **Apply / Chat** <!-- /improve --apply_suggestion=0 -->

<details><summary>Suggestion importance[1-10]: 8</summary>

__

Why: The suggestion correctly identifies a potential AttributeError that could occur if `thread_context.left_file_start` exists but `thread_context.left_file_end` doesn't (or vice versa). Changing from `or` to `and` ensures both objects exist before accessing their properties, preventing runtime errors.


</details></details></td><td align=center>Medium

</td></tr><tr><td rowspan=1>Learned<br>best practice</td>
<td>



<details><summary>Add null check for credentials<!-- not_implemented --></summary>

___


**Add a null check for credentials before accessing its properties. If security is <br>set with auto_error=False, credentials could be None when no authentication is <br>provided.**

[pr_agent/servers/azuredevops_server_webhook.py [81-85]](https://github.com/qodo-ai/pr-agent/pull/1746/files#diff-4525cb1c9c5ffe691dee1db5501c614ab50388fdf711220ee18db18cf64a7358R81-R85)

```diff
-if WEBHOOK_USERNAME is None or WEBHOOK_PASSWORD is None:
+if WEBHOOK_USERNAME is None or WEBHOOK_PASSWORD is None or credentials is None:
     return
 
 is_user_ok = secrets.compare_digest(credentials.username, WEBHOOK_USERNAME)
 is_pass_ok = secrets.compare_digest(credentials.password, WEBHOOK_PASSWORD)
```

- [ ] **Apply / Chat** <!-- /improve --apply_suggestion=1 -->

<details><summary>Suggestion importance[1-10]: 6</summary>

__

Why: 
Relevant best practice - Add null safety checks before performing operations on potentially null or undefined values to prevent runtime errors

</details></details></td><td align=center>Low

</td></tr>
<tr><td align="center" colspan="2">

- [ ] Update <!-- /improve --more_suggestions=true -->

</td><td></td></tr></tbody></table>

- [ ]  **Author self-review**: I have reviewed the PR code suggestions, and addressed the relevant ones. <!-- fold suggestions self-review -->

___

#### Previous suggestions
<details><summary>Suggestions up to commit 35bf999</summary>
<br><table><thead><tr><td><strong>Category</strong></td><td align=left><strong>Suggestion&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </strong></td><td align=center><strong>Impact</strong></td></tr><tbody><tr><td rowspan=1>Possible issue</td>
<td>



<details><summary>Add missing return statement<!-- not_implemented --></summary>

___


**The method is missing a return statement to pass back the result from <br><code>publish_persistent_comment_full()</code>. This could cause issues when callers expect a <br>return value, such as a comment object reference.**

[pr_agent/git_providers/azuredevops_provider.py [392-397]](https://github.com/qodo-ai/pr-agent/pull/1746/files#diff-1a90ea92bcfba3cf9f1dbc298d2e570566f4c439cb3650ee746cebdb02ca4a0bR392-R397)

```diff
 def publish_persistent_comment(self, pr_comment: str,
                                initial_header: str,
                                update_header: bool = True,
                                name='review',
                                final_update_message=True):
-    self.publish_persistent_comment_full(pr_comment, initial_header, update_header, name, final_update_message)
+    return self.publish_persistent_comment_full(pr_comment, initial_header, update_header, name, final_update_message)
```

 <!-- /improve --apply_suggestion=0 -->

<details><summary>Suggestion importance[1-10]: 8</summary>

__

Why: The suggestion correctly identifies that the `publish_persistent_comment` method is missing a return statement to pass back the result from `publish_persistent_comment_full()`. This is important as callers may expect a return value (like a comment object reference), and without it, `None` would be returned implicitly.


</details></details></td><td align=center>Medium

</td></tr><tr><td rowspan=1>Learned<br>best practice</td>
<td>



<details><summary>
Add null check for commits
</summary>

___


**Add null safety checks to handle the case where no commits are found in the PR. <br>This prevents potential IndexError if the commits list is empty.**

[pr_agent/git_providers/azuredevops_provider.py [630-634]](https://github.com/qodo-ai/pr-agent/pull/1746/files#diff-1a90ea92bcfba3cf9f1dbc298d2e570566f4c439cb3650ee746cebdb02ca4a0bR630-R634)

```diff
 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)
+    if not commits:
+        get_logger().warning(f"No commits found for PR #{self.pr_num}")
+        return self.pr_url
     last = commits[0]
     url = self.azure_devops_client.normalized_url + "/" + self.workspace_slug + "/_git/" + self.repo_slug + "/commit/" + last.commit_id
     return url
```

`[Suggestion processed]`

<details><summary>Suggestion importance[1-10]: 6</summary>

__

Why: 
Relevant best practice - Add null safety checks before performing operations on potentially null or undefined values to prevent runtime errors, especially when handling optional parameters, configuration values, or API responses.

</details></details></td><td align=center>Low

</td></tr>
<tr><td align="center" colspan="2">

 <!-- /improve --more_suggestions=true -->

</td><td></td></tr></tbody></table>

</details>
author_association: CONTRIBUTOR
user: login: mrT23
created_at: 2025-05-08T16:14:11Z
body: Thanks for the PR @twdkeule 

i gave some notes to correct.
Also, please thoroughly review the PR and make sure the changes fully support all previous capabilities.

My azure account is dead (Microsoft 👎 ), so I rely more on you to be sure the PR is correct.
author_association: CONTRIBUTOR
user: login: twdkeule
created_at: 2025-05-09T11:08:29Z
body: I expanded the scope of the pull request to all the Azure-related fixes that I implemented. I can split it into separate pull requests, if you prefer.
author_association: CONTRIBUTOR
user: login: twdkeule
created_at: 2025-05-09T11:50:13Z
body: /improve
author_association: CONTRIBUTOR
user: login: mrT23
created_at: 2025-05-10T05:53:32Z
body: @twdkeule 
i cannot approve the PR in its current state.

It should include **only** changes to Azure DevOps provider, _in a minimal way_, and you should be 100% sure, without any doubt, that it has zero impact on any other providers. This way can it be merged


author_association: CONTRIBUTOR
user: login: twdkeule
created_at: 2025-05-12T07:06:43Z
body: I removed all commits that are not specific to AzureDevops.
author_association: CONTRIBUTOR
user: login: mrT23
created_at: 2025-05-12T12:55:47Z
body: thanks @twdkeule 
author_association: CONTRIBUTOR