diff --git a/pr_agent/git_providers/azuredevops_provider.py b/pr_agent/git_providers/azuredevops_provider.py index 24049c047a..e1e8ccc7e1 100644 --- a/pr_agent/git_providers/azuredevops_provider.py +++ b/pr_agent/git_providers/azuredevops_provider.py @@ -396,7 +396,8 @@ def publish_description(self, pr_title: str, pr_body: str): get_logger().warning("PR description was truncated due to length limit") try: updated_pr = GitPullRequest() - updated_pr.title = pr_title + if pr_title is not None: + updated_pr.title = pr_title updated_pr.description = pr_body self.azure_devops_client.update_pull_request( project=self.workspace_slug, diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index 6944e41fa2..e772cada34 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -601,11 +601,10 @@ def get_commit_messages(self): # bitbucket does not support labels def publish_description(self, pr_title: str, description: str): - payload = json.dumps({ - "description": description, - "title": pr_title - - }) + payload_dict = {"description": description} + if pr_title is not None: + payload_dict["title"] = pr_title + payload = json.dumps(payload_dict) response = requests.request("PUT", self.bitbucket_pull_request_api_url, headers=self.headers, data=payload) try: diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index c929221af9..20006408e2 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -513,11 +513,17 @@ def get_commit_messages(self): # bitbucket does not support labels def publish_description(self, pr_title: str, description: str): + pr = self.pr + if pr_title is None: + # Replace-style update: an omitted/stale title would be lost, so + # re-fetch to preserve a title edited during the describe run. + pr = self._get_pr() + self.pr = pr payload = { - "version": self.pr.version, + "version": pr.version, "description": description, - "title": pr_title, - "reviewers": self.pr.reviewers # needs to be sent otherwise gets wiped + "title": pr_title if pr_title is not None else pr.title, + "reviewers": pr.reviewers # needs to be sent otherwise gets wiped } try: self.bitbucket_client.update_pull_request(self.workspace_slug, self.repo_slug, str(self.pr_num), payload) diff --git a/pr_agent/git_providers/codecommit_client.py b/pr_agent/git_providers/codecommit_client.py index 5f18c90dac..ef42efd67f 100644 --- a/pr_agent/git_providers/codecommit_client.py +++ b/pr_agent/git_providers/codecommit_client.py @@ -200,7 +200,8 @@ def publish_description(self, pr_number: int, pr_title: str, pr_body: str): self._connect_boto_client() try: - self.boto_client.update_pull_request_title(pullRequestId=str(pr_number), title=pr_title) + if pr_title is not None: + self.boto_client.update_pull_request_title(pullRequestId=str(pr_number), title=pr_title) self.boto_client.update_pull_request_description(pullRequestId=str(pr_number), description=pr_body) except botocore.exceptions.ClientError as e: if e.response["Error"]["Code"] == 'PullRequestDoesNotExistException': diff --git a/pr_agent/git_providers/gerrit_provider.py b/pr_agent/git_providers/gerrit_provider.py index ced150c915..edef5cb0dd 100644 --- a/pr_agent/git_providers/gerrit_provider.py +++ b/pr_agent/git_providers/gerrit_provider.py @@ -368,7 +368,8 @@ def publish_comment(self, pr_comment: str, is_temporary: bool = False): def publish_description(self, pr_title: str, pr_body: str): msg = adopt_to_gerrit_message(pr_body) - add_comment(self.parsed_url, self.refspec, pr_title + '\n' + msg) + text = msg if pr_title is None else pr_title + '\n' + msg + add_comment(self.parsed_url, self.refspec, text) def publish_inline_comments(self, comments: list[dict]): raise NotImplementedError( diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index 631e189c04..bec8062143 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -167,6 +167,9 @@ def get_incremental_commits(self, is_incremental): @abstractmethod def publish_description(self, pr_title: str, pr_body: str): + # pr_title may be None, which means "leave the existing title unchanged" + # and update only the description. Implementations must not write the + # title in that case. pass @abstractmethod diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index ac84f7938f..6cef7acb07 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -640,13 +640,15 @@ def get_git_repo_url(self, issues_or_pr_url: str) -> str: def publish_description(self, pr_title: str, pr_body: str) -> None: """Publish PR description""" - response = self.repo_api.edit_pull_request( + edit_kwargs = dict( owner=self.owner, repo=self.repo, pr_number=self.pr_number if self.enabled_pr else self.issue_number, - title=pr_title, - body=pr_body + body=pr_body, ) + if pr_title is not None: + edit_kwargs["title"] = pr_title + response = self.repo_api.edit_pull_request(**edit_kwargs) if not response: self.logger.error("Failed to publish PR description") diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 8bd0aa679e..628caf7a19 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -358,7 +358,10 @@ def _get_diff_files(self) -> list[FilePatchInfo]: raise RateLimitExceeded("Rate limit exceeded for GitHub API.") from e def publish_description(self, pr_title: str, pr_body: str): - self.pr.edit(title=pr_title, body=pr_body) + if pr_title is None: + self.pr.edit(body=pr_body) + else: + self.pr.edit(title=pr_title, body=pr_body) def get_latest_commit_url(self) -> str: return self.last_commit_id.html_url diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index 6d25ce607f..2128e2670b 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -485,7 +485,8 @@ def get_files(self) -> list: def publish_description(self, pr_title: str, pr_body: str): try: - self.mr.title = pr_title + if pr_title is not None: + self.mr.title = pr_title self.mr.description = pr_body self.mr.save() except Exception as e: diff --git a/pr_agent/git_providers/local_git_provider.py b/pr_agent/git_providers/local_git_provider.py index 420289761c..3c062205e7 100644 --- a/pr_agent/git_providers/local_git_provider.py +++ b/pr_agent/git_providers/local_git_provider.py @@ -111,8 +111,8 @@ def get_files(self) -> List[str]: def publish_description(self, pr_title: str, pr_body: str): with open(self.description_path, "w") as file: - # Write the string to the file - file.write(pr_title + '\n' + pr_body) + title = self.get_pr_title() if pr_title is None else pr_title + file.write(title + '\n' + pr_body) def publish_comment(self, pr_comment: str, is_temporary: bool = False): with open(self.review_path, "w") as file: diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index 0190b13954..4af1d44d45 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -180,7 +180,10 @@ async def run(self): else: self.git_provider.publish_comment(full_markdown_description) else: - self.git_provider.publish_description(pr_title.strip(), pr_body) + # Pass None when the title is not AI-generated so the provider + # leaves it untouched, avoiding reverting a manual edit (#2474). + title_to_publish = pr_title.strip() if get_settings().pr_description.generate_ai_title else None + self.git_provider.publish_description(title_to_publish, pr_body) # publish final update message if (get_settings().pr_description.final_update_message and not get_settings().config.get('is_auto_command', False)): diff --git a/tests/unittest/test_gitlab_provider.py b/tests/unittest/test_gitlab_provider.py index c3864264d8..3bd3cdcf06 100644 --- a/tests/unittest/test_gitlab_provider.py +++ b/tests/unittest/test_gitlab_provider.py @@ -232,3 +232,26 @@ def test_get_line_link_handles_file_and_line_ranges(self, gitlab_provider): assert gitlab_provider.get_line_link("src/app.py", 10, 12) == ( "https://gitlab.com/group/repo/-/blob/feature/cache/src/app.py?ref_type=heads#L10-12" ) + + def test_publish_description_with_none_title_leaves_title_unchanged(self, gitlab_provider): + gitlab_provider.mr = MagicMock() + gitlab_provider.mr.title = "Original title" + gitlab_provider.id_mr = 1 + + gitlab_provider.publish_description(None, "Updated description") + + # Title must not be overwritten when pr_title is None; only the body updates. + assert gitlab_provider.mr.title == "Original title" + assert gitlab_provider.mr.description == "Updated description" + gitlab_provider.mr.save.assert_called_once() + + def test_publish_description_with_title_updates_both(self, gitlab_provider): + gitlab_provider.mr = MagicMock() + gitlab_provider.mr.title = "Original title" + gitlab_provider.id_mr = 1 + + gitlab_provider.publish_description("AI title", "Updated description") + + assert gitlab_provider.mr.title == "AI title" + assert gitlab_provider.mr.description == "Updated description" + gitlab_provider.mr.save.assert_called_once()