Skip to content

Commit f30a860

Browse files
committed
refactor: split repo context responsibilities
1 parent f286c78 commit f30a860

2 files changed

Lines changed: 161 additions & 70 deletions

File tree

pr_agent/algo/repo_context.py

Lines changed: 109 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,107 @@ def _get_repo_context_process_cache_key(git_provider, context_files: list, max_l
7575
return type(git_provider).__name__, pr_url, _get_repo_context_cache_key(context_files, max_lines)
7676

7777

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+
if provider_class.get_repo_file_content is not GitProvider.get_repo_file_content:
108+
return True
109+
110+
if provider_class not in _unsupported_repo_context_provider_classes:
111+
_unsupported_repo_context_provider_classes.add(provider_class)
112+
get_logger().warning(
113+
f"repo_context_files is configured, but {provider_class.__name__} does not support repository "
114+
"file fetching; skipping repo context"
115+
)
116+
return False
117+
118+
119+
def _get_provider_repo_context_cache(git_provider) -> _RepoContextCache:
120+
repo_context_cache = getattr(git_provider, REPO_CONTEXT_CACHE_ATTRIBUTE, None)
121+
if repo_context_cache is None or not isinstance(repo_context_cache, _RepoContextCache):
122+
repo_context_cache = _RepoContextCache()
123+
setattr(git_provider, REPO_CONTEXT_CACHE_ATTRIBUTE, repo_context_cache)
124+
return repo_context_cache
125+
126+
127+
def _get_cached_repo_context(git_provider, context_files: list, max_lines: int):
128+
process_cache_key = _get_repo_context_process_cache_key(git_provider, context_files, max_lines)
129+
if process_cache_key is not None:
130+
cached_repo_context = _repo_context_process_cache.get(process_cache_key, _REPO_CONTEXT_CACHE_MISS)
131+
if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS:
132+
return cached_repo_context
133+
134+
cache_key = _get_repo_context_cache_key(context_files, max_lines)
135+
cached_repo_context = _get_provider_repo_context_cache(git_provider).get(cache_key, _REPO_CONTEXT_CACHE_MISS)
136+
if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS:
137+
return cached_repo_context
138+
139+
return _REPO_CONTEXT_CACHE_MISS
140+
141+
142+
def _store_repo_context(git_provider, context_files: list, max_lines: int, repo_context: str) -> None:
143+
cache_key = _get_repo_context_cache_key(context_files, max_lines)
144+
_get_provider_repo_context_cache(git_provider)[cache_key] = repo_context
145+
146+
process_cache_key = _get_repo_context_process_cache_key(git_provider, context_files, max_lines)
147+
if process_cache_key:
148+
_repo_context_process_cache[process_cache_key] = repo_context
149+
150+
151+
def _load_repo_context_files(git_provider, context_files: list) -> tuple[dict[str, str], bool]:
152+
files = {}
153+
had_fetch_error = False
154+
for file_path in context_files:
155+
if not isinstance(file_path, str) or not file_path.strip():
156+
get_logger().warning("Skipping invalid repo context file path", artifact={"file_path": file_path})
157+
continue
158+
159+
file_path = file_path.strip()
160+
try:
161+
content = git_provider.get_repo_file_content(file_path)
162+
except Exception as e:
163+
had_fetch_error = True
164+
get_logger().warning(f"Failed to load repo context file: {file_path}", artifact={"error": str(e)})
165+
continue
166+
167+
if not content:
168+
get_logger().debug(f"Repo context file is empty or missing: {file_path}")
169+
continue
170+
171+
if isinstance(content, bytes):
172+
content = content.decode("utf-8", errors="replace")
173+
174+
files[file_path] = str(content).rstrip()
175+
176+
return files, had_fetch_error
177+
178+
78179
def render_instruction_files(files: dict[str, str]) -> str:
79180
parts = [
80181
INSTRUCTION_FILES_INTRO,
@@ -139,88 +240,26 @@ def render_instruction_files_with_line_budget(files: dict[str, str], max_lines:
139240

140241

141242
def build_repo_context(git_provider) -> str:
142-
context_files = get_settings().config.get("repo_context_files", [])
143-
if not context_files:
144-
return ""
145-
if isinstance(context_files, str):
146-
get_logger().warning(
147-
"repo_context_files should be a list of file paths; treating string value as one file path",
148-
artifact={"repo_context_files": context_files},
149-
)
150-
context_files = [context_files]
151-
elif not isinstance(context_files, list):
152-
get_logger().warning(
153-
"repo_context_files should be a list of file paths; skipping repo context",
154-
artifact={"repo_context_files": context_files},
155-
)
243+
repo_context_config = _get_repo_context_config()
244+
if repo_context_config is None:
156245
return ""
157246

158-
provider_class = type(git_provider)
159-
if provider_class.get_repo_file_content is GitProvider.get_repo_file_content:
160-
if provider_class not in _unsupported_repo_context_provider_classes:
161-
_unsupported_repo_context_provider_classes.add(provider_class)
162-
get_logger().warning(
163-
f"repo_context_files is configured, but {provider_class.__name__} does not support repository "
164-
"file fetching; skipping repo context"
165-
)
247+
context_files, max_lines = repo_context_config
248+
if not _provider_supports_repo_context(git_provider):
166249
return ""
167250

168-
max_lines = get_settings().config.get("repo_context_max_lines", 500)
169-
try:
170-
max_lines = max(0, int(max_lines))
171-
except (TypeError, ValueError):
172-
max_lines = 500
173-
174-
cache_key = _get_repo_context_cache_key(context_files, max_lines)
175-
process_cache_key = _get_repo_context_process_cache_key(git_provider, context_files, max_lines)
176-
if process_cache_key is not None:
177-
cached_repo_context = _repo_context_process_cache.get(process_cache_key, _REPO_CONTEXT_CACHE_MISS)
178-
if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS:
179-
return cached_repo_context
180-
181-
repo_context_cache = getattr(git_provider, REPO_CONTEXT_CACHE_ATTRIBUTE, None)
182-
if repo_context_cache is None or not isinstance(repo_context_cache, _RepoContextCache):
183-
repo_context_cache = _RepoContextCache()
184-
setattr(git_provider, REPO_CONTEXT_CACHE_ATTRIBUTE, repo_context_cache)
185-
cached_repo_context = repo_context_cache.get(cache_key, _REPO_CONTEXT_CACHE_MISS)
251+
cached_repo_context = _get_cached_repo_context(git_provider, context_files, max_lines)
186252
if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS:
187253
return cached_repo_context
188254

189-
files = {}
190-
had_fetch_error = False
191-
for file_path in context_files:
192-
if not isinstance(file_path, str) or not file_path.strip():
193-
get_logger().warning("Skipping invalid repo context file path", artifact={"file_path": file_path})
194-
continue
195-
196-
file_path = file_path.strip()
197-
try:
198-
content = git_provider.get_repo_file_content(file_path)
199-
except Exception as e:
200-
had_fetch_error = True
201-
get_logger().warning(f"Failed to load repo context file: {file_path}", artifact={"error": str(e)})
202-
continue
203-
204-
if not content:
205-
get_logger().debug(f"Repo context file is empty or missing: {file_path}")
206-
continue
207-
208-
if isinstance(content, bytes):
209-
content = content.decode("utf-8", errors="replace")
210-
211-
files[file_path] = str(content).rstrip()
212-
255+
files, had_fetch_error = _load_repo_context_files(git_provider, context_files)
213256
if not files and had_fetch_error:
214257
return ""
215258

216259
if not files:
217-
repo_context_cache[cache_key] = ""
218-
if process_cache_key:
219-
_repo_context_process_cache[process_cache_key] = ""
260+
_store_repo_context(git_provider, context_files, max_lines, "")
220261
return ""
221262

222263
repo_context = render_instruction_files_with_line_budget(files, max_lines)
223-
repo_context_cache[cache_key] = repo_context
224-
if process_cache_key:
225-
_repo_context_process_cache[process_cache_key] = repo_context
264+
_store_repo_context(git_provider, context_files, max_lines, repo_context)
226265
return repo_context

tests/unittest/test_repo_context.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,58 @@ def test_repo_context_cache_evicts_oldest_entry_when_full():
152152
assert cache.get("third", missing) == "three"
153153

154154

155+
def test_get_repo_context_config_normalizes_inputs(repo_context_settings):
156+
repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", "AGENTS.md")
157+
repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", "12")
158+
159+
assert repo_context._get_repo_context_config() == (["AGENTS.md"], 12)
160+
161+
162+
def test_get_repo_context_config_rejects_non_list_container(repo_context_settings):
163+
repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", {"AGENTS.md": True})
164+
165+
assert repo_context._get_repo_context_config() is None
166+
167+
168+
def test_provider_supports_repo_context_warns_once_for_unsupported_provider(repo_context_settings):
169+
provider = UnsupportedProvider()
170+
171+
with patch("pr_agent.algo.repo_context.get_logger") as mock_get_logger:
172+
assert repo_context._provider_supports_repo_context(provider) is False
173+
assert repo_context._provider_supports_repo_context(provider) is False
174+
175+
mock_get_logger.return_value.warning.assert_called_once_with(
176+
"repo_context_files is configured, but UnsupportedProvider does not support repository file fetching; "
177+
"skipping repo context"
178+
)
179+
180+
181+
def test_load_repo_context_files_normalizes_fetch_results():
182+
provider = FakeProvider({
183+
"AGENTS.md": b"Repo purpose",
184+
"EMPTY.md": "",
185+
"MISSING.md": None,
186+
})
187+
188+
files, had_fetch_error = repo_context._load_repo_context_files(
189+
provider, ["AGENTS.md", "EMPTY.md", "MISSING.md", " "]
190+
)
191+
192+
assert files == {"AGENTS.md": "Repo purpose"}
193+
assert had_fetch_error is False
194+
assert provider.requested_paths == ["AGENTS.md", "EMPTY.md", "MISSING.md"]
195+
196+
197+
def test_load_repo_context_files_reports_fetch_errors():
198+
provider = FakeProvider({})
199+
provider.get_repo_file_content = Mock(side_effect=Exception("temporary outage"))
200+
201+
files, had_fetch_error = repo_context._load_repo_context_files(provider, ["AGENTS.md"])
202+
203+
assert files == {}
204+
assert had_fetch_error is True
205+
206+
155207
def test_build_repo_context_process_cache_invalidates_when_config_changes(repo_context_settings):
156208
repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", ["AGENTS.md"])
157209
repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500)

0 commit comments

Comments
 (0)