Skip to content

Commit 2b313ad

Browse files
committed
chore: address agent comments
1 parent 58f468a commit 2b313ad

5 files changed

Lines changed: 163 additions & 10 deletions

File tree

pr_agent/algo/repo_context.py

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,99 @@
11
from html import escape
22

33
from pr_agent.config_loader import get_settings
4+
from pr_agent.git_providers.git_provider import GitProvider
45
from pr_agent.log import get_logger
56

7+
TRUNCATION_MARKER = "...(truncated)..."
8+
INSTRUCTION_FILES_INTRO = (
9+
"You are being given instruction files. Follow them as project-specific guidance when reviewing code."
10+
)
11+
MARKDOWN_FENCE = "`````"
12+
_unsupported_repo_context_provider_classes = set()
13+
614

715
def render_instruction_files(files: dict[str, str]) -> str:
816
parts = [
9-
"You are being given instruction files. Follow them as project-specific guidance when reviewing code.",
17+
INSTRUCTION_FILES_INTRO,
1018
"<instruction_files>",
1119
]
1220

1321
for path, content in files.items():
1422
scope = path.rsplit("/", 1)[0] if "/" in path else "repo-root"
1523
parts.append(f'<file path="{escape(path, quote=True)}" scope="{escape(scope, quote=True)}">')
16-
parts.append("`````markdown")
24+
parts.append(f"{MARKDOWN_FENCE}markdown")
1725
parts.append(content.rstrip())
18-
parts.append("`````")
26+
parts.append(MARKDOWN_FENCE)
1927
parts.append("</file>")
2028
parts.append("")
2129

2230
parts.append("</instruction_files>")
2331
return "\n".join(parts)
2432

2533

34+
def render_instruction_files_with_line_budget(files: dict[str, str], max_lines: int) -> str:
35+
parts = [
36+
INSTRUCTION_FILES_INTRO,
37+
"<instruction_files>",
38+
]
39+
40+
for path, content in files.items():
41+
scope = path.rsplit("/", 1)[0] if "/" in path else "repo-root"
42+
file_header = [
43+
f'<file path="{escape(path, quote=True)}" scope="{escape(scope, quote=True)}">',
44+
f"{MARKDOWN_FENCE}markdown",
45+
]
46+
file_footer = [
47+
MARKDOWN_FENCE,
48+
"</file>",
49+
"",
50+
]
51+
content_lines = content.rstrip().splitlines()
52+
reserved_closing_lines = len(file_header) + len(file_footer) + 1
53+
available_content_lines = max_lines - len(parts) - reserved_closing_lines
54+
55+
parts.extend(file_header)
56+
if available_content_lines >= len(content_lines):
57+
parts.extend(content_lines)
58+
else:
59+
if available_content_lines > 1:
60+
parts.extend(content_lines[: available_content_lines - 1])
61+
parts.append(TRUNCATION_MARKER)
62+
parts.extend(file_footer)
63+
break
64+
65+
parts.extend(file_footer)
66+
67+
parts.append("</instruction_files>")
68+
return "\n".join(parts).strip()
69+
70+
2671
def build_repo_context(git_provider) -> str:
2772
context_files = get_settings().config.get("repo_context_files", [])
2873
if not context_files:
2974
return ""
75+
if isinstance(context_files, str):
76+
get_logger().warning(
77+
"repo_context_files should be a list of file paths; treating string value as one file path",
78+
artifact={"repo_context_files": context_files},
79+
)
80+
context_files = [context_files]
81+
elif not isinstance(context_files, list):
82+
get_logger().warning(
83+
"repo_context_files should be a list of file paths; skipping repo context",
84+
artifact={"repo_context_files": context_files},
85+
)
86+
return ""
87+
88+
provider_class = type(git_provider)
89+
if provider_class.get_repo_file_content is GitProvider.get_repo_file_content:
90+
if provider_class not in _unsupported_repo_context_provider_classes:
91+
_unsupported_repo_context_provider_classes.add(provider_class)
92+
get_logger().warning(
93+
f"repo_context_files is configured, but {provider_class.__name__} does not support repository "
94+
"file fetching; skipping repo context"
95+
)
96+
return ""
3097

3198
max_lines = get_settings().config.get("repo_context_max_lines", 500)
3299
try:
@@ -59,6 +126,4 @@ def build_repo_context(git_provider) -> str:
59126
if not files:
60127
return ""
61128

62-
rendered_lines = render_instruction_files(files).splitlines()
63-
64-
return "\n".join(rendered_lines[:max_lines]).strip()
129+
return render_instruction_files_with_line_budget(files, max_lines)

pr_agent/git_providers/gitlab_provider.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,17 @@ def get_repo_settings(self):
796796
except Exception:
797797
return ""
798798

799+
def get_repo_file_content(self, file_path: str):
800+
try:
801+
project = self.gl.projects.get(self.id_project)
802+
contents = project.files.get(file_path=file_path, ref=project.default_branch).decode()
803+
return decode_if_bytes(contents)
804+
except GitlabGetError:
805+
return ""
806+
except Exception as e:
807+
get_logger().warning(f"Failed to load repo file: {file_path}, error: {e}")
808+
return ""
809+
799810
def get_workspace_name(self):
800811
return self.id_project.split('/')[0]
801812

pr_agent/settings/configuration.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ ai_timeout=120 # 2 minutes
2525
skip_keys = []
2626
custom_reasoning_model = false # when true, disables system messages and temperature controls for models that don't support chat-style inputs
2727
response_language="en-US" # Language locales code for PR responses in ISO 3166 and ISO 639 format (e.g., "en-US", "it-IT", "zh-CN", ...)
28-
repo_context_files = [] # Explicit repository-relative files to include as AI prompt context, e.g. ["AGENTS.md"]
28+
repo_context_files = [] # Explicit repository-relative files to include as AI prompt context; use a list, e.g. ["AGENTS.md"]
2929
repo_context_max_lines = 500 # Maximum total lines rendered from configured repo context files
3030
# token limits
3131
max_description_tokens = 500

tests/unittest/test_gitlab_provider.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,27 @@ def test_get_pr_file_content_other_exception(self, gitlab_provider, mock_project
7373

7474
assert content == ""
7575

76+
def test_get_repo_file_content_loads_from_default_branch(self, gitlab_provider, mock_gitlab_client, mock_project):
77+
mock_project.default_branch = "main"
78+
mock_file = MagicMock(ProjectFile)
79+
mock_file.decode.return_value = b"repo context"
80+
mock_project.files.get.return_value = mock_file
81+
82+
content = gitlab_provider.get_repo_file_content("AGENTS.md")
83+
84+
assert content == "repo context"
85+
mock_gitlab_client.projects.get.assert_called_with("test/repo")
86+
mock_project.files.get.assert_called_once_with(file_path="AGENTS.md", ref="main")
87+
mock_file.decode.assert_called_once()
88+
89+
def test_get_repo_file_content_treats_missing_file_as_empty(self, gitlab_provider, mock_project):
90+
mock_project.default_branch = "main"
91+
mock_project.files.get.side_effect = GitlabGetError("404 Not Found")
92+
93+
content = gitlab_provider.get_repo_file_content("AGENTS.md")
94+
95+
assert content == ""
96+
7697
def test_create_or_update_pr_file_create_new(self, gitlab_provider, mock_project):
7798
mock_project.files.get.side_effect = GitlabGetError("404 Not Found")
7899
mock_file = MagicMock()

tests/unittest/test_repo_context.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
from unittest.mock import Mock
1+
from unittest.mock import Mock, patch
22

33
import pytest
44
from jinja2 import Environment, StrictUndefined
55

6+
from pr_agent.algo import repo_context
67
from pr_agent.algo.repo_context import build_repo_context, render_instruction_files
78
from pr_agent.config_loader import get_settings
9+
from pr_agent.git_providers.git_provider import GitProvider
810
from pr_agent.git_providers.github_provider import GithubProvider
911

1012

@@ -18,16 +20,22 @@ def get_repo_file_content(self, file_path: str):
1820
return self.files.get(file_path)
1921

2022

23+
class UnsupportedProvider:
24+
get_repo_file_content = GitProvider.get_repo_file_content
25+
26+
2127
@pytest.fixture
2228
def repo_context_settings():
2329
settings = get_settings()
2430
original_files = settings.config.get("repo_context_files", [])
2531
original_max_lines = settings.config.get("repo_context_max_lines", 500)
32+
original_warned_provider_classes = repo_context._unsupported_repo_context_provider_classes.copy()
2633

2734
yield settings
2835

2936
settings.set("CONFIG.REPO_CONTEXT_FILES", original_files)
3037
settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", original_max_lines)
38+
repo_context._unsupported_repo_context_provider_classes = original_warned_provider_classes
3139

3240

3341
def test_build_repo_context_fetches_and_formats_configured_files(repo_context_settings):
@@ -101,12 +109,19 @@ def test_build_repo_context_enforces_total_line_cap(repo_context_settings):
101109
"CONTRIBUTING.md": "four\nfive",
102110
})
103111

104-
assert build_repo_context(provider) == (
112+
context = build_repo_context(provider)
113+
114+
assert context == (
105115
"You are being given instruction files. Follow them as project-specific guidance when reviewing code.\n"
106116
"<instruction_files>\n"
107117
'<file path="AGENTS.md" scope="repo-root">\n'
108-
"`````markdown"
118+
"`````markdown\n"
119+
"...(truncated)...\n"
120+
"`````\n"
121+
"</file>\n\n"
122+
"</instruction_files>"
109123
)
124+
assert context.endswith("`````\n</file>\n\n</instruction_files>")
110125

111126

112127
def test_build_repo_context_returns_empty_when_no_files_configured(repo_context_settings):
@@ -115,6 +130,47 @@ def test_build_repo_context_returns_empty_when_no_files_configured(repo_context_
115130
assert build_repo_context(FakeProvider({"AGENTS.md": "repo purpose"})) == ""
116131

117132

133+
def test_build_repo_context_treats_string_config_as_single_file(repo_context_settings):
134+
repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", "AGENTS.md")
135+
provider = FakeProvider({"AGENTS.md": "repo purpose"})
136+
137+
assert build_repo_context(provider) == (
138+
"You are being given instruction files. Follow them as project-specific guidance when reviewing code.\n"
139+
"<instruction_files>\n"
140+
'<file path="AGENTS.md" scope="repo-root">\n'
141+
"`````markdown\n"
142+
"repo purpose\n"
143+
"`````\n"
144+
"</file>\n\n"
145+
"</instruction_files>"
146+
)
147+
assert provider.requested_paths == ["AGENTS.md"]
148+
149+
150+
def test_build_repo_context_skips_non_list_container(repo_context_settings):
151+
repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", {"AGENTS.md": True})
152+
provider = FakeProvider({"AGENTS.md": "repo purpose"})
153+
154+
assert build_repo_context(provider) == ""
155+
assert provider.requested_paths == []
156+
157+
158+
def test_build_repo_context_warns_once_for_provider_without_repo_file_fetching(repo_context_settings):
159+
repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", ["AGENTS.md"])
160+
provider = UnsupportedProvider()
161+
162+
with patch("pr_agent.algo.repo_context.get_logger") as mock_get_logger:
163+
context = build_repo_context(provider)
164+
second_context = build_repo_context(provider)
165+
166+
assert context == ""
167+
assert second_context == ""
168+
mock_get_logger.return_value.warning.assert_called_once_with(
169+
"repo_context_files is configured, but UnsupportedProvider does not support repository file fetching; "
170+
"skipping repo context"
171+
)
172+
173+
118174
def test_github_provider_decodes_repo_context_files_and_treats_failures_as_missing():
119175
provider = GithubProvider.__new__(GithubProvider)
120176
provider.repo_obj = Mock()

0 commit comments

Comments
 (0)