diff --git a/pr_agent/git_providers/azuredevops_provider.py b/pr_agent/git_providers/azuredevops_provider.py index b9d2f3990c..dd7304e7c1 100644 --- a/pr_agent/git_providers/azuredevops_provider.py +++ b/pr_agent/git_providers/azuredevops_provider.py @@ -174,6 +174,29 @@ 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: + # Use the source commit (PR head), not the merge-preview commit, + # so metadata files reflect the branch under review + source_commit = self.pr.last_merge_source_commit + version = GitVersionDescriptor( + version=source_commit.commit_id, version_type="commit" + ) if source_commit else None + 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, + version_descriptor=version, + ) + content = list(contents)[0] + return content.decode("utf-8") if isinstance(content, bytes) else content + except Exception as e: + get_logger().debug(f"Failed to get repo file '{file_path}': {e}") + return "" + def get_files(self): files = [] for i in self.azure_devops_client.get_pull_request_commits( diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index 754cf81bf4..4fb866bc0f 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -89,6 +89,23 @@ 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 "" + response.raise_for_status() + return response.text + except requests.exceptions.HTTPError as e: + get_logger().warning(f"Failed to get repo file '{file_path}': {e}") + return "" + except requests.exceptions.ConnectionError as e: + get_logger().warning(f"Connection error getting repo file '{file_path}': {e}") + 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) diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index c929221af9..38c817aec0 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -116,6 +116,19 @@ 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: + head_sha = self.pr.fromRef['latestCommit'] + content = self.get_file(file_path, head_sha) + return content.decode("utf-8") if isinstance(content, bytes) else (content or "") + except HTTPError as e: + if e.response.status_code != 404: + get_logger().error(f"Failed to load {file_path} file, error: {e}") + return "" + except Exception as e: + get_logger().error(f"Failed to load {file_path} file, error: {e}") + return "" + def get_pr_id(self): return self.pr_num diff --git a/pr_agent/git_providers/codecommit_provider.py b/pr_agent/git_providers/codecommit_provider.py index c4f1ed7bf9..d63e0d1cd1 100644 --- a/pr_agent/git_providers/codecommit_provider.py +++ b/pr_agent/git_providers/codecommit_provider.py @@ -299,6 +299,14 @@ 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 ValueError as e: + get_logger().debug(f"Failed to get repo file '{file_path}': {e}") + 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 diff --git a/pr_agent/git_providers/gerrit_provider.py b/pr_agent/git_providers/gerrit_provider.py index ced150c915..fc03334cff 100644 --- a/pr_agent/git_providers/gerrit_provider.py +++ b/pr_agent/git_providers/gerrit_provider.py @@ -231,6 +231,13 @@ def get_repo_settings(self): except OSError: return b"" + def get_repo_file(self, file_path: str) -> str: + try: + blob = self.repo.head.commit.tree[file_path] + return blob.data_stream.read().decode('utf-8', errors='replace') + except (KeyError, ValueError): + return "" + def get_diff_files(self) -> list[FilePatchInfo]: diffs = self.repo.head.commit.diff( self.repo.head.commit.parents[0], # previous commit diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index 631e189c04..58975ec85f 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -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 "" diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index 89a6248e9b..559d98dfe2 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -623,6 +623,24 @@ 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 ApiException as e: + if e.status != 404: + self.logger.warning(f"Failed to get repo file '{file_path}': {e}") + return "" + except Exception as e: + self.logger.debug(f"Failed to get repo file '{file_path}': {e}") + return "" + def get_user_id(self) -> str: """Get the ID of the authenticated user""" return f"{self.pr.user.id}" if self.pr else "" diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index fa52b7dc05..04024f32af 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -740,6 +740,19 @@ 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 GithubException as e: + if e.status != 404: + get_logger().warning(f"Failed to get repo file '{file_path}': {e}") + return "" + except Exception as e: + get_logger().debug(f"Failed to get repo file '{file_path}': {e}") + return "" + def get_workspace_name(self): return self.repo.split('/')[0] diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index 7f5937343a..5fcaffd539 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -797,6 +797,19 @@ 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 GitlabGetError: + # File not found or not accessible — expected when the metadata file doesn't exist + return "" + except Exception as e: + get_logger().debug(f"Failed to get repo file '{file_path}': {e}") + return "" + def get_workspace_name(self): return self.id_project.split('/')[0] diff --git a/pr_agent/git_providers/utils.py b/pr_agent/git_providers/utils.py index 1e64b9578d..0ac293a7d4 100644 --- a/pr_agent/git_providers/utils.py +++ b/pr_agent/git_providers/utils.py @@ -1,7 +1,9 @@ import copy import os +import posixpath import tempfile import traceback +import urllib.parse from dynaconf import Dynaconf from starlette_context import context @@ -10,6 +12,42 @@ from pr_agent.git_providers import get_git_provider_with_context from pr_agent.log import get_logger +# Baseline extra_instructions values captured before the first metadata injection. +# Used in non-context runtimes (CLI, polling) to restore clean state between PRs, +# preventing metadata from one PR leaking into the next. +_extra_instructions_baseline: dict[str, str] = {} + +def _is_safe_repo_file_path(file_path: str) -> bool: + """ + Validate that a file path is safe to read from a repository root. + Rejects absolute paths, paths with '..' traversal components, backslashes, + and percent-encoded bypass attempts. + """ + if not file_path or not file_path.strip(): + return False + # Decode percent-encoded sequences (e.g. %2e%2e/) before validation to prevent bypass + file_path = urllib.parse.unquote(file_path) + # Reject paths that still contain '%' after decoding (double-encoding, ambiguous encodings) + if "%" in file_path: + return False + # Reject absolute paths (Unix and Windows-style) + if os.path.isabs(file_path) or file_path.startswith("/") or file_path.startswith("\\"): + return False + if len(file_path) >= 2 and file_path[1] == ":": # e.g. C:\... + return False + # Reject backslashes (non-standard on most git providers, potential traversal vector) + if "\\" in file_path: + return False + # Reject any ".." path segment to prevent directory traversal + segments = file_path.replace("\\", "/").split("/") + if ".." in segments: + return False + # Normalize and reject any ".." components as a defense-in-depth check + normalized = posixpath.normpath(file_path) + if normalized.startswith("..") or "/.." in normalized: + return False + return True + def apply_repo_settings(pr_url): os.environ["AUTO_CAST_FOR_DYNACONF"] = "false" @@ -85,6 +123,88 @@ 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 + # + # Guard: apply_repo_settings() can be called multiple times per request (e.g. once in the + # server handler and again inside PRAgent.handle_request). The TOML settings are idempotent + # (set/overwrite), but metadata is *appended* to extra_instructions, so we must skip on + # repeated calls to avoid duplicating content in prompts. + # In server mode we use the Starlette request context for a per-request flag. In CLI/polling + # mode (no context), we restore extra_instructions to the pre-metadata baseline before each + # application, preventing metadata from one PR leaking into the next. + repo_metadata_applied = False + try: + repo_metadata_applied = context.get("repo_metadata_applied", False) + except Exception: + pass + if not repo_metadata_applied and get_settings().config.get("add_repo_metadata", False): + try: + tool_sections = [ + "pr_reviewer", + "pr_description", + "pr_code_suggestions", + "pr_add_docs", + "pr_update_changelog", + "pr_test", + "pr_improve_component", + ] + + # In non-context runtimes (CLI, polling), restore extra_instructions to their + # pre-metadata baseline so metadata from a previous PR doesn't persist. + global _extra_instructions_baseline + is_context_mode = False + try: + is_context_mode = context.exists() + except Exception: + pass + if not is_context_mode: + if _extra_instructions_baseline: + # Restore baseline before applying this PR's metadata + for section, baseline_value in _extra_instructions_baseline.items(): + get_settings().set(f"{section}.extra_instructions", baseline_value) + else: + # First run: capture the current values as the baseline + for section in tool_sections: + section_obj = get_settings().get(section, None) + if section_obj is not None and hasattr(section_obj, "extra_instructions"): + _extra_instructions_baseline[section] = section_obj.extra_instructions or "" + + 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: + if not _is_safe_repo_file_path(file_name): + get_logger().warning(f"Skipping unsafe metadata file path: '{file_name}'") + continue + 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}") + + # Append combined metadata to extra_instructions for every tool that supports it. + if metadata_content_parts: + combined_metadata = "\n\n".join(metadata_content_parts) + 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) + # Mark as applied for this request (server mode only) + try: + context["repo_metadata_applied"] = True + except Exception: + pass + except Exception as e: + get_logger().debug(f"Failed to load repository metadata files: {e}") + # enable switching models with a short definition if get_settings().config.model.lower() == 'claude-3-5-sonnet': set_claude_model() diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 16ffbcae2a..8cf441ba3c 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -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 = [] diff --git a/tests/unittest/test_repo_metadata.py b/tests/unittest/test_repo_metadata.py new file mode 100644 index 0000000000..766d870822 --- /dev/null +++ b/tests/unittest/test_repo_metadata.py @@ -0,0 +1,261 @@ +""" +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 import utils as git_utils +from pr_agent.git_providers.utils import _is_safe_repo_file_path, 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_settings(): + """Snapshot and restore all settings modified by tests to avoid cross-test leakage.""" + tool_sections = [ + "pr_reviewer", "pr_description", "pr_code_suggestions", + "pr_add_docs", "pr_update_changelog", "pr_test", "pr_improve_component", + ] + original_extra = {} + for section in tool_sections: + section_obj = get_settings().get(section, None) + if section_obj is not None: + original_extra[section] = getattr(section_obj, 'extra_instructions', "") + + original_add_repo_metadata = get_settings().config.get("add_repo_metadata", False) + original_file_list = get_settings().config.get("add_repo_metadata_file_list", + ["AGENTS.md", "QODO.md", "CLAUDE.md"]) + original_baseline = git_utils._extra_instructions_baseline.copy() + + yield + + for section, value in original_extra.items(): + get_settings().set(f"{section}.extra_instructions", value) + get_settings().set("config.add_repo_metadata", original_add_repo_metadata) + get_settings().set("config.add_repo_metadata_file_list", original_file_list) + git_utils._extra_instructions_baseline.clear() + git_utils._extra_instructions_baseline.update(original_baseline) + + +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 + + +class TestRepoFilePathValidation: + """Tests for _is_safe_repo_file_path to prevent directory traversal attacks.""" + + @pytest.mark.parametrize("path", [ + "AGENTS.md", + "CLAUDE.md", + "docs/QODO.md", + "some-file.txt", + ]) + def test_safe_paths_accepted(self, path): + assert _is_safe_repo_file_path(path) is True + + @pytest.mark.parametrize("path", [ + "../etc/passwd", + "../../secrets.txt", + "foo/../../etc/shadow", + "/etc/passwd", + "/absolute/path.md", + "C:\\Windows\\system32\\config", + "foo\\..\\bar", + "", + " ", + "\\leading-backslash", + # Percent-encoded traversal attempts + "%2e%2e/etc/passwd", + "%2e%2e%2fetc%2fpasswd", + "foo/%2e%2e/%2e%2e/etc/shadow", + "%2F etc/passwd", + # Double-encoded (still contains % after first decode) + "%252e%252e/secrets", + ]) + def test_unsafe_paths_rejected(self, path): + assert _is_safe_repo_file_path(path) is False + + def test_traversal_path_not_loaded(self, monkeypatch): + """A traversal path in add_repo_metadata_file_list must not reach get_repo_file.""" + calls = [] + + class SpyGitProvider: + def get_repo_settings(self): + return "" + def get_repo_file(self, file_path: str) -> str: + calls.append(file_path) + return "should not be used" + + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda pr_url: SpyGitProvider(), + ) + get_settings().set("config.add_repo_metadata", True) + get_settings().set("config.add_repo_metadata_file_list", + ["../secrets.txt", "/etc/passwd"]) + + apply_repo_settings("https://example.com/pr/1") + + # Neither unsafe path should have been forwarded to the provider + assert calls == [] + assert not (get_settings().pr_reviewer.extra_instructions or "") + + def test_repeated_calls_do_not_duplicate_metadata(self, monkeypatch): + """Calling apply_repo_settings() twice must not append metadata twice.""" + provider = FakeGitProvider(repo_files={"AGENTS.md": "Do not duplicate me"}) + 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") + apply_repo_settings("https://example.com/pr/1") + + instructions = get_settings().pr_reviewer.extra_instructions + assert instructions.count("Do not duplicate me") == 1 + + def test_cross_pr_metadata_does_not_accumulate(self, monkeypatch): + """In non-context mode (CLI/polling), metadata from PR A must not leak into PR B.""" + # Simulate two different PRs with different metadata files + repo_files_by_url = { + "https://example.com/pr/1": {"AGENTS.md": "Instructions for repo A"}, + "https://example.com/pr/2": {"AGENTS.md": "Instructions for repo B"}, + } + + def fake_provider(pr_url): + return FakeGitProvider(repo_files=repo_files_by_url[pr_url]) + + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + fake_provider, + ) + get_settings().set("config.add_repo_metadata", True) + get_settings().set("config.add_repo_metadata_file_list", ["AGENTS.md"]) + + # Process PR A + apply_repo_settings("https://example.com/pr/1") + instructions = get_settings().pr_reviewer.extra_instructions + assert "Instructions for repo A" in instructions + + # Process PR B — must replace, not accumulate + apply_repo_settings("https://example.com/pr/2") + instructions = get_settings().pr_reviewer.extra_instructions + assert "Instructions for repo B" in instructions + assert "Instructions for repo A" not in instructions