From b5a693dacdc024204362ad900bff17085be0003e Mon Sep 17 00:00:00 2001 From: Ismael Martinez Ramos Date: Fri, 26 Jun 2026 09:01:04 +0100 Subject: [PATCH 1/4] fix: don't overwrite PR title in /describe when generate_ai_title is false publish_description re-saved the PR/MR title on every describe run even when the title was not AI-generated. Because the title is read at job start and written back unconditionally, a manual title edit made while describe is running gets reverted. This affected every provider, since they all set the title alongside the description. Pass None as the title from the describe flow when generate_ai_title is false, and update each provider to leave the existing title unchanged in that case (updating only the description). Refs: #2474 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../git_providers/azuredevops_provider.py | 3 ++- pr_agent/git_providers/bitbucket_provider.py | 9 ++++---- .../bitbucket_server_provider.py | 5 +++- pr_agent/git_providers/codecommit_client.py | 4 +++- pr_agent/git_providers/gerrit_provider.py | 3 ++- pr_agent/git_providers/git_provider.py | 3 +++ pr_agent/git_providers/gitea_provider.py | 9 +++++--- pr_agent/git_providers/github_provider.py | 6 ++++- pr_agent/git_providers/gitlab_provider.py | 3 ++- pr_agent/git_providers/local_git_provider.py | 3 ++- pr_agent/tools/pr_description.py | 8 ++++++- tests/unittest/test_gitlab_provider.py | 23 +++++++++++++++++++ 12 files changed, 63 insertions(+), 16 deletions(-) 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..d9059fe825 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -516,7 +516,10 @@ def publish_description(self, pr_title: str, description: str): payload = { "version": self.pr.version, "description": description, - "title": pr_title, + # The update replaces the PR, so omitted fields get wiped. When + # pr_title is None (title not AI-generated) keep the existing title + # rather than blanking it. + "title": pr_title if pr_title is not None else self.pr.title, "reviewers": self.pr.reviewers # needs to be sent otherwise gets wiped } try: diff --git a/pr_agent/git_providers/codecommit_client.py b/pr_agent/git_providers/codecommit_client.py index 5f18c90dac..162a92b8c3 100644 --- a/pr_agent/git_providers/codecommit_client.py +++ b/pr_agent/git_providers/codecommit_client.py @@ -200,7 +200,9 @@ 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) + # pr_title is None when the title was not AI-generated: leave it unchanged. + 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..e5a1cf3c2f 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -640,13 +640,16 @@ 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, ) + # Leave the existing title unchanged when it was not AI-generated. + 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..b70d1f1785 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -358,7 +358,11 @@ 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: + # Leave the existing title unchanged; update only the body. + 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..b0b8672b8d 100644 --- a/pr_agent/git_providers/local_git_provider.py +++ b/pr_agent/git_providers/local_git_provider.py @@ -112,7 +112,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) + content = pr_body if pr_title is None else pr_title + '\n' + pr_body + file.write(content) 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..6bae070907 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -180,7 +180,13 @@ async def run(self): else: self.git_provider.publish_comment(full_markdown_description) else: - self.git_provider.publish_description(pr_title.strip(), pr_body) + # When the title was not AI-generated, pass None so the + # provider leaves the existing title untouched. Re-writing the + # original title on every run is normally a no-op, but it races + # with a manual title edit made while describe is running and + # reverts it. See issue #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() From 27903ad88dd3bc11f89ca8d02846d39ce5e19e49 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Ramos Date: Fri, 26 Jun 2026 16:13:59 +0100 Subject: [PATCH 2/4] fix(bitbucket-server): re-fetch PR before publish to avoid reverting a stale title When pr_title is None the cached self.pr.title could be stale, so a title edited during the describe run could still be reverted. Re-fetch the PR first so the latest title, version and reviewers are used in the replace-style update. Refs: #2474 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../git_providers/bitbucket_server_provider.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index d9059fe825..de25e3baa3 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -513,14 +513,19 @@ 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: + # The update replaces the PR, so an omitted title would be wiped. + # Re-fetch the latest PR so a title edited during the describe run is + # preserved instead of reverted to a stale cached value. This also + # refreshes version/reviewers, which the replace-style update needs. + pr = self._get_pr() + self.pr = pr payload = { - "version": self.pr.version, + "version": pr.version, "description": description, - # The update replaces the PR, so omitted fields get wiped. When - # pr_title is None (title not AI-generated) keep the existing title - # rather than blanking it. - "title": pr_title if pr_title is not None else self.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) From d45ddc84e66872e527a5494f0fd901d4f7dfe1e8 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Ramos Date: Fri, 26 Jun 2026 16:18:32 +0100 Subject: [PATCH 3/4] fix(local): keep the title line in describe output when title is not AI-generated When pr_title is None the local provider wrote only the body, dropping the title line from description.md. Use the provider's existing title (get_pr_title) so the title line is preserved, matching the leave-title-unchanged contract. Refs: #2474 Co-Authored-By: Claude Opus 4.8 (1M context) --- pr_agent/git_providers/local_git_provider.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pr_agent/git_providers/local_git_provider.py b/pr_agent/git_providers/local_git_provider.py index b0b8672b8d..82045fe0a3 100644 --- a/pr_agent/git_providers/local_git_provider.py +++ b/pr_agent/git_providers/local_git_provider.py @@ -111,9 +111,10 @@ 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 - content = pr_body if pr_title is None else pr_title + '\n' + pr_body - file.write(content) + # When pr_title is None (title not AI-generated) keep the existing + # title rather than dropping the title line from the output. + 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: From aaf00bb45dc598dd9dca1edb4b731ff88bb9de96 Mon Sep 17 00:00:00 2001 From: Ismael Martinez Ramos Date: Fri, 26 Jun 2026 16:25:55 +0100 Subject: [PATCH 4/4] chore: trim redundant inline comments in title-preservation change Keep only the non-obvious rationale (shared describe path, the abstract publish_description contract, and the Bitbucket Server re-fetch); drop the per-provider comments that just restate the guard. Refs: #2474 Co-Authored-By: Claude Opus 4.8 (1M context) --- pr_agent/git_providers/bitbucket_server_provider.py | 6 ++---- pr_agent/git_providers/codecommit_client.py | 1 - pr_agent/git_providers/gitea_provider.py | 1 - pr_agent/git_providers/github_provider.py | 1 - pr_agent/git_providers/local_git_provider.py | 2 -- pr_agent/tools/pr_description.py | 7 ++----- 6 files changed, 4 insertions(+), 14 deletions(-) diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index de25e3baa3..20006408e2 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -515,10 +515,8 @@ def get_commit_messages(self): def publish_description(self, pr_title: str, description: str): pr = self.pr if pr_title is None: - # The update replaces the PR, so an omitted title would be wiped. - # Re-fetch the latest PR so a title edited during the describe run is - # preserved instead of reverted to a stale cached value. This also - # refreshes version/reviewers, which the replace-style update needs. + # 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 = { diff --git a/pr_agent/git_providers/codecommit_client.py b/pr_agent/git_providers/codecommit_client.py index 162a92b8c3..ef42efd67f 100644 --- a/pr_agent/git_providers/codecommit_client.py +++ b/pr_agent/git_providers/codecommit_client.py @@ -200,7 +200,6 @@ def publish_description(self, pr_number: int, pr_title: str, pr_body: str): self._connect_boto_client() try: - # pr_title is None when the title was not AI-generated: leave it unchanged. 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) diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index e5a1cf3c2f..6cef7acb07 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -646,7 +646,6 @@ def publish_description(self, pr_title: str, pr_body: str) -> None: pr_number=self.pr_number if self.enabled_pr else self.issue_number, body=pr_body, ) - # Leave the existing title unchanged when it was not AI-generated. if pr_title is not None: edit_kwargs["title"] = pr_title response = self.repo_api.edit_pull_request(**edit_kwargs) diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index b70d1f1785..628caf7a19 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -359,7 +359,6 @@ def _get_diff_files(self) -> list[FilePatchInfo]: def publish_description(self, pr_title: str, pr_body: str): if pr_title is None: - # Leave the existing title unchanged; update only the body. self.pr.edit(body=pr_body) else: self.pr.edit(title=pr_title, body=pr_body) diff --git a/pr_agent/git_providers/local_git_provider.py b/pr_agent/git_providers/local_git_provider.py index 82045fe0a3..3c062205e7 100644 --- a/pr_agent/git_providers/local_git_provider.py +++ b/pr_agent/git_providers/local_git_provider.py @@ -111,8 +111,6 @@ def get_files(self) -> List[str]: def publish_description(self, pr_title: str, pr_body: str): with open(self.description_path, "w") as file: - # When pr_title is None (title not AI-generated) keep the existing - # title rather than dropping the title line from the output. title = self.get_pr_title() if pr_title is None else pr_title file.write(title + '\n' + pr_body) diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index 6bae070907..4af1d44d45 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -180,11 +180,8 @@ async def run(self): else: self.git_provider.publish_comment(full_markdown_description) else: - # When the title was not AI-generated, pass None so the - # provider leaves the existing title untouched. Re-writing the - # original title on every run is normally a no-op, but it races - # with a manual title edit made while describe is running and - # reverts it. See issue #2474. + # 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)