From f7b1672982b7f552d8015fffc8b842dbb283f44f Mon Sep 17 00:00:00 2001 From: Hank <3490170+Hank076@users.noreply.github.com> Date: Sat, 14 Mar 2026 06:25:40 +0800 Subject: [PATCH 1/9] fix(settings): restore missing large PR handling config for pr_description (#2234) * fix(settings): restore missing large PR handling config for pr_description The `enable_large_pr_handling`, `max_ai_calls`, and `async_ai_calls` keys were removed from the `[pr_description]` section in `settings/configuration.toml`, but `pr_description.py` still accesses `get_settings().pr_description.enable_large_pr_handling` at runtime. When the key is absent, an exception is raised inside `_prepare_prediction()` before `get_pr_diff` is even called. `retry_with_fallback_models()` catches it silently and logs "Failed to generate prediction", causing all configured models to exhaust in milliseconds with no actual API call ever made. Fixes #2230 * fix(settings): restore inline_file_summary config for pr_description `inline_file_summary` was missing from the `[pr_description]` section in `settings/configuration.toml`. `pr_description.py:130` accesses `get_settings().pr_description.inline_file_summary` at runtime, and the missing key causes an exception that silently fails the describe command. Fixes #2230 * fix(settings): remove Pro-only label from large PR handling config --------- Co-authored-by: Hank --- pr_agent/settings/configuration.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 12d8b5d0ce..bc65af6c0a 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -107,10 +107,14 @@ publish_description_as_comment_persistent=true enable_semantic_files_types=true collapsible_file_list='adaptive' # true, false, 'adaptive' collapsible_file_list_threshold=6 +inline_file_summary=false # false, true, 'table' # markers use_description_markers=false include_generated_by_header=true #custom_labels = ['Bug fix', 'Tests', 'Bug fix with tests', 'Enhancement', 'Documentation', 'Other'] +enable_large_pr_handling=true +max_ai_calls=4 +async_ai_calls=true [pr_questions] # /ask # enable_help_text=false From 6a27a15b24c04e95f6850f3009bd24124e449ffc Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sat, 14 Mar 2026 20:28:53 +0800 Subject: [PATCH 2/9] feat: update default model to gpt-5.4-2026-03-05 (#2254) --- pr_agent/settings/configuration.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index bc65af6c0a..4799c5baaa 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -4,7 +4,7 @@ [config] # models -model="gpt-5.2-2025-12-11" +model="gpt-5.4-2026-03-05" fallback_models=["o4-mini"] #model_reasoning="o4-mini" # dedicated reasoning model for self-reflection #model_weak="gpt-4o" # optional, a weaker model to use for some easier tasks From 0737f844b48ce4c2945c35a0cabc6c84c6a8f43d Mon Sep 17 00:00:00 2001 From: Patrick Decat Date: Sat, 14 Mar 2026 14:52:31 +0100 Subject: [PATCH 3/9] fix: restore Dynaconf fresh vars support (#2104) --- pr_agent/custom_merge_loader.py | 4 +- .../unittest/test_fresh_vars_functionality.py | 388 ++++++++++++++++++ 2 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 tests/unittest/test_fresh_vars_functionality.py diff --git a/pr_agent/custom_merge_loader.py b/pr_agent/custom_merge_loader.py index 8aa29e8dd3..75b07a7718 100644 --- a/pr_agent/custom_merge_loader.py +++ b/pr_agent/custom_merge_loader.py @@ -12,6 +12,7 @@ def load(obj, env=None, silent=True, key=None, filename=None): - Replaces list and dict fields instead of appending/updating (non-default Dynaconf behavior). - Enforces several security checks (e.g., disallows includes/preloads and enforces .toml files). - Supports optional single-key loading. + - Supports Dynaconf's fresh_vars feature for dynamic reloading. Args: obj: The Dynaconf settings instance to update. env: The current environment name (upper case). Defaults to 'DEVELOPMENT'. Note: currently unused. @@ -93,7 +94,8 @@ def load(obj, env=None, silent=True, key=None, filename=None): # Update the settings object for k, v in accumulated_data.items(): - if key is None or key == k: + # For fresh_vars support: key parameter is uppercase, but accumulated_data keys are lowercase + if key is None or key.upper() == k.upper(): obj.set(k, v) def validate_file_security(file_data, filename): diff --git a/tests/unittest/test_fresh_vars_functionality.py b/tests/unittest/test_fresh_vars_functionality.py new file mode 100644 index 0000000000..fe2cfa15f1 --- /dev/null +++ b/tests/unittest/test_fresh_vars_functionality.py @@ -0,0 +1,388 @@ +""" +Comprehensive unit tests for Dynaconf fresh_vars functionality. + +These tests verify that the fresh_vars feature works correctly with the custom_merge_loader, +particularly for the GitLab credentials use case where values should be reloaded from disk +on each access rather than being cached. + +The tests are designed to detect if fresh_vars is broken due to custom loader changes, +such as those introduced in https://github.com/qodo-ai/pr-agent/pull/2087. +""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest +from dynaconf import Dynaconf + +# Import get_settings at module level to complete the import chain and avoid circular import issues +# This ensures pr_agent.config_loader is fully loaded before custom_merge_loader is used in tests +from pr_agent.config_loader import get_settings # noqa: F401 + + +# Module-level helper function +def create_dynaconf_with_custom_loader(temp_dir, secrets_file): + """ + Create a Dynaconf instance matching the production configuration. + + This mimics the config_loader.py setup with: + - core_loaders disabled + - custom_merge_loader and env_loader enabled + - merge_enabled = True + + Note: fresh_vars should be configured via FRESH_VARS_FOR_DYNACONF environment variable, + which is the only way to configure it in pr-agent. + + Args: + temp_dir: Temporary directory path + secrets_file: Path to secrets file + + Returns: + Dynaconf instance configured like production + """ + return Dynaconf( + core_loaders=[], + loaders=["pr_agent.custom_merge_loader", "dynaconf.loaders.env_loader"], + root_path=temp_dir, + merge_enabled=True, + envvar_prefix=False, + load_dotenv=False, + settings_files=[str(secrets_file)], + ) + + +class TestFreshVarsGitLabScenario: + """ + Test fresh_vars functionality for the GitLab credentials use case. + + This class tests the specific scenario where: + - FRESH_VARS_FOR_DYNACONF='["GITLAB"]' is set + - .secrets.toml contains gitlab.personal_access_token and gitlab.shared_secret + - Values should be reloaded from disk on each access (not cached) + """ + + def setup_method(self): + """Set up temporary directory and files for each test.""" + self.temp_dir = tempfile.mkdtemp() + self.secrets_file = Path(self.temp_dir) / ".secrets.toml" + + def teardown_method(self): + """Clean up temporary files after each test.""" + import shutil + + if hasattr(self, "temp_dir") and Path(self.temp_dir).exists(): + shutil.rmtree(self.temp_dir) + + def create_secrets_toml(self, personal_access_token="initial_token", shared_secret="initial_secret"): + """ + Create a .secrets.toml file with GitLab credentials. + + Args: + personal_access_token: The GitLab personal access token value + shared_secret: The GitLab shared secret value + """ + content = f"""[gitlab] +personal_access_token = "{personal_access_token}" +shared_secret = "{shared_secret}" +""" + self.secrets_file.write_text(content) + + def test_gitlab_personal_access_token_reload(self): + """ + Test that gitlab.personal_access_token is reloaded when marked as fresh. + + This is the critical test for the user's use case. It verifies that: + 1. Initial value is loaded correctly + 2. After modifying the file, the new value is returned (not cached) + 3. This works with the custom_merge_loader + """ + # Create initial secrets file + self.create_secrets_toml(personal_access_token="token_v1", shared_secret="secret_v1") + + # Set FRESH_VARS_FOR_DYNACONF environment variable (the only way to configure fresh_vars in pr-agent) + with patch.dict(os.environ, {"FRESH_VARS_FOR_DYNACONF": '["GITLAB"]'}): + # Create Dynaconf with GITLAB marked as fresh via env var + settings = create_dynaconf_with_custom_loader(self.temp_dir, self.secrets_file) + + # First access - should return initial value + first_token = settings.GITLAB.PERSONAL_ACCESS_TOKEN + assert first_token == "token_v1", "Initial personal_access_token should be 'token_v1'" + + # Modify the secrets file + self.create_secrets_toml(personal_access_token="token_v2_updated", shared_secret="secret_v1") + + # Second access - should return NEW value (not cached) + second_token = settings.GITLAB.PERSONAL_ACCESS_TOKEN + assert second_token == "token_v2_updated", ( + "After file modification, personal_access_token should be reloaded to 'token_v2_updated'" + ) + + # Verify the values are different (fresh_vars working) + assert first_token != second_token, "fresh_vars should cause values to be reloaded, not cached" + + def test_gitlab_multiple_fields_reload(self): + """ + Test that both gitlab fields reload together when GITLAB is marked as fresh. + + This verifies that fresh_vars works correctly when multiple fields + in the same section are modified simultaneously. + """ + # Create initial secrets file + self.create_secrets_toml(personal_access_token="token_v1", shared_secret="secret_v1") + + # Set FRESH_VARS_FOR_DYNACONF environment variable + with patch.dict(os.environ, {"FRESH_VARS_FOR_DYNACONF": '["GITLAB"]'}): + # Create Dynaconf with GITLAB marked as fresh via env var + settings = create_dynaconf_with_custom_loader(self.temp_dir, self.secrets_file) + + # First access - both fields + first_token = settings.GITLAB.PERSONAL_ACCESS_TOKEN + first_secret = settings.GITLAB.SHARED_SECRET + assert first_token == "token_v1" + assert first_secret == "secret_v1" + + # Modify both fields in the secrets file + self.create_secrets_toml( + personal_access_token="token_v2_both_updated", shared_secret="secret_v2_both_updated" + ) + + # Second access - both fields should be updated + second_token = settings.GITLAB.PERSONAL_ACCESS_TOKEN + second_secret = settings.GITLAB.SHARED_SECRET + + assert second_token == "token_v2_both_updated", "personal_access_token should be reloaded" + assert second_secret == "secret_v2_both_updated", "shared_secret should be reloaded" + + # Verify both fields were reloaded + assert first_token != second_token, "personal_access_token should not be cached" + assert first_secret != second_secret, "shared_secret should not be cached" + + +class TestFreshVarsCustomLoaderIntegration: + """ + Test fresh_vars integration with custom_merge_loader. + + These tests verify that fresh_vars works correctly when using the + custom_merge_loader instead of Dynaconf's default core loaders. + """ + + def setup_method(self): + """Set up temporary directory and files for each test.""" + self.temp_dir = tempfile.mkdtemp() + self.secrets_file = Path(self.temp_dir) / ".secrets.toml" + + def teardown_method(self): + """Clean up temporary files after each test.""" + import shutil + + if hasattr(self, "temp_dir") and Path(self.temp_dir).exists(): + shutil.rmtree(self.temp_dir) + + def create_secrets_toml(self, personal_access_token="initial_token", shared_secret="initial_secret"): + """Create a .secrets.toml file with GitLab credentials.""" + content = f"""[gitlab] +personal_access_token = "{personal_access_token}" +shared_secret = "{shared_secret}" +""" + self.secrets_file.write_text(content) + + def test_fresh_vars_without_core_loaders(self): + """ + Critical test: Verify fresh_vars works when core_loaders are disabled. + + This test detects if the bug exists where fresh_vars stops working + when core_loaders=[] is set. This is the key issue that may have been + introduced by the custom_merge_loader changes. + + Expected behavior: + - If fresh_vars works: second_value != first_value + - If fresh_vars is broken: second_value == first_value (cached) + """ + # Create initial secrets file + self.create_secrets_toml(personal_access_token="token_before_bug_test") + + # Set FRESH_VARS_FOR_DYNACONF environment variable + with patch.dict(os.environ, {"FRESH_VARS_FOR_DYNACONF": '["GITLAB"]'}): + # Create Dynaconf WITHOUT core loaders but WITH fresh_vars via env var + settings = create_dynaconf_with_custom_loader(self.temp_dir, self.secrets_file) + + # First access + first_value = settings.GITLAB.PERSONAL_ACCESS_TOKEN + assert first_value == "token_before_bug_test", "Initial value should be loaded correctly" + + # Modify the file + self.create_secrets_toml(personal_access_token="token_after_bug_test") + + # Second access - THIS IS THE CRITICAL CHECK + second_value = settings.GITLAB.PERSONAL_ACCESS_TOKEN + + # If this assertion fails, fresh_vars is broken with custom_merge_loader + assert second_value == "token_after_bug_test", ( + "CRITICAL: fresh_vars should reload the value even with core_loaders=[]" + ) + + assert first_value != second_value, "CRITICAL: Values should be different, indicating fresh_vars is working" + + def test_custom_loader_respects_fresh_vars(self): + """ + Test that custom_merge_loader respects the fresh_vars configuration. + + Verifies that when a section is marked as fresh, the custom loader + doesn't cache values from that section. + """ + # Create initial secrets file with multiple sections + content = """[gitlab] +personal_access_token = "gitlab_token_v1" + +[github] +user_token = "github_token_v1" +""" + self.secrets_file.write_text(content) + + # Set FRESH_VARS_FOR_DYNACONF environment variable (only GITLAB) + with patch.dict(os.environ, {"FRESH_VARS_FOR_DYNACONF": '["GITLAB"]'}): + # Create Dynaconf with only GITLAB marked as fresh via env var + settings = create_dynaconf_with_custom_loader(self.temp_dir, self.secrets_file) + + # Access both sections + gitlab_token_1 = settings.GITLAB.PERSONAL_ACCESS_TOKEN + github_token_1 = settings.GITHUB.USER_TOKEN + + # Modify both sections + content = """[gitlab] +personal_access_token = "gitlab_token_v2" + +[github] +user_token = "github_token_v2" +""" + self.secrets_file.write_text(content) + + # Access again + gitlab_token_2 = settings.GITLAB.PERSONAL_ACCESS_TOKEN + github_token_2 = settings.GITHUB.USER_TOKEN + + # GITLAB should be reloaded (marked as fresh) + assert gitlab_token_2 == "gitlab_token_v2", "GITLAB section should be reloaded (marked as fresh)" + assert gitlab_token_1 != gitlab_token_2, "GITLAB values should not be cached" + + # GITHUB should be cached (not marked as fresh) + assert github_token_2 == "github_token_v1", "GITHUB section should be cached (not marked as fresh)" + assert github_token_1 == github_token_2, "GITHUB values should be cached" + + +class TestFreshVarsBasicFunctionality: + """ + Test basic fresh_vars functionality and edge cases. + + These tests verify fundamental fresh_vars behavior and ensure + the feature works as expected in various scenarios. + """ + + def setup_method(self): + """Set up temporary directory and files for each test.""" + self.temp_dir = tempfile.mkdtemp() + self.secrets_file = Path(self.temp_dir) / ".secrets.toml" + + def teardown_method(self): + """Clean up temporary files after each test.""" + import shutil + + if hasattr(self, "temp_dir") and Path(self.temp_dir).exists(): + shutil.rmtree(self.temp_dir) + + def create_secrets_toml(self, personal_access_token="initial_token"): + """Create a .secrets.toml file with GitLab credentials.""" + content = f"""[gitlab] +personal_access_token = "{personal_access_token}" +""" + self.secrets_file.write_text(content) + + def test_gitlab_credentials_not_cached_when_fresh(self): + """ + Test that GitLab credentials are not cached when marked as fresh. + + This verifies the core requirement: when GITLAB is in fresh_vars, + accessing the credentials multiple times should reload from disk + each time, not return a cached value. + """ + # Create initial secrets file + self.create_secrets_toml(personal_access_token="no_cache_v1") + + # Set FRESH_VARS_FOR_DYNACONF environment variable + with patch.dict(os.environ, {"FRESH_VARS_FOR_DYNACONF": '["GITLAB"]'}): + # Create Dynaconf with GITLAB marked as fresh via env var + settings = create_dynaconf_with_custom_loader(self.temp_dir, self.secrets_file) + + # Access the token multiple times before modification + access_1 = settings.GITLAB.PERSONAL_ACCESS_TOKEN + access_2 = settings.GITLAB.PERSONAL_ACCESS_TOKEN + access_3 = settings.GITLAB.PERSONAL_ACCESS_TOKEN + + # All should return the same value (file hasn't changed) + assert access_1 == access_2 == access_3 == "no_cache_v1", ( + "Multiple accesses before modification should return same value" + ) + + # Modify the file + self.create_secrets_toml(personal_access_token="no_cache_v2") + + # Access again - should get new value immediately + access_4 = settings.GITLAB.PERSONAL_ACCESS_TOKEN + assert access_4 == "no_cache_v2", "First access after modification should return new value" + + # Verify no caching occurred + assert access_1 != access_4, "Value should change after file modification (no caching)" + + # Modify again + self.create_secrets_toml(personal_access_token="no_cache_v3") + + # Access again - should get newest value + access_5 = settings.GITLAB.PERSONAL_ACCESS_TOKEN + assert access_5 == "no_cache_v3", "Second modification should also be detected" + + # Verify the progression + assert access_1 != access_4 != access_5, "Each modification should result in a different value (no caching)" + + def test_fresh_vars_works_with_default_loaders(self): + """ + Test that fresh_vars works correctly with Dynaconf's default core loaders. + + This is a control test to prove that fresh_vars functionality works + as expected when using the standard Dynaconf configuration (with core_loaders). + This helps isolate the bug to the custom_merge_loader configuration. + """ + # Create initial secrets file + self.create_secrets_toml(personal_access_token="default_v1") + + # Create Dynaconf with DEFAULT loaders (not custom_merge_loader) + settings = Dynaconf( + # Use default core_loaders (don't disable them) + root_path=self.temp_dir, + merge_enabled=True, + envvar_prefix=False, + load_dotenv=False, + settings_files=[str(self.secrets_file)], + fresh_vars=["GITLAB"], + ) + + # First access + first_value = settings.GITLAB.PERSONAL_ACCESS_TOKEN + assert first_value == "default_v1" + + # Modify file + self.create_secrets_toml(personal_access_token="default_v2") + + # Second access - should be reloaded with default loaders + second_value = settings.GITLAB.PERSONAL_ACCESS_TOKEN + assert second_value == "default_v2", ( + "With default loaders, fresh_vars SHOULD work correctly. " + "If this test fails, the issue is not specific to custom_merge_loader." + ) + + assert first_value != second_value, "Values should be different when using default loaders with fresh_vars" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From aaf8fbe218362b29ac13f39e52f9dfdd4a895e65 Mon Sep 17 00:00:00 2001 From: Antoine Mahassadi <127432580+damnthonyy@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:07:43 +0100 Subject: [PATCH 4/9] feat: add branch name issue extraction feature (#2231) * feat: add branch name issue extraction feature * docs: clarify branch-name issue linking for GitHub only * refactor: update ticket extraction logic to support custom regex and improve error handling ( suggestions by qodo ) * fix : add suggestion by qodo Co-authored-by: Cursor cursoragent@cursor.com * Add validation for branch_issue_regex capturing groups with fallback to default pattern * Remove .coverage file and revert change configuration.toml --- .../core-abilities/fetching_ticket_context.md | 5 + pr_agent/settings/configuration.toml | 11 +- pr_agent/tools/ticket_pr_compliance_check.py | 60 +++++++++- .../test_extract_issue_from_branch.py | 112 ++++++++++++++++++ 4 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 tests/unittest/test_extract_issue_from_branch.py diff --git a/docs/docs/core-abilities/fetching_ticket_context.md b/docs/docs/core-abilities/fetching_ticket_context.md index 2c1dfd3a4e..de06ba2172 100644 --- a/docs/docs/core-abilities/fetching_ticket_context.md +++ b/docs/docs/core-abilities/fetching_ticket_context.md @@ -2,6 +2,9 @@ `Supported Git Platforms: GitHub, GitLab, Bitbucket` +!!! note "Branch-name issue linking: GitHub only (for now)" + Extracting issue links from the **branch name** (and the optional `branch_issue_regex` setting) is currently implemented for **GitHub only**. Support for GitLab, Bitbucket, and other platforms is planned for a later release. The GitHub flow was the most relevant to implement first; other providers will follow. + ## Overview PR-Agent streamlines code review workflows by seamlessly connecting with multiple ticket management systems. @@ -85,6 +88,8 @@ Examples of valid GitHub/Gitlab issue references: Branch names can also be used to link issues, for example: - `123-fix-bug` (where `123` is the issue number) +This branch-name detection applies **only when the git provider is GitHub**. Support for other platforms is planned for later. + Since PR-Agent is integrated with GitHub, it doesn't require any additional configuration to fetch GitHub issues. ## Jira Integration diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 4799c5baaa..27dfc061aa 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -61,6 +61,14 @@ reasoning_effort = "medium" # "low", "medium", "high" enable_claude_extended_thinking = false # Set to true to enable extended thinking feature extended_thinking_budget_tokens = 2048 extended_thinking_max_output_tokens = 4096 +# Extract issue number from PR source branch name (e.g. feature/1-auth-google -> issue #1). When true, branch-derived +# issue URLs are merged with tickets from the PR description for compliance. Set to false to restore description-only behaviour. +# Note: Branch-name extraction is GitHub-only for now; other providers planned for later. +extract_issue_from_branch = true +# Optional: custom regex with exactly one capturing group for the issue number (validated at runtime; falls back +# to default if missing). If empty, uses default pattern: first 1-6 digits at start of branch or after a slash, +# followed by hyphen or end (e.g. feature/1-test, 123-fix). GitHub only; other providers planned for later. +branch_issue_regex = "" [pr_reviewer] # /review # @@ -110,12 +118,11 @@ collapsible_file_list_threshold=6 inline_file_summary=false # false, true, 'table' # markers use_description_markers=false +enable_large_pr_handling=true include_generated_by_header=true #custom_labels = ['Bug fix', 'Tests', 'Bug fix with tests', 'Enhancement', 'Documentation', 'Other'] -enable_large_pr_handling=true max_ai_calls=4 async_ai_calls=true - [pr_questions] # /ask # enable_help_text=false use_conversation_history=true diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 523e21f921..6d25d76b19 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -10,6 +10,8 @@ GITHUB_TICKET_PATTERN = re.compile( r'(https://github[^/]+/[^/]+/[^/]+/issues/\d+)|(\b(\w+)/(\w+)#(\d+)\b)|(#\d+)' ) +# Option A: issue number at start of branch or after /, followed by - or end (e.g. feature/1-test-issue, 123-fix) +BRANCH_ISSUE_PATTERN = re.compile(r"(?:^|/)(\d{1,6})(?=-|$)") def find_jira_tickets(text): # Regular expression patterns for JIRA tickets @@ -63,13 +65,69 @@ def extract_ticket_links_from_pr_description(pr_description, repo_path, base_url return list(github_tickets) +def extract_ticket_links_from_branch_name(branch_name, repo_path, base_url_html="https://github.com"): + """ + Extract GitHub issue URLs from branch name. Numbers are matched at start of branch or after /, + followed by - or end (e.g. feature/1-test-issue -> #1). Respects extract_issue_from_branch + and optional branch_issue_regex (may be under [config] in TOML). + """ + if not branch_name or not repo_path: + return [] + if not isinstance(branch_name, str): + return [] + settings = get_settings() + if not settings.get("extract_issue_from_branch", settings.get("config.extract_issue_from_branch", True)): + return [] + github_tickets = set() + custom_regex_str = settings.get("branch_issue_regex") or settings.get("config.branch_issue_regex", "") or "" + if custom_regex_str: + try: + pattern = re.compile(custom_regex_str) + if pattern.groups < 1: + get_logger().error( + "branch_issue_regex must contain at least one capturing group for the issue number; using default pattern." + ) + pattern = BRANCH_ISSUE_PATTERN + except re.error as e: + get_logger().error(f"Invalid custom regex for branch issue extraction: {e}") + return [] + else: + pattern = BRANCH_ISSUE_PATTERN + for match in pattern.finditer(branch_name): + try: + issue_number = match.group(1) + except IndexError: + continue + if issue_number and issue_number.isdigit(): + github_tickets.add( + f"{base_url_html.strip('/')}/{repo_path}/issues/{issue_number}" + ) + return list(github_tickets) + async def extract_tickets(git_provider): MAX_TICKET_CHARACTERS = 10000 try: if isinstance(git_provider, GithubProvider): user_description = git_provider.get_user_description() - tickets = extract_ticket_links_from_pr_description(user_description, git_provider.repo, git_provider.base_url_html) + description_tickets = extract_ticket_links_from_pr_description( + user_description, git_provider.repo, git_provider.base_url_html + ) + branch_name = git_provider.get_pr_branch() + branch_tickets = extract_ticket_links_from_branch_name( + branch_name, git_provider.repo, git_provider.base_url_html + ) + seen = set() + merged = [] + for link in description_tickets + branch_tickets: + if link not in seen: + seen.add(link) + merged.append(link) + if len(merged) > 3: + get_logger().info(f"Too many tickets (description + branch): {len(merged)}") + tickets = merged[:3] + else: + tickets = merged tickets_content = [] if tickets: diff --git a/tests/unittest/test_extract_issue_from_branch.py b/tests/unittest/test_extract_issue_from_branch.py new file mode 100644 index 0000000000..6e957ba3f8 --- /dev/null +++ b/tests/unittest/test_extract_issue_from_branch.py @@ -0,0 +1,112 @@ +import pytest + +from pr_agent.tools.ticket_pr_compliance_check import extract_ticket_links_from_branch_name + + +class TestExtractTicketsLinkFromBranchName: + """Unit tests for branch-name issue extraction (option A: number at start of segment).""" + + def test_feature_slash_number_suffix(self): + """feature/1-test-issue -> issue #1""" + result = extract_ticket_links_from_branch_name( + "feature/1-test-issue", "org/repo", "https://github.com" + ) + assert result == ["https://github.com/org/repo/issues/1"] + + def test_fix_slash_number_suffix(self): + """fix/123-bug -> issue #123""" + result = extract_ticket_links_from_branch_name( + "fix/123-bug", "owner/repo", "https://github.com" + ) + assert result == ["https://github.com/owner/repo/issues/123"] + + def test_number_at_start_no_slash(self): + """123-fix -> issue #123""" + result = extract_ticket_links_from_branch_name( + "123-fix", "org/repo", "https://github.com" + ) + assert result == ["https://github.com/org/repo/issues/123"] + + def test_empty_branch_returns_empty(self): + """Empty branch name -> []""" + result = extract_ticket_links_from_branch_name("", "org/repo") + assert result == [] + + def test_none_branch_returns_empty(self): + """None branch name -> []""" + result = extract_ticket_links_from_branch_name(None, "org/repo") + assert result == [] + + def test_no_digits_in_segment_returns_empty(self): + """feature/no-issue -> []""" + result = extract_ticket_links_from_branch_name( + "feature/no-issue", "org/repo", "https://github.com" + ) + assert result == [] + + def test_base_url_no_trailing_slash(self): + """base_url_html without trailing slash is normalized""" + result = extract_ticket_links_from_branch_name( + "feature/1-test", "org/repo", "https://github.com/" + ) + assert result == ["https://github.com/org/repo/issues/1"] + + def test_disable_via_config_returns_empty(self, monkeypatch): + """When extract_issue_from_branch is False, return []""" + fake_settings = type("Settings", (), {})() + fake_settings.get = lambda key, default=None: ( + False if key in ("extract_issue_from_branch", "config.extract_issue_from_branch") else ( + "" if key in ("branch_issue_regex", "config.branch_issue_regex") else default + ) + ) + import pr_agent.tools.ticket_pr_compliance_check as m + monkeypatch.setattr(m, "get_settings", lambda: fake_settings) + result = extract_ticket_links_from_branch_name( + "feature/1-test", "org/repo", "https://github.com" + ) + assert result == [] + + def test_invalid_custom_regex_returns_empty(self, monkeypatch): + """When branch_issue_regex is invalid, log and return []""" + fake_settings = type("Settings", (), {})() + fake_settings.get = lambda key, default=None: ( + True if key in ("extract_issue_from_branch", "config.extract_issue_from_branch") else ( + "[" if key in ("branch_issue_regex", "config.branch_issue_regex") else default + ) + ) + import pr_agent.tools.ticket_pr_compliance_check as m + monkeypatch.setattr(m, "get_settings", lambda: fake_settings) + result = extract_ticket_links_from_branch_name( + "feature/1-test", "org/repo", "https://github.com" + ) + assert result == [] + + def test_custom_regex_without_capturing_group_falls_back_to_default(self, monkeypatch): + """When branch_issue_regex has no capturing group, fall back to default pattern (no crash).""" + fake_settings = type("Settings", (), {})() + fake_settings.get = lambda key, default=None: ( + True if key in ("extract_issue_from_branch", "config.extract_issue_from_branch") else ( + r"\d+" if key in ("branch_issue_regex", "config.branch_issue_regex") else default + ) + ) + import pr_agent.tools.ticket_pr_compliance_check as m + monkeypatch.setattr(m, "get_settings", lambda: fake_settings) + result = extract_ticket_links_from_branch_name( + "feature/1-test", "org/repo", "https://github.com" + ) + assert result == ["https://github.com/org/repo/issues/1"] + + def test_empty_repo_path_returns_empty(self): + """Empty repo_path -> [] (guard in function)""" + result = extract_ticket_links_from_branch_name("feature/1-test", "", "https://github.com") + assert result == [] + + def test_multiple_matches_deduplicated(self): + """Branch with multiple segments with numbers yields unique issue URLs""" + result = extract_ticket_links_from_branch_name( + "feature/1-test/2-other", "org/repo", "https://github.com" + ) + assert set(result) == { + "https://github.com/org/repo/issues/1", + "https://github.com/org/repo/issues/2", + } From 74c0062d9d6f7e49f25015ab4b210e7081a038c8 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:14:25 +0800 Subject: [PATCH 5/9] feat(models): add OpenAI's latest GPT-5.4-mini and GPT-5.4-nano model (#2266) * Add GPT-5.4-mini model support Add GPT-5.4-mini and its current snapshot to the model registry with the documented 400K context window. Align the tests with the existing GPT-5.4 model pattern while keeping the change limited to registry and test coverage. * Add GPT-5.4-nano model support Add GPT-5.4-nano and its current snapshot to the model registry with the documented 400K context window. Align the tests with the existing GPT-5.4 model pattern while keeping the change limited to registry and test coverage. --- pr_agent/algo/__init__.py | 4 +++ tests/unittest/test_get_max_tokens.py | 26 +++++++++++++++++++ .../unittest/test_litellm_reasoning_effort.py | 4 +++ 3 files changed, 34 insertions(+) diff --git a/pr_agent/algo/__init__.py b/pr_agent/algo/__init__.py index a31c95e0ce..3b2683613e 100644 --- a/pr_agent/algo/__init__.py +++ b/pr_agent/algo/__init__.py @@ -44,6 +44,10 @@ 'gpt-5.3-codex': 400000, # 400K, but may be limited by config.max_model_tokens 'gpt-5.4': 272000, # 272K safe default without opt-in 1M context parameters 'gpt-5.4-2026-03-05': 272000, # 272K safe default without opt-in 1M context parameters + 'gpt-5.4-mini': 400000, # 400K, but may be limited by config.max_model_tokens + 'gpt-5.4-mini-2026-03-17': 400000, # 400K, but may be limited by config.max_model_tokens + 'gpt-5.4-nano': 400000, # 400K, but may be limited by config.max_model_tokens + 'gpt-5.4-nano-2026-03-17': 400000, # 400K, but may be limited by config.max_model_tokens 'o1-mini': 128000, # 128K, but may be limited by config.max_model_tokens 'o1-mini-2024-09-12': 128000, # 128K, but may be limited by config.max_model_tokens 'o1-preview': 128000, # 128K, but may be limited by config.max_model_tokens diff --git a/tests/unittest/test_get_max_tokens.py b/tests/unittest/test_get_max_tokens.py index f6050812b1..7ad8d53bef 100644 --- a/tests/unittest/test_get_max_tokens.py +++ b/tests/unittest/test_get_max_tokens.py @@ -35,6 +35,32 @@ def test_gpt54_model_max_tokens(self, monkeypatch, model): assert get_max_tokens(model) == 272000 + @pytest.mark.parametrize("model", ["gpt-5.4-mini", "gpt-5.4-mini-2026-03-17"]) + def test_gpt54_mini_model_max_tokens(self, monkeypatch, model): + fake_settings = type('', (), { + 'config': type('', (), { + 'custom_model_max_tokens': 0, + 'max_model_tokens': 0 + })() + })() + + monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + + assert get_max_tokens(model) == 400000 + + @pytest.mark.parametrize("model", ["gpt-5.4-nano", "gpt-5.4-nano-2026-03-17"]) + def test_gpt54_nano_model_max_tokens(self, monkeypatch, model): + fake_settings = type('', (), { + 'config': type('', (), { + 'custom_model_max_tokens': 0, + 'max_model_tokens': 0 + })() + })() + + monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + + assert get_max_tokens(model) == 400000 + # Test situations where the model is not registered and exists as a custom model def test_model_has_custom(self, monkeypatch): fake_settings = type('', (), { diff --git a/tests/unittest/test_litellm_reasoning_effort.py b/tests/unittest/test_litellm_reasoning_effort.py index 4b7e25826f..30e7813c8f 100644 --- a/tests/unittest/test_litellm_reasoning_effort.py +++ b/tests/unittest/test_litellm_reasoning_effort.py @@ -311,6 +311,10 @@ async def test_gpt5_model_detection_various_versions(self, monkeypatch, mock_log "gpt-5-2025-08-07", "gpt-5.1", "gpt-5.4", + "gpt-5.4-nano", + "gpt-5.4-nano-2026-03-17", + "gpt-5.4-mini", + "gpt-5.4-mini-2026-03-17", "gpt-5.4-2026-03-05", "gpt-5-turbo", "gpt-5.1-codex", From f2f2397f226e8e84cd43058c6f2385c824af94b4 Mon Sep 17 00:00:00 2001 From: Ryno Date: Sat, 21 Mar 2026 23:16:46 +0200 Subject: [PATCH 6/9] feat(models): add support for gemini-3.1-flash-lite-preview (#2264) --- pr_agent/algo/__init__.py | 2 ++ tests/unittest/test_get_max_tokens.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/pr_agent/algo/__init__.py b/pr_agent/algo/__init__.py index 3b2683613e..c47a7fc5f0 100644 --- a/pr_agent/algo/__init__.py +++ b/pr_agent/algo/__init__.py @@ -98,6 +98,7 @@ 'vertex_ai/gemini-2.5-flash': 1048576, 'vertex_ai/gemini-3-flash-preview': 1048576, 'vertex_ai/gemini-3-pro-preview': 1048576, + 'vertex_ai/gemini-3.1-flash-lite-preview': 1048576, 'vertex_ai/gemini-3.1-pro-preview': 1048576, 'vertex_ai/gemma2': 8200, 'gemini/gemini-1.5-pro': 1048576, @@ -112,6 +113,7 @@ 'gemini/gemini-2.5-pro': 1048576, 'gemini/gemini-3-flash-preview': 1048576, 'gemini/gemini-3-pro-preview': 1048576, + 'gemini/gemini-3.1-flash-lite-preview': 1048576, 'gemini/gemini-3.1-pro-preview': 1048576, 'codechat-bison': 6144, 'codechat-bison-32k': 32000, diff --git a/tests/unittest/test_get_max_tokens.py b/tests/unittest/test_get_max_tokens.py index 7ad8d53bef..7a02fc8238 100644 --- a/tests/unittest/test_get_max_tokens.py +++ b/tests/unittest/test_get_max_tokens.py @@ -133,6 +133,8 @@ def test_model_max_tokens_with__limit(self, monkeypatch): "vertex_ai/gemini-3-pro-preview", "gemini/gemini-3.1-pro-preview", "vertex_ai/gemini-3.1-pro-preview", + "gemini/gemini-3.1-flash-lite-preview", + "vertex_ai/gemini-3.1-flash-lite-preview", ]) def test_gemini_3_and_3_1_pro_preview(self, monkeypatch, model): fake_settings = type("", (), { From ffab51f40f9dd2c4b2be6cf4b75b1ccca66e3b2e Mon Sep 17 00:00:00 2001 From: Napat Rungruangbangchan Date: Sun, 22 Mar 2026 04:17:56 +0700 Subject: [PATCH 7/9] docs: fix broken Get Started link and remove empty div (#2262) --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 489fbda779..ade64dcaae 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Try the free version on our website. -👉[Get Started Now](www.qodo.ai/get-started/) +👉[Get Started Now](https://www.qodo.ai/get-started/) PR-Agent is an open-source, AI-powered code review agent and a community-maintained legacy project of Qodo. It is distinct from Qodo’s primary AI code review offering, which provides a feature-rich, context-aware experience. Qodo now offers a free tier that integrates seamlessly with GitHub, GitLab, Bitbucket, and Azure DevOps for high-quality automated reviews. @@ -221,9 +221,6 @@ ___

-
- -

## Try It Now From f5cf45b64daf1d5333c0416affae2adb65c19ce3 Mon Sep 17 00:00:00 2001 From: locnt241 <73770977+ElliotNguyen68@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:25:11 +0700 Subject: [PATCH 8/9] feat(models): add max context window for gpt-5.3-chat model (#2280) Co-authored-by: Loc Nguyen --- pr_agent/algo/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pr_agent/algo/__init__.py b/pr_agent/algo/__init__.py index c47a7fc5f0..a035d08fe8 100644 --- a/pr_agent/algo/__init__.py +++ b/pr_agent/algo/__init__.py @@ -42,6 +42,7 @@ 'gpt-5.2-chat-latest': 128000, # 128K, but may be limited by config.max_model_tokens 'gpt-5.2-codex': 400000, # 400K, but may be limited by config.max_model_tokens 'gpt-5.3-codex': 400000, # 400K, but may be limited by config.max_model_tokens + 'gpt-5.3-chat': 128000, # 128K, but may be limited by config.max_model_tokens 'gpt-5.4': 272000, # 272K safe default without opt-in 1M context parameters 'gpt-5.4-2026-03-05': 272000, # 272K safe default without opt-in 1M context parameters 'gpt-5.4-mini': 400000, # 400K, but may be limited by config.max_model_tokens From 42d55d4182a4839820b11b6c4d06fce855970301 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:33:42 +0800 Subject: [PATCH 9/9] chore: refine PR reviewer prompt guidance (#2209) Tighten scope to issues introduced by the PR and clarify what to flag. Improve issue descriptions to be more concrete and actionable while allowing high-impact, lower-confidence risks when uncertainty is stated. --- docs/docs/tools/review.md | 4 ++-- pr_agent/settings/pr_reviewer_prompts.toml | 23 +++++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/docs/tools/review.md b/docs/docs/tools/review.md index a7b1a802ea..06b5e11f93 100644 --- a/docs/docs/tools/review.md +++ b/docs/docs/tools/review.md @@ -159,8 +159,8 @@ extra_instructions = "..." The `review` can tool automatically add labels to your Pull Requests: - - **`possible security issue`**: This label is applied if the tool detects a potential [security vulnerability](https://github.com/qodo-ai/pr-agent/blob/main/pr_agent/settings/pr_reviewer_prompts.toml#L121) in the PR's code. This feedback is controlled by the 'enable_review_labels_security' flag (default is true). - - **`review effort [x/5]`**: This label estimates the [effort](https://github.com/qodo-ai/pr-agent/blob/main/pr_agent/settings/pr_reviewer_prompts.toml#L105) required to review the PR on a relative scale of 1 to 5, where 'x' represents the assessed effort. This feedback is controlled by the 'enable_review_labels_effort' flag (default is true). + - **`possible security issue`**: This label is applied if the tool detects a potential [security vulnerability](https://github.com/qodo-ai/pr-agent/blob/main/pr_agent/settings/pr_reviewer_prompts.toml#L134) in the PR's code. This feedback is controlled by the 'enable_review_labels_security' flag (default is true). + - **`review effort [x/5]`**: This label estimates the [effort](https://github.com/qodo-ai/pr-agent/blob/main/pr_agent/settings/pr_reviewer_prompts.toml#L118) required to review the PR on a relative scale of 1 to 5, where 'x' represents the assessed effort. This feedback is controlled by the 'enable_review_labels_effort' flag (default is true). - **`ticket compliance`**: Adds a label indicating code compliance level ("Fully compliant" | "PR Code Verified" | "Partially compliant" | "Not compliant") to any GitHub/Jira/Linea ticket linked in the PR. Controlled by the 'require_ticket_labels' flag (default: false). If 'require_no_ticket_labels' is also enabled, PRs without ticket links will receive a "No ticket found" label. diff --git a/pr_agent/settings/pr_reviewer_prompts.toml b/pr_agent/settings/pr_reviewer_prompts.toml index 477f8553de..2f253199d8 100644 --- a/pr_agent/settings/pr_reviewer_prompts.toml +++ b/pr_agent/settings/pr_reviewer_prompts.toml @@ -1,7 +1,7 @@ [pr_review_prompt] system="""You are PR-Reviewer, a language model designed to review a Git Pull Request (PR). Your task is to provide constructive and concise feedback for the PR. -The review should focus on new code added in the PR code diff (lines starting with '+') +The review should focus on new code added in the PR code diff (lines starting with '+'), and only on issues introduced by this PR. The format we will use to present the PR code diff: @@ -38,8 +38,7 @@ __new hunk__ - In the format above, the diff is organized into separate '__new hunk__' and '__old hunk__' sections for each code chunk. '__new hunk__' contains the updated code, while '__old hunk__' shows the removed code. If no code was removed in a specific chunk, the __old hunk__ section will be omitted. - We also added line numbers for the '__new hunk__' code, to help you refer to the code lines in your suggestions. These line numbers are not part of the actual code, and should only be used for reference. -- Code lines are prefixed with symbols ('+', '-', ' '). The '+' symbol indicates new code added in the PR, the '-' symbol indicates code removed in the PR, and the ' ' symbol indicates unchanged code. \ - The review should address new code added in the PR code diff (lines starting with '+'). +- Code lines are prefixed with symbols ('+', '-', ' '). The '+' symbol indicates new code added in the PR, the '-' symbol indicates code removed in the PR, and the ' ' symbol indicates unchanged code. {%- if is_ai_metadata %} - If available, an AI-generated summary will appear and provide a high-level overview of the file changes. Note that this summary may not be fully accurate or complete. {%- endif %} @@ -47,6 +46,20 @@ __new hunk__ - Note that you only see changed code segments (diff hunks in a PR), not the entire codebase. Avoid suggestions that might duplicate existing functionality or questioning code elements (like variables declarations or import statements) that may be defined elsewhere in the codebase. - 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. +Determining what to flag: +- For clear bugs and security issues, be thorough. Do not skip a genuine problem just because the trigger scenario is narrow. +- For lower-severity concerns, be certain before flagging. If you cannot confidently explain why something is a problem with a concrete scenario, do not flag it. +- Each issue must be discrete and actionable, not a vague concern about the codebase in general. +- Do not speculate that a change might break other code unless you can identify the specific affected code path from the diff context. +- Do not flag intentional design choices or stylistic preferences unless they introduce a clear defect. +- When confidence is limited but the potential impact is high (e.g., data loss, security), report it with an explicit note on what remains uncertain. Otherwise, prefer not reporting over guessing. + +Constructing comments: +- Be direct about why something is a problem and the realistic scenario where it manifests. +- Communicate severity accurately. Do not overstate impact. If an issue only arises under specific inputs or environments, say so upfront. +- Keep each issue description concise. Write so the reader grasps the point immediately without close reading. +- Use a matter-of-fact, helpful tone. Avoid accusatory language, excessive praise, or filler phrases like 'Great job', 'Thanks for'. + {%- if extra_instructions %} @@ -68,7 +81,7 @@ class SubPR(BaseModel): class KeyIssuesComponentLink(BaseModel): relevant_file: str = Field(description="The full file path of the relevant file") issue_header: str = Field(description="One or two word title for the issue. For example: 'Possible Bug', etc.") - issue_content: str = Field(description="A short and concise summary of what should be further inspected and validated during the PR review process for this issue. Do not mention line numbers in this field.") + issue_content: str = Field(description="A short and concise description of the issue, why it matters, and the specific scenario or input that triggers it. Do not mention line numbers in this field.") start_line: int = Field(description="The start line that corresponds to this issue in the relevant file") end_line: int = Field(description="The end line that corresponds to this issue in the relevant file") @@ -116,7 +129,7 @@ class Review(BaseModel): {%- if question_str %} insights_from_user_answers: str = Field(description="shortly summarize the insights you gained from the user's answers to the questions") {%- endif %} - key_issues_to_review: List[KeyIssuesComponentLink] = Field("A short and diverse list (0-{{ num_max_findings }} issues) of high-priority bugs, problems or performance concerns introduced in the PR code, which the PR reviewer should further focus on and validate during the review process.") + key_issues_to_review: List[KeyIssuesComponentLink] = Field("A concise list (0-{{ num_max_findings }} issues) of bugs, security vulnerabilities, or significant performance concerns introduced in this PR. Only include issues you are confident about. If confidence is limited but the potential impact is high (e.g., data loss, security), you may include it only if you explicitly note what remains uncertain. Each issue must identify a concrete problem with a realistic trigger scenario. An empty list is acceptable if no clear issues are found.") {%- if require_security_review %} security_concerns: str = Field(description="Does this PR code introduce vulnerabilities such as exposure of sensitive information (e.g., API keys, secrets, passwords), or security concerns like SQL injection, XSS, CSRF, and others ? Answer 'No' (without explaining why) if there are no possible issues. If there are security concerns or issues, start your answer with a short header, such as: 'Sensitive information exposure: ...', 'SQL injection: ...', etc. Explain your answer. Be specific and give examples if possible") {%- endif %}