Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions pr_agent/git_providers/azuredevops_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,21 @@ def get_repo_settings(self):
get_logger().error(f"Failed to get repo settings, error: {e}")
return ""

def get_repo_file(self, file_path: str) -> str:
try:
contents = self.azure_devops_client.get_item_content(
repository_id=self.repo_slug,
project=self.workspace_slug,
download=False,
include_content_metadata=False,
include_content=True,
path=file_path,
)
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
content = list(contents)[0]
return content.decode("utf-8") if isinstance(content, bytes) else content
Comment on lines +177 to +195
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Azure reads merged commit 🐞 Bug ≡ Correctness

AzureDevOpsProvider.get_repo_file() reads files at self.pr.last_merge_commit (merge result) instead
of the PR source/head commit, so metadata may reflect the merged preview rather than the branch
under review. This is inconsistent with other providers in this PR that explicitly read from the
PR/MR source branch or head SHA.
Agent Prompt
### Issue description
`AzureDevopsProvider.get_repo_file()` fetches repository files using `self.pr.last_merge_commit`, which corresponds to the merge-result/merge-preview commit, not the PR source/head. Repository metadata should reflect the branch under review.

### Issue Context
Other providers in this PR intentionally read from the PR/MR source branch or head SHA.

### Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[177-193]

### Fix approach
Fetch the file using the PR source/head commit instead of `last_merge_commit`.
- Prefer Azure PR fields that represent the source commit (e.g., `last_merge_source_commit` if available on the PR model).
- If the model doesn’t expose it directly, derive the correct commit via the PR’s source ref and latest commit and use that commit id in `GitVersionDescriptor`.
- Ensure the ref used here matches what `get_pr_branch()` identifies as the PR source branch.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

except Exception:
return ""
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

def get_files(self):
files = []
for i in self.azure_devops_client.get_pull_request_commits(
Expand Down
12 changes: 12 additions & 0 deletions pr_agent/git_providers/bitbucket_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ def get_repo_settings(self):
except Exception:
return ""

def get_repo_file(self, file_path: str) -> str:
try:
# Read from the PR's source branch so metadata files reflect the branch under review
url = (f"https://api.bitbucket.org/2.0/repositories/{self.workspace_slug}/{self.repo_slug}/src/"
f"{self.pr.source_branch}/{file_path}")
response = requests.request("GET", url, headers=self.headers)
if response.status_code == 404:
return ""
return response.text
Comment on lines +92 to +101
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Bitbucket file fetch can hang 🐞 Bug ☼ Reliability

BitbucketProvider.get_repo_file performs an HTTP GET without a timeout, so enabling
add_repo_metadata can block apply_repo_settings indefinitely on stalled connections. Because
apply_repo_settings is called before request handling logic, this can stall the whole PR-agent flow.
Agent Prompt
### Issue description
`BitbucketProvider.get_repo_file()` makes an outbound HTTP request without a timeout. In network stalls, this can hang indefinitely and block `apply_repo_settings()`.

### Issue Context
`apply_repo_settings()` runs before PR processing and calls `get_repo_file()` for each metadata filename, so this path is on the critical startup path when `add_repo_metadata=true`.

### Fix Focus Areas
- Add an explicit `timeout=` to the Bitbucket `requests.request("GET", ...)` call.
- Prefer a configurable timeout (or a sensible constant) and handle timeout exceptions similarly to other request errors.
- pr_agent/git_providers/bitbucket_provider.py[92-108]
- pr_agent/git_providers/utils.py[174-186]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

except Exception:
return ""

def get_git_repo_url(self, pr_url: str=None) -> str: #bitbucket does not support issue url, so ignore param
try:
parsed_url = urlparse(self.pr_url)
Expand Down
10 changes: 10 additions & 0 deletions pr_agent/git_providers/bitbucket_server_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ def get_repo_settings(self):
get_logger().error(f"Failed to load .pr_agent.toml file, error: {e}")
return ""

def get_repo_file(self, file_path: str) -> str:
try:
content = self.bitbucket_client.get_content_of_file(self.workspace_slug, self.repo_slug, file_path)
return content.decode("utf-8") if isinstance(content, bytes) else content
except Exception as e:
if isinstance(e, HTTPError) and e.response.status_code == 404:
return ""
get_logger().error(f"Failed to load {file_path} file, error: {e}")
return ""
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

def get_pr_id(self):
return self.pr_num

Expand Down
7 changes: 7 additions & 0 deletions pr_agent/git_providers/codecommit_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,13 @@ def get_repo_settings(self):
settings_filename = ".pr_agent.toml"
return self.codecommit_client.get_file(self.repo_name, settings_filename, self.pr.source_commit, optional=True)

def get_repo_file(self, file_path: str) -> str:
try:
content = self.codecommit_client.get_file(self.repo_name, file_path, self.pr.source_commit, optional=True)
return content.decode("utf-8") if isinstance(content, bytes) else (content or "")
except Exception:
return ""

def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]:
get_logger().info("CodeCommit provider does not support eyes reaction yet")
return True
Expand Down
7 changes: 7 additions & 0 deletions pr_agent/git_providers/gerrit_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,13 @@ def get_repo_settings(self):
except OSError:
return b""

def get_repo_file(self, file_path: str) -> str:
try:
with open(self.repo_path / file_path, 'r') as f:
return f.read()
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
except OSError:
return ""
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

def get_diff_files(self) -> list[FilePatchInfo]:
diffs = self.repo.head.commit.diff(
self.repo.head.commit.parents[0], # previous commit
Expand Down
13 changes: 13 additions & 0 deletions pr_agent/git_providers/git_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,19 @@ def _is_generated_by_pr_agent(self, description_lowercase: str) -> bool:
def get_repo_settings(self):
pass

def get_repo_file(self, file_path: str) -> str:
"""
Read a text file from the PR's head branch root directory.

Args:
file_path: Relative path to the file from the repository root.

Returns:
The file content as a UTF-8 string, or "" if the file does not exist
or cannot be read.
"""
return ""

def get_workspace_name(self):
return ""

Expand Down
13 changes: 13 additions & 0 deletions pr_agent/git_providers/gitea_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,19 @@ def get_repo_settings(self) -> str:

return response

def get_repo_file(self, file_path: str) -> str:
"""Get a file from the repository root"""
try:
response = self.repo_api.get_file_content(
owner=self.owner,
repo=self.repo,
commit_sha=self.sha,
filepath=file_path
)
return response if response else ""
except Exception:
return ""

def get_user_id(self) -> str:
"""Get the ID of the authenticated user"""
return f"{self.pr.user.id}" if self.pr else ""
Expand Down
8 changes: 8 additions & 0 deletions pr_agent/git_providers/github_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,14 @@ def get_repo_settings(self):
except Exception:
return ""

def get_repo_file(self, file_path: str) -> str:
try:
# Read from the PR's head branch so metadata files reflect the branch under review
contents = self.repo_obj.get_contents(file_path, ref=self.pr.head.sha).decoded_content
return contents.decode("utf-8") if isinstance(contents, bytes) else contents
except Exception:
return ""
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

def get_workspace_name(self):
return self.repo.split('/')[0]

Expand Down
9 changes: 9 additions & 0 deletions pr_agent/git_providers/gitlab_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,15 @@ def get_repo_settings(self):
except Exception:
return ""

def get_repo_file(self, file_path: str) -> str:
try:
# Read from the MR's source branch so metadata files reflect the branch under review
contents = self.gl.projects.get(self.id_project).files.get(
file_path=file_path, ref=self.mr.source_branch).decode()
return contents.decode("utf-8") if isinstance(contents, bytes) else contents
except Exception:
return ""
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

def get_workspace_name(self):
return self.id_project.split('/')[0]

Expand Down
43 changes: 43 additions & 0 deletions pr_agent/git_providers/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,49 @@ def apply_repo_settings(pr_url):
except Exception as e:
get_logger().error(f"Failed to remove temporary settings file {repo_settings_file}", e)

# Repository metadata: fetch well-known instruction files (AGENTS.md, QODO.md, CLAUDE.md, …)
# from the PR's head branch root and inject their contents into every tool's extra_instructions.
# See: https://qodo-merge-docs.qodo.ai/usage-guide/additional_configurations/#bringing-additional-repository-metadata-to-pr-agent
if get_settings().config.get("add_repo_metadata", False):
try:
metadata_files = get_settings().config.get("add_repo_metadata_file_list",
["AGENTS.md", "QODO.md", "CLAUDE.md"])

# Collect contents of all metadata files that exist in the repo
metadata_content_parts = []
for file_name in metadata_files:
Comment on lines +142 to +179
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. add_repo_metadata_file_list not validated 📘 Rule violation ≡ Correctness

add_repo_metadata_file_list is used directly without validating/normalizing its type/contents,
which can lead to incorrect behavior (e.g., iterating over characters if a string is provided) or
runtime errors. The checklist requires normalizing and validating user-provided settings before
using them in logic.
Agent Prompt
## Issue description
`config.add_repo_metadata_file_list` is consumed without validation. If the setting is mis-typed (e.g., a string, `None`, or a list containing non-strings), metadata loading can behave incorrectly or crash.

## Issue Context
This setting is user-provided via TOML/env/config overrides and must be normalized to a predictable shape before iterating.

## Fix Focus Areas
- pr_agent/git_providers/utils.py[142-186]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

try:
content = git_provider.get_repo_file(file_name)
if content and content.strip():
metadata_content_parts.append(content.strip())
get_logger().info(f"Loaded repository metadata file: {file_name}")
except Exception as e:
get_logger().debug(f"Failed to load metadata file {file_name}: {e}")

# Append combined metadata to extra_instructions for every tool that supports it.
if metadata_content_parts:
combined_metadata = "\n\n".join(metadata_content_parts)
tool_sections = [
"pr_reviewer",
"pr_description",
"pr_code_suggestions",
"pr_add_docs",
"pr_update_changelog",
"pr_test",
"pr_improve_component",
]
for section in tool_sections:
section_obj = get_settings().get(section, None)
if section_obj is not None and hasattr(section_obj, 'extra_instructions'):
existing = section_obj.extra_instructions or ""
if existing:
new_value = f"{existing}\n\n{combined_metadata}"
else:
new_value = combined_metadata
get_settings().set(f"{section}.extra_instructions", new_value)
except Exception as e:
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
get_logger().debug(f"Failed to load repository metadata files: {e}")
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

# enable switching models with a short definition
if get_settings().config.model.lower() == 'claude-3-5-sonnet':
set_claude_model()
Expand Down
2 changes: 2 additions & 0 deletions pr_agent/settings/configuration.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ log_level="DEBUG"
use_wiki_settings_file=true
use_repo_settings_file=true
use_global_settings_file=true
add_repo_metadata=false # when true, searches the PR's head branch root for metadata files (by default: AGENTS.md, QODO.md, CLAUDE.md) and appends their content as extra instructions to all tools
add_repo_metadata_file_list=["AGENTS.md", "QODO.md", "CLAUDE.md"] # override the default list of metadata filenames to search for when add_repo_metadata is true
disable_auto_feedback = false
ai_timeout=120 # 2 minutes
skip_keys = []
Expand Down
146 changes: 146 additions & 0 deletions tests/unittest/test_repo_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""
Tests for the add_repo_metadata feature in apply_repo_settings().

When config.add_repo_metadata is true, metadata files (AGENTS.md, QODO.md,
CLAUDE.md by default) are fetched from the PR's head branch and their contents
are appended to extra_instructions for every tool that supports it.
"""

import pytest

from pr_agent.config_loader import get_settings
from pr_agent.git_providers.utils import apply_repo_settings


class FakeGitProvider:
"""Minimal git provider stub for testing repo metadata loading."""

def __init__(self, repo_files=None):
"""
Args:
repo_files: dict mapping file names to their content strings.
Files not in the dict will return "" (not found).
"""
self._repo_files = repo_files or {}

def get_repo_settings(self):
return ""

def get_repo_file(self, file_path: str) -> str:
return self._repo_files.get(file_path, "")


@pytest.fixture(autouse=True)
def _reset_extra_instructions():
"""Reset extra_instructions for all tool sections before each test."""
tool_sections = [
"pr_reviewer", "pr_description", "pr_code_suggestions",
"pr_add_docs", "pr_update_changelog", "pr_test", "pr_improve_component",
]
original_values = {}
for section in tool_sections:
section_obj = get_settings().get(section, None)
if section_obj is not None:
original_values[section] = getattr(section_obj, 'extra_instructions', "")

yield

for section, value in original_values.items():
get_settings().set(f"{section}.extra_instructions", value)

Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

class TestRepoMetadata:
def test_metadata_disabled_by_default(self, monkeypatch):
"""When add_repo_metadata is false, no metadata files are loaded."""
provider = FakeGitProvider(repo_files={"AGENTS.md": "should not appear"})
monkeypatch.setattr(
"pr_agent.git_providers.utils.get_git_provider_with_context",
lambda pr_url: provider,
)
get_settings().set("config.add_repo_metadata", False)

apply_repo_settings("https://example.com/pr/1")

assert "should not appear" not in (get_settings().pr_reviewer.extra_instructions or "")

def test_metadata_appended_to_extra_instructions(self, monkeypatch):
"""When enabled, metadata file contents are appended to extra_instructions."""
provider = FakeGitProvider(repo_files={"AGENTS.md": "Review with care"})
monkeypatch.setattr(
"pr_agent.git_providers.utils.get_git_provider_with_context",
lambda pr_url: provider,
)
get_settings().set("config.add_repo_metadata", True)
get_settings().set("config.add_repo_metadata_file_list", ["AGENTS.md"])

apply_repo_settings("https://example.com/pr/1")

assert "Review with care" in get_settings().pr_reviewer.extra_instructions
assert "Review with care" in get_settings().pr_code_suggestions.extra_instructions

def test_multiple_metadata_files_combined(self, monkeypatch):
"""Contents of multiple metadata files are joined together."""
provider = FakeGitProvider(repo_files={
"AGENTS.md": "Agent instructions",
"CLAUDE.md": "Claude instructions",
})
monkeypatch.setattr(
"pr_agent.git_providers.utils.get_git_provider_with_context",
lambda pr_url: provider,
)
get_settings().set("config.add_repo_metadata", True)
get_settings().set("config.add_repo_metadata_file_list", ["AGENTS.md", "CLAUDE.md"])

apply_repo_settings("https://example.com/pr/1")

instructions = get_settings().pr_reviewer.extra_instructions
assert "Agent instructions" in instructions
assert "Claude instructions" in instructions

def test_missing_metadata_files_skipped(self, monkeypatch):
"""Files that don't exist in the repo are silently skipped."""
provider = FakeGitProvider(repo_files={"AGENTS.md": "Found this one"})
monkeypatch.setattr(
"pr_agent.git_providers.utils.get_git_provider_with_context",
lambda pr_url: provider,
)
get_settings().set("config.add_repo_metadata", True)
get_settings().set("config.add_repo_metadata_file_list",
["AGENTS.md", "NONEXISTENT.md"])

apply_repo_settings("https://example.com/pr/1")

instructions = get_settings().pr_reviewer.extra_instructions
assert "Found this one" in instructions
assert "NONEXISTENT" not in instructions

def test_metadata_appended_to_existing_extra_instructions(self, monkeypatch):
"""Metadata is appended to (not replacing) any pre-existing extra_instructions."""
provider = FakeGitProvider(repo_files={"AGENTS.md": "From agents file"})
monkeypatch.setattr(
"pr_agent.git_providers.utils.get_git_provider_with_context",
lambda pr_url: provider,
)
get_settings().set("config.add_repo_metadata", True)
get_settings().set("config.add_repo_metadata_file_list", ["AGENTS.md"])
get_settings().set("pr_reviewer.extra_instructions", "Existing instructions")

apply_repo_settings("https://example.com/pr/1")

instructions = get_settings().pr_reviewer.extra_instructions
assert "Existing instructions" in instructions
assert "From agents file" in instructions

def test_custom_file_list(self, monkeypatch):
"""Users can specify a custom list of metadata files to search for."""
provider = FakeGitProvider(repo_files={"CUSTOM.md": "Custom content"})
monkeypatch.setattr(
"pr_agent.git_providers.utils.get_git_provider_with_context",
lambda pr_url: provider,
)
get_settings().set("config.add_repo_metadata", True)
get_settings().set("config.add_repo_metadata_file_list", ["CUSTOM.md"])

apply_repo_settings("https://example.com/pr/1")

assert "Custom content" in get_settings().pr_reviewer.extra_instructions