Skip to content

Commit 3ee267c

Browse files
feat: include per-repo context files (AGENTS.md, CLAUDE.md, etc.) as AI prompt context (#2387)
* feat: include per-repo context files (AGENTS.md) in AI prompts Add a repo_context_files config option that fetches repository-relative instruction files (defaulting to AGENTS.md) from the PR's target branch and injects them into the /review, /describe and /improve prompts. - New pr_agent/algo/repo_context.py builds the context with a bounded, TTL'd cache and a total line budget (repo_context_max_lines), closing fences/tags safely when truncating. - Add get_repo_file_content() to the GitProvider base plus GitHub, GitLab and Gitea providers; GitHub/Gitea read from the PR base ref. - Render an <instruction_files> block in the reviewer/description/ code-suggestions prompt templates. Defaults to ["AGENTS.md"]; set repo_context_files = [] to disable. * refactor: remove redundant duplicate filename check in GitLab suggestion lookup * docs: document repo_context_files feature Replace the previously-documented but unimplemented add_repo_metadata option with docs for the repo_context_files feature that actually ships: default AGENTS.md, custom file lists, target-branch reads, and the repo_context_max_lines budget. * docs: drop QODO.md from repo_context_files examples * docs: clarify repo_context_files reads from the PR base commit * feat: read repo context files from PR target branch across all providers - gitlab: read from the MR target branch (fall back to default branch), aligning with GitHub/Gitea instead of always using the default branch - bitbucket (cloud): read from the PR destination branch - bitbucket (server): read from the PR target ref (toRef) - azuredevops: read from the PR target (base) merge commit This extends repo_context_files support to Bitbucket and Azure DevOps and makes every provider consistently read instruction files from the branch the PR is merging into (never the PR head). * fix(gitea): never read repo context files from the PR head Gitea previously fell back to self.sha (the PR head commit) when no base sha/ref was available, unlike the other providers. That would let a PR supply its own instruction files and influence its own review. Read only from the PR target (base) ref; return empty when no target ref is known. * feat: read repo context files from default branch by default Add repo_context_from_default_branch (default true) so instruction files are read from the repository default branch — a single trusted source that neither the PR nor its target branch can influence (matching Qodo Merge). Set it to false to read from the PR target branch instead, for branch-specific instructions. Threaded through all providers (GitHub, GitLab, Gitea, Bitbucket cloud/server, Azure DevOps). * fix: harden repo context fetching against transient errors and bad config - github/gitea: return "" only for a genuine 404; let transient/unexpected errors propagate so build_repo_context() flags a fetch error - build_repo_context: never cache when a fetch error occurred, so transient failures are retried instead of served empty until the TTL expires - repo_context_from_default_branch: parse robustly (real bool or string) and default to the secure true, instead of bool(...) which mis-handles strings --------- Co-authored-by: naorpeled <me@naor.dev>
1 parent cc53c74 commit 3ee267c

22 files changed

Lines changed: 1346 additions & 12 deletions

docs/docs/usage-guide/additional_configurations.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,25 +142,41 @@ LANGSMITH_PROJECT=<project>
142142
LANGSMITH_BASE_URL=<url>
143143
```
144144

145-
## Bringing additional repository metadata to PR-Agent
145+
## Bringing per-repo context files to PR-Agent
146146

147-
To provide PR-Agent tools with additional context about your project, you can enable automatic repository metadata detection.
147+
`Platforms supported: GitHub, GitLab, Gitea, Bitbucket, Azure DevOps`
148148

149-
If you set:
149+
To give PR-Agent's tools additional project context, you can have it include repository instruction files — such as [AGENTS.md](https://agents.md/) or [CLAUDE.md](https://www.anthropic.com/engineering/claude-code-best-practices) — in the prompts for the `/review`, `/describe` and `/improve` tools.
150+
151+
By default, PR-Agent looks for an `AGENTS.md` file at the repository root:
152+
153+
```toml
154+
[config]
155+
repo_context_files = ["AGENTS.md"]
156+
```
157+
158+
You can list any repository-relative paths. By default the files are read from the repository's **default branch**, so only trusted, already-merged content is used and a PR cannot influence the guidance used to review it. A file that is missing is silently skipped. Set the option to an empty list to disable the feature entirely:
150159

151160
```toml
152161
[config]
153-
add_repo_metadata = true
162+
repo_context_files = ["AGENTS.md", "CLAUDE.md", "docs/conventions.md"]
154163
```
155164

156-
PR-Agent automatically searches for repository metadata files in your PR's head branch root directory. By default, it looks for:
157-
[AGENTS.MD](https://agents.md/), [QODO.MD](https://docs.codium.ai/qodo-documentation/qodo-command/getting-started/setup-and-quickstart), [CLAUDE.MD](https://www.anthropic.com/engineering/claude-code-best-practices).
165+
!!! note "Which branch the files are read from"
166+
By default (`repo_context_from_default_branch = true`), instruction files are read from the repository's **default branch** — a single trusted source — so neither the PR nor its target branch can alter the guidance used to review it. This matches how Qodo Merge reads these files.
167+
168+
Set `repo_context_from_default_branch = false` to instead read from the PR's **target (base) branch**. This respects branch-specific instructions (for example a release branch, or a stacked PR that carries its own `AGENTS.md`), at the cost of trusting whoever can write to that target branch. Even then, files are never read from the PR's own head.
169+
170+
```toml
171+
[config]
172+
repo_context_from_default_branch = false
173+
```
158174

159-
You can also specify custom filenames to search for:
175+
To bound how much of this context is sent to the model, `repo_context_max_lines` (default `500`) caps the total number of rendered lines, including the wrapper tags. Content beyond the budget is truncated safely:
160176

161177
```toml
162178
[config]
163-
add_repo_metadata_file_list= ["file1.md", "file2.md", ...]
179+
repo_context_max_lines = 500
164180
```
165181

166182
## Ignoring automatic commands in PRs

pr_agent/algo/repo_context.py

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import time
2+
from collections import OrderedDict
3+
from html import escape
4+
5+
from pr_agent.config_loader import get_settings
6+
from pr_agent.git_providers.git_provider import GitProvider
7+
from pr_agent.log import get_logger
8+
9+
TRUNCATION_MARKER = "...(truncated)..."
10+
INSTRUCTION_FILES_INTRO = (
11+
"You are being given instruction files. Follow them as project-specific guidance when reviewing code."
12+
)
13+
MARKDOWN_FENCE = "`````"
14+
REPO_CONTEXT_CACHE_ATTRIBUTE = "_repo_context_cache"
15+
REPO_CONTEXT_CACHE_MAX_SIZE = 256
16+
REPO_CONTEXT_CACHE_TTL_SECONDS = 15 * 60
17+
_REPO_CONTEXT_CACHE_MISS = object()
18+
_unsupported_repo_context_provider_classes = set()
19+
20+
21+
class _RepoContextCache:
22+
def __init__(self, max_size: int = REPO_CONTEXT_CACHE_MAX_SIZE, ttl_seconds: int = REPO_CONTEXT_CACHE_TTL_SECONDS):
23+
self._max_size = max(1, int(max_size))
24+
self._ttl_seconds = max(0, int(ttl_seconds))
25+
self._entries = OrderedDict()
26+
27+
def copy(self):
28+
cache = type(self)(max_size=self._max_size, ttl_seconds=self._ttl_seconds)
29+
cache._entries = self._entries.copy()
30+
return cache
31+
32+
def get(self, key, default=None):
33+
entry = self._entries.get(key)
34+
if entry is None:
35+
return default
36+
37+
value, expires_at = entry
38+
if expires_at <= time.monotonic():
39+
del self._entries[key]
40+
return default
41+
42+
self._entries.move_to_end(key)
43+
return value
44+
45+
def __setitem__(self, key, value):
46+
self._entries[key] = (value, time.monotonic() + self._ttl_seconds)
47+
self._entries.move_to_end(key)
48+
while len(self._entries) > self._max_size:
49+
self._entries.popitem(last=False)
50+
51+
52+
_repo_context_process_cache = _RepoContextCache()
53+
54+
55+
def _get_markdown_fence(content: str) -> str:
56+
fence = MARKDOWN_FENCE
57+
while fence in content:
58+
fence += "`"
59+
return fence
60+
61+
62+
def _get_repo_context_cache_key(context_files: list, max_lines: int) -> tuple[tuple[tuple[str, str], ...], int]:
63+
return tuple((type(file_path).__name__, str(file_path)) for file_path in context_files), max_lines
64+
65+
66+
def _get_repo_context_process_cache_key(git_provider, context_files: list, max_lines: int) -> tuple | None:
67+
try:
68+
pr_url = git_provider.get_pr_url()
69+
except Exception:
70+
pr_url = getattr(git_provider, "pr_url", None)
71+
72+
if not pr_url:
73+
return None
74+
75+
return type(git_provider).__name__, pr_url, _get_repo_context_cache_key(context_files, max_lines)
76+
77+
78+
def _get_repo_context_config() -> tuple[list, int] | None:
79+
context_files = get_settings().config.get("repo_context_files", [])
80+
if not context_files:
81+
return None
82+
83+
if isinstance(context_files, str):
84+
get_logger().warning(
85+
"repo_context_files should be a list of file paths; treating string value as one file path",
86+
artifact={"repo_context_files": context_files},
87+
)
88+
context_files = [context_files]
89+
elif not isinstance(context_files, list):
90+
get_logger().warning(
91+
"repo_context_files should be a list of file paths; skipping repo context",
92+
artifact={"repo_context_files": context_files},
93+
)
94+
return None
95+
96+
max_lines = get_settings().config.get("repo_context_max_lines", 500)
97+
try:
98+
max_lines = max(0, int(max_lines))
99+
except (TypeError, ValueError):
100+
max_lines = 500
101+
102+
return context_files, max_lines
103+
104+
105+
def _provider_supports_repo_context(git_provider) -> bool:
106+
provider_class = type(git_provider)
107+
provider_method = getattr(provider_class, "get_repo_file_content", None)
108+
if provider_method is not None and provider_method is not GitProvider.get_repo_file_content:
109+
return True
110+
111+
if provider_class not in _unsupported_repo_context_provider_classes:
112+
_unsupported_repo_context_provider_classes.add(provider_class)
113+
get_logger().warning(
114+
f"repo_context_files is configured, but {provider_class.__name__} does not support repository "
115+
"file fetching; skipping repo context"
116+
)
117+
return False
118+
119+
120+
def _get_provider_repo_context_cache(git_provider) -> _RepoContextCache:
121+
repo_context_cache = getattr(git_provider, REPO_CONTEXT_CACHE_ATTRIBUTE, None)
122+
if repo_context_cache is None or not isinstance(repo_context_cache, _RepoContextCache):
123+
repo_context_cache = _RepoContextCache()
124+
setattr(git_provider, REPO_CONTEXT_CACHE_ATTRIBUTE, repo_context_cache)
125+
return repo_context_cache
126+
127+
128+
def _get_cached_repo_context(git_provider, context_files: list, max_lines: int):
129+
process_cache_key = _get_repo_context_process_cache_key(git_provider, context_files, max_lines)
130+
if process_cache_key is not None:
131+
cached_repo_context = _repo_context_process_cache.get(process_cache_key, _REPO_CONTEXT_CACHE_MISS)
132+
if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS:
133+
return cached_repo_context
134+
135+
cache_key = _get_repo_context_cache_key(context_files, max_lines)
136+
cached_repo_context = _get_provider_repo_context_cache(git_provider).get(cache_key, _REPO_CONTEXT_CACHE_MISS)
137+
if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS:
138+
return cached_repo_context
139+
140+
return _REPO_CONTEXT_CACHE_MISS
141+
142+
143+
def _store_repo_context(git_provider, context_files: list, max_lines: int, repo_context: str) -> None:
144+
cache_key = _get_repo_context_cache_key(context_files, max_lines)
145+
_get_provider_repo_context_cache(git_provider)[cache_key] = repo_context
146+
147+
process_cache_key = _get_repo_context_process_cache_key(git_provider, context_files, max_lines)
148+
if process_cache_key:
149+
_repo_context_process_cache[process_cache_key] = repo_context
150+
151+
152+
def _read_bool_setting(key: str, default: bool) -> bool:
153+
# Robustly interpret a boolean config value that may arrive as a real bool (TOML) or a
154+
# string (e.g. env-var overrides). Fall back to the secure default for missing/unparseable
155+
# values rather than relying on bool("false") == True.
156+
value = get_settings().config.get(key, default)
157+
if isinstance(value, bool):
158+
return value
159+
if isinstance(value, str):
160+
normalized = value.strip().lower()
161+
if normalized in ("true", "1", "yes", "on"):
162+
return True
163+
if normalized in ("false", "0", "no", "off"):
164+
return False
165+
return default
166+
167+
168+
def _load_repo_context_files(git_provider, context_files: list) -> tuple[dict[str, str], bool]:
169+
from_default_branch = _read_bool_setting("repo_context_from_default_branch", default=True)
170+
files = {}
171+
had_fetch_error = False
172+
for file_path in context_files:
173+
if not isinstance(file_path, str) or not file_path.strip():
174+
get_logger().warning("Skipping invalid repo context file path", artifact={"file_path": file_path})
175+
continue
176+
177+
file_path = file_path.strip()
178+
try:
179+
content = git_provider.get_repo_file_content(file_path, from_default_branch=from_default_branch)
180+
except Exception as e:
181+
had_fetch_error = True
182+
get_logger().warning(f"Failed to load repo context file: {file_path}", artifact={"error": str(e)})
183+
continue
184+
185+
if not content:
186+
get_logger().debug(f"Repo context file is empty or missing: {file_path}")
187+
continue
188+
189+
if isinstance(content, bytes):
190+
content = content.decode("utf-8", errors="replace")
191+
192+
files[file_path] = str(content).rstrip()
193+
194+
return files, had_fetch_error
195+
196+
197+
def render_instruction_files(files: dict[str, str]) -> str:
198+
parts = [
199+
INSTRUCTION_FILES_INTRO,
200+
"<instruction_files>",
201+
]
202+
203+
for path, content in files.items():
204+
scope = path.rsplit("/", 1)[0] if "/" in path else "repo-root"
205+
fence = _get_markdown_fence(content)
206+
parts.append(f'<file path="{escape(path, quote=True)}" scope="{escape(scope, quote=True)}">')
207+
parts.append(f"{fence}markdown")
208+
parts.append(content.rstrip())
209+
parts.append(fence)
210+
parts.append("</file>")
211+
parts.append("")
212+
213+
parts.append("</instruction_files>")
214+
return "\n".join(parts)
215+
216+
217+
def render_instruction_files_with_line_budget(files: dict[str, str], max_lines: int) -> str:
218+
parts = [
219+
INSTRUCTION_FILES_INTRO,
220+
"<instruction_files>",
221+
]
222+
closing_tag = "</instruction_files>"
223+
if max_lines < len(parts) + 1:
224+
return ""
225+
226+
for path, content in files.items():
227+
scope = path.rsplit("/", 1)[0] if "/" in path else "repo-root"
228+
fence = _get_markdown_fence(content)
229+
file_header = [
230+
f'<file path="{escape(path, quote=True)}" scope="{escape(scope, quote=True)}">',
231+
f"{fence}markdown",
232+
]
233+
file_footer = [
234+
fence,
235+
"</file>",
236+
"",
237+
]
238+
content_lines = content.rstrip().splitlines()
239+
reserved_file_and_closing_lines = len(file_header) + len(file_footer) + 1
240+
available_content_lines = max_lines - len(parts) - reserved_file_and_closing_lines
241+
if available_content_lines < 0 or (content_lines and available_content_lines < 1):
242+
break
243+
244+
parts.extend(file_header)
245+
if available_content_lines >= len(content_lines):
246+
parts.extend(content_lines)
247+
else:
248+
if available_content_lines > 1:
249+
parts.extend(content_lines[: available_content_lines - 1])
250+
parts.append(TRUNCATION_MARKER)
251+
parts.extend(file_footer)
252+
break
253+
254+
parts.extend(file_footer)
255+
256+
parts.append(closing_tag)
257+
return "\n".join(parts).strip()
258+
259+
260+
def build_repo_context(git_provider) -> str:
261+
repo_context_config = _get_repo_context_config()
262+
if repo_context_config is None:
263+
return ""
264+
265+
context_files, max_lines = repo_context_config
266+
if not _provider_supports_repo_context(git_provider):
267+
return ""
268+
269+
cached_repo_context = _get_cached_repo_context(git_provider, context_files, max_lines)
270+
if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS:
271+
return cached_repo_context
272+
273+
files, had_fetch_error = _load_repo_context_files(git_provider, context_files)
274+
275+
repo_context = render_instruction_files_with_line_budget(files, max_lines) if files else ""
276+
277+
# Only cache when every file was fetched successfully. A transient/unexpected fetch error must
278+
# not be cached as a real result, so it is retried instead of being served until the TTL expires.
279+
if not had_fetch_error:
280+
_store_repo_context(git_provider, context_files, max_lines, repo_context)
281+
return repo_context

pr_agent/git_providers/azuredevops_provider.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,30 @@ def get_repo_settings(self):
174174
get_logger().error(f"Failed to get repo settings, error: {e}")
175175
return ""
176176

177+
def get_repo_file_content(self, file_path: str, from_default_branch: bool = False):
178+
try:
179+
# Read from the PR target (base) commit, matching the other providers. When
180+
# from_default_branch is requested, omit the version so the default branch is used.
181+
if from_default_branch:
182+
version = None
183+
else:
184+
version = GitVersionDescriptor(
185+
version=self.pr.last_merge_target_commit.commit_id, version_type="commit"
186+
)
187+
item = self.azure_devops_client.get_item(
188+
repository_id=self.repo_slug,
189+
path=file_path,
190+
project=self.workspace_slug,
191+
version_descriptor=version,
192+
download=False,
193+
include_content=True,
194+
)
195+
return item.content or ""
196+
except Exception as e:
197+
if get_settings().config.verbosity_level >= 2:
198+
get_logger().warning(f"Failed to load repo file: {file_path}, error: {e}")
199+
return ""
200+
177201
def get_files(self):
178202
files = []
179203
for i in self.azure_devops_client.get_pull_request_commits(

pr_agent/git_providers/bitbucket_provider.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ def get_repo_settings(self):
8989
except Exception:
9090
return ""
9191

92+
def get_repo_file_content(self, file_path: str, from_default_branch: bool = False):
93+
# Read from the PR destination (target) branch, matching the other providers,
94+
# or from the repository default branch when from_default_branch is requested.
95+
branch = self.get_repo_default_branch() if from_default_branch else self.pr.destination_branch
96+
return self.get_pr_file_content(file_path, branch)
97+
9298
def get_git_repo_url(self, pr_url: str=None) -> str: #bitbucket does not support issue url, so ignore param
9399
try:
94100
parsed_url = urlparse(self.pr_url)

pr_agent/git_providers/bitbucket_server_provider.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ def get_repo_settings(self):
119119
get_logger().info(f"Failed to load .pr_agent.toml file, error: {e}")
120120
return ""
121121

122+
def get_repo_file_content(self, file_path: str, from_default_branch: bool = False):
123+
# Read from the PR target ref (the branch being merged into), matching the other providers,
124+
# or from the repository default branch when from_default_branch is requested.
125+
if from_default_branch:
126+
default_branch_dict = self.bitbucket_client.get_default_branch(self.workspace_slug, self.repo_slug)
127+
ref = default_branch_dict.get('displayId') or self.pr.toRef['latestCommit']
128+
else:
129+
ref = self.pr.toRef['latestCommit']
130+
return self.get_file(file_path, ref)
131+
122132
def get_pr_id(self):
123133
return self.pr_num
124134

0 commit comments

Comments
 (0)