Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 13 additions & 1 deletion docs/docs/tools/improve.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,22 @@ Use triple quotes to write multi-line instructions. Use bullet points or numbers

### Best practices

`Platforms supported: GitHub, GitLab, Bitbucket`
`Platforms supported: GitHub, GitLab, Bitbucket, Azure DevOps`

PR-Agent supports both simple and hierarchical best practices configurations to provide guidance to the AI model for generating relevant code suggestions.

!!! info "Open-source `pr-agent`"
The OSS `pr-agent` package automatically loads `best_practices.md` from the repository's default branch on every `improve` run, truncates it to `[best_practices].max_lines_allowed` (default 800), and feeds it to the model as a labeled block in the `improve` prompt. The OSS `review` tool does not consume this file.

Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
To opt out, add to your `.pr_agent.toml`:

```toml
[best_practices]
enable_repo_best_practices_md = false
# Or override the default file path:
# repo_best_practices_md_path = "docs/best_practices.md"
```

???- tip "Writing effective best practices files"

The following guidelines apply to all best practices files:
Expand Down
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_pr_agent_repo_custom_file(self, file_path: str) -> bytes:
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,
)
chunks = [c if isinstance(c, (bytes, bytearray)) else str(c).encode("utf-8") for c in contents]
return b"".join(chunks)
Comment on lines +187 to +188
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

2. Azure chunks comprehension too long 📘 Rule violation ⚙ Maintainability

The added list comprehension for chunks is on a single line that exceeds the repository’s
120-character limit, risking Ruff (E501) failures and reducing readability.
Agent Prompt
## Issue description
A newly-added list comprehension exceeds Ruff's configured `line-length = 120`.

## Issue Context
The repository uses Ruff with `line-length = 120`.

## Fix Focus Areas
- pr_agent/git_providers/azuredevops_provider.py[187-188]

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

except Exception:
return b""

def get_files(self):
files = []
for i in self.azure_devops_client.get_pull_request_commits(
Expand Down
11 changes: 11 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,17 @@ def get_repo_settings(self):
except Exception:
return ""

def get_pr_agent_repo_custom_file(self, file_path: str) -> bytes:
try:
url = (f"https://api.bitbucket.org/2.0/repositories/{self.workspace_slug}/{self.repo_slug}/src/"
f"{self.pr.destination_branch}/{file_path}")
response = requests.request("GET", url, headers=self.headers)
if response.status_code == 404:
return b""
return response.text.encode("utf-8")
except Exception:
return b""
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated

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
4 changes: 4 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,10 @@ def _is_generated_by_pr_agent(self, description_lowercase: str) -> bool:
def get_repo_settings(self):
pass

def get_pr_agent_repo_custom_file(self, file_path: str) -> bytes:
"""Fetch a file from the repo (default branch). Empty bytes if missing or unsupported."""
return b""

def get_workspace_name(self):
return ""

Expand Down
6 changes: 6 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,12 @@ def get_repo_settings(self):
except Exception:
return ""

def get_pr_agent_repo_custom_file(self, file_path: str) -> bytes:
try:
return self.repo_obj.get_contents(file_path).decoded_content
except Exception:
return b""

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

Expand Down
7 changes: 7 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,13 @@ def get_repo_settings(self):
except Exception:
return ""

def get_pr_agent_repo_custom_file(self, file_path: str) -> bytes:
try:
main_branch = self.gl.projects.get(self.id_project).default_branch
return self.gl.projects.get(self.id_project).files.get(file_path=file_path, ref=main_branch).decode()
except Exception:
return b""

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

Expand Down
9 changes: 9 additions & 0 deletions pr_agent/git_providers/local_git_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ def get_commit_messages(self):
def get_repo_settings(self):
pass # Not applicable to the local git provider, but required by the interface

def get_pr_agent_repo_custom_file(self, file_path: str) -> bytes:
try:
full_path = Path(self.repo.working_tree_dir) / file_path
if not full_path.is_file():
return b""
return full_path.read_bytes()
except Exception:
return b""

def remove_reaction(self, comment):
pass # Not applicable to the local git provider, but required by the interface

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ Specific guidelines for generating code suggestions:
- Be aware that your input consists only of partial code segments (PR diff code), not the complete codebase. Therefore, avoid making suggestions that might duplicate existing functionality, and refrain from questioning code elements (such as variable declarations or import statements) that may be defined elsewhere in the codebase.
- When mentioning code elements (variables, names, or files) in your response, surround them with backticks (`). For example: "verify that `user_id` is..."

{%- if relevant_best_practices %}


Organization best practices (from `best_practices.md`, treat as authoritative coding standards):
======
{{ relevant_best_practices }}
======
{%- endif %}

{%- if extra_instructions %}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ Specific guidelines for generating code suggestions:
- Note that you will only see partial code segments that were changed (diff hunks in a PR code), and not the entire codebase. Avoid suggestions that might duplicate existing functionality of the outer codebase. In addition, the absence of a definition, declaration, import, or initialization for any entity in the PR code is NEVER a basis for a suggestion.
- Also note that if the code ends at an opening brace or statement that begins a new scope (like 'if', 'for', 'try'), don't treat it as incomplete. Instead, acknowledge the visible scope boundary and analyze only the code shown.

{%- if relevant_best_practices %}


Organization best practices (from `best_practices.md`, treat as authoritative coding standards):
======
{{ relevant_best_practices }}
======
{%- endif %}

{%- if extra_instructions %}


Expand Down
5 changes: 5 additions & 0 deletions pr_agent/settings/configuration.toml
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,11 @@ content = ""
organization_name = ""
max_lines_allowed = 800
enable_global_best_practices = false
# OSS: load best_practices.md from the repo and pass it to the 'improve' tool prompt.
# Default ON to match the public docs; set to false in your .pr_agent.toml to opt out.
# See docs/docs/tools/improve.md.
enable_repo_best_practices_md = true
repo_best_practices_md_path = "best_practices.md"

[auto_best_practices]
enable_auto_best_practices = true # public - general flag to disable all auto best practices usage
Expand Down
48 changes: 47 additions & 1 deletion pr_agent/tools/pr_code_suggestions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Dict, List

from jinja2 import Environment, StrictUndefined
from starlette_context import context

from pr_agent.algo import MAX_TOKENS
from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler
Expand All @@ -30,6 +31,51 @@
from pr_agent.tools.pr_description import insert_br_after_x_chars


def _load_repo_best_practices_md(git_provider) -> str:
settings = get_settings()
if not settings.get("best_practices.enable_repo_best_practices_md", True):
return ""
try:
cached = context.get("best_practices_md", None)
except Exception:
cached = None
if cached is not None:
return cached
file_path = settings.get("best_practices.repo_best_practices_md_path", "best_practices.md") or "best_practices.md"
raw = b""
try:
raw = git_provider.get_pr_agent_repo_custom_file(file_path) or b""
except Exception as e:
get_logger().warning(f"Failed to fetch {file_path} from repo: {e}")
if isinstance(raw, (bytes, bytearray)):
text = raw.decode("utf-8", errors="replace")
else:
text = str(raw or "")
if not text.strip():
try:
context["best_practices_md"] = ""
except Exception:
pass
return ""
line_count = text.count("\n") + 1
get_logger().info(
f"Loaded {file_path} from repo ({len(text)} bytes, {line_count} lines) for 'improve' tool"
)
max_lines = int(settings.get("best_practices.max_lines_allowed", 800) or 800)
lines = text.splitlines()
if len(lines) > max_lines:
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
get_logger().warning(
f"Truncating {file_path} from {len(lines)} to {max_lines} lines "
f"(see [best_practices].max_lines_allowed)"
)
text = "\n".join(lines[:max_lines])
try:
context["best_practices_md"] = text
except Exception:
pass
return text


class PRCodeSuggestions:
def __init__(self, pr_url: str, cli_mode=False, args: list = None,
ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler):
Expand Down Expand Up @@ -67,7 +113,7 @@ def __init__(self, pr_url: str, cli_mode=False, args: list = None,
"num_code_suggestions": num_code_suggestions,
"extra_instructions": get_settings().pr_code_suggestions.extra_instructions,
"commit_messages_str": self.git_provider.get_commit_messages(),
"relevant_best_practices": "",
"relevant_best_practices": _load_repo_best_practices_md(self.git_provider),
"is_ai_metadata": get_settings().get("config.enable_ai_metadata", False),
"focus_only_on_problems": get_settings().get("pr_code_suggestions.focus_only_on_problems", False),
"date": datetime.now().strftime('%Y-%m-%d'),
Expand Down
141 changes: 141 additions & 0 deletions tests/unittest/test_pr_code_suggestions_best_practices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import unittest
from unittest.mock import MagicMock, patch

from pr_agent.tools.pr_code_suggestions import _load_repo_best_practices_md


def _provider(returns):
p = MagicMock(spec=["get_pr_agent_repo_custom_file"])
p.get_pr_agent_repo_custom_file.return_value = returns
return p


class _FakeContext(dict):
"""Mimics starlette_context.context.get/__setitem__ in a plain dict."""


class _FakeContextProxy:
"""Module-level proxy that works as both subscriptable and attribute target."""

def __init__(self):
self._store = {}

def get(self, key, default=None):
return self._store.get(key, default)

def __getitem__(self, key):
return self._store[key]

def __setitem__(self, key, value):
self._store[key] = value

def reset(self):
self._store.clear()


class TestLoadRepoBestPracticesMd(unittest.TestCase):
def setUp(self):
self.fake_ctx = _FakeContextProxy()
self.ctx_patch = patch(
"pr_agent.tools.pr_code_suggestions.context", self.fake_ctx
)
self.ctx_patch.start()

def tearDown(self):
self.ctx_patch.stop()

@patch("pr_agent.tools.pr_code_suggestions.get_settings")
def test_enabled_by_default_with_content(self, mock_get_settings):
s = MagicMock()
s.get.side_effect = lambda key, default=None: {
"best_practices.enable_repo_best_practices_md": True,
"best_practices.repo_best_practices_md_path": "best_practices.md",
"best_practices.max_lines_allowed": 800,
}.get(key, default)
mock_get_settings.return_value = s
prov = _provider(b"# Best practices\n- rule 1\n- rule 2\n")
out = _load_repo_best_practices_md(prov)
self.assertIn("rule 1", out)
self.assertIn("rule 2", out)
prov.get_pr_agent_repo_custom_file.assert_called_once_with("best_practices.md")

@patch("pr_agent.tools.pr_code_suggestions.get_settings")
def test_opt_out_skips_fetch(self, mock_get_settings):
s = MagicMock()
s.get.side_effect = lambda key, default=None: {
"best_practices.enable_repo_best_practices_md": False,
}.get(key, default)
mock_get_settings.return_value = s
prov = _provider(b"should not be read")
out = _load_repo_best_practices_md(prov)
self.assertEqual(out, "")
prov.get_pr_agent_repo_custom_file.assert_not_called()

@patch("pr_agent.tools.pr_code_suggestions.get_settings")
def test_file_absent_returns_empty(self, mock_get_settings):
s = MagicMock()
s.get.side_effect = lambda key, default=None: {
"best_practices.enable_repo_best_practices_md": True,
"best_practices.repo_best_practices_md_path": "best_practices.md",
"best_practices.max_lines_allowed": 800,
}.get(key, default)
mock_get_settings.return_value = s
prov = _provider(b"")
out = _load_repo_best_practices_md(prov)
self.assertEqual(out, "")

@patch("pr_agent.tools.pr_code_suggestions.get_logger")
@patch("pr_agent.tools.pr_code_suggestions.get_settings")
def test_truncation_emits_warning(self, mock_get_settings, mock_get_logger):
s = MagicMock()
s.get.side_effect = lambda key, default=None: {
"best_practices.enable_repo_best_practices_md": True,
"best_practices.repo_best_practices_md_path": "best_practices.md",
"best_practices.max_lines_allowed": 5,
}.get(key, default)
mock_get_settings.return_value = s
logger = MagicMock()
mock_get_logger.return_value = logger
body = "\n".join(f"line {i}" for i in range(20))
prov = _provider(body.encode("utf-8"))
out = _load_repo_best_practices_md(prov)
self.assertEqual(len(out.splitlines()), 5)
# WARNING message about truncation must include the from/to counts.
warning_msgs = [c.args[0] for c in logger.warning.call_args_list]
self.assertTrue(any("Truncating" in m and "20" in m and "5" in m for m in warning_msgs),
f"warning not emitted: {warning_msgs}")
# INFO log emitted on fetch.
info_msgs = [c.args[0] for c in logger.info.call_args_list]
self.assertTrue(any("Loaded" in m for m in info_msgs))

@patch("pr_agent.tools.pr_code_suggestions.get_settings")
def test_caches_across_calls(self, mock_get_settings):
s = MagicMock()
s.get.side_effect = lambda key, default=None: {
"best_practices.enable_repo_best_practices_md": True,
"best_practices.repo_best_practices_md_path": "best_practices.md",
"best_practices.max_lines_allowed": 800,
}.get(key, default)
mock_get_settings.return_value = s
prov = _provider(b"hello\n")
first = _load_repo_best_practices_md(prov)
second = _load_repo_best_practices_md(prov)
self.assertEqual(first, second)
prov.get_pr_agent_repo_custom_file.assert_called_once()

@patch("pr_agent.tools.pr_code_suggestions.get_settings")
def test_str_return_tolerated(self, mock_get_settings):
s = MagicMock()
s.get.side_effect = lambda key, default=None: {
"best_practices.enable_repo_best_practices_md": True,
"best_practices.repo_best_practices_md_path": "best_practices.md",
"best_practices.max_lines_allowed": 800,
}.get(key, default)
mock_get_settings.return_value = s
prov = _provider("text content\n")
out = _load_repo_best_practices_md(prov)
self.assertIn("text content", out)


if __name__ == "__main__":
unittest.main()
Comment on lines +1 to +158
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

3. testloadrepobestpracticesmd uses unittest 📘 Rule violation ⚙ Maintainability

The new test module is written using unittest.TestCase and unittest.main(), while the
repository’s test suite is configured for pytest conventions. This can lead to inconsistent test
patterns and maintenance overhead.
Agent Prompt
## Issue description
The new unit tests are implemented with `unittest` (`unittest.TestCase`, `unittest.main()`), but the compliance requirement and repository conventions expect pytest-style tests.

## Issue Context
The repository is configured for pytest test discovery/execution, and other unit tests in `tests/unittest/` follow pytest patterns.

## Fix Focus Areas
- tests/unittest/test_pr_code_suggestions_best_practices.py[1-158]

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

Loading