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", + }