Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/docs/core-abilities/fetching_ticket_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions pr_agent/settings/configuration.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 #
Expand Down Expand Up @@ -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
Expand Down
60 changes: 59 additions & 1 deletion pr_agent/tools/ticket_pr_compliance_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
112 changes: 112 additions & 0 deletions tests/unittest/test_extract_issue_from_branch.py
Original file line number Diff line number Diff line change
@@ -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",
}
Loading