diff --git a/AUTO1_PATCHES.md b/AUTO1_PATCHES.md index f03adbf9f8..e787da4936 100644 --- a/AUTO1_PATCHES.md +++ b/AUTO1_PATCHES.md @@ -7,8 +7,8 @@ Keep this list minimal to ease upstream rebases. - Upstream repo: qodo-ai/pr-agent - Upstream tag: main -- Upstream commit: 1b0609a013f53694c36d457149bde70abf50c048 -- Synced on: 2026-02-23 +- Upstream commit: 42d55d4182a4839820b11b6c4d06fce855970301 +- Synced on: 2026-03-23 ## Local patches 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 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/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/algo/__init__.py b/pr_agent/algo/__init__.py index a31c95e0ce..a035d08fe8 100644 --- a/pr_agent/algo/__init__.py +++ b/pr_agent/algo/__init__.py @@ -42,8 +42,13 @@ '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 + '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 @@ -94,6 +99,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, @@ -108,6 +114,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/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/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 05190b9c42..15acd7b803 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 @@ -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 # @@ -113,11 +121,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 +enable_large_pr_handling=true include_generated_by_header=true #custom_labels = ['Bug fix', 'Tests', 'Bug fix with tests', 'Enhancement', 'Documentation', 'Other'] - +max_ai_calls=4 +async_ai_calls=true [pr_questions] # /ask # enable_help_text=false use_conversation_history=true diff --git a/pr_agent/settings/pr_reviewer_prompts.toml b/pr_agent/settings/pr_reviewer_prompts.toml index 95e473ac18..67f433a5bb 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 %} @@ -54,6 +53,20 @@ __new hunk__ - Set `evidence_type` to `diff` when changed lines directly support the issue, `ticket` for a clear ticket mismatch, or `inferred` when the issue depends on indirect clues. {%- endif %} +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 %} @@ -75,7 +88,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.") {%- if findings_metadata %} confidence: str = Field(description="Your confidence in this issue based only on the visible evidence. Allowed values: high, medium, low") evidence_type: str = Field(description="What directly supports this issue. Allowed values: diff, ticket, inferred") @@ -127,7 +140,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 %} diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 2e697b0ff0..9d5ea7f08a 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -11,6 +11,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})(?=-|$)") TICKET_PROMPT_FIELDS = ("ticket_id", "ticket_url", "title", "body", "labels", "requirements") @@ -116,6 +118,45 @@ 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) -> tuple[list | None, str]: MAX_TICKET_CHARACTERS = 10000 @@ -142,7 +183,24 @@ async def extract_tickets(git_provider) -> tuple[list | None, str]: 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 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", + } 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"]) diff --git a/tests/unittest/test_get_max_tokens.py b/tests/unittest/test_get_max_tokens.py index f6050812b1..7a02fc8238 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('', (), { @@ -107,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("", (), { 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",