Skip to content
Open
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
6 changes: 6 additions & 0 deletions pr_agent/settings/.secrets_template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ url = ""
org = ""
pat = ""

[jira]
# For fetching ticket context from Jira (Cloud or Server/Data Center)
jira_base_url = "" # e.g. https://your-org.atlassian.net
jira_api_email = "" # Atlassian account email (Cloud) or username (Server basic auth)
jira_api_token = "" # API token (Cloud), password (Server basic auth), or PAT (Server)

[azure_devops_server]
# For Azure devops Server basic auth - configured in the webhook creation
# Optional, uncomment if you want to use Azure devops webhooks. Value assigned when you create the webhook
Expand Down
11 changes: 11 additions & 0 deletions pr_agent/settings/configuration.toml
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,17 @@ pr_commands = [
"/improve --pr_code_suggestions.commitable_code_suggestions=true",
]

[jira]
# Credentials for fetching ticket context from Jira (Cloud or Server/Data Center).
# Leave empty to disable Jira ticket lookup. Set these via environment variables or
# the secrets file rather than committing them (e.g. jira__jira_api_token).
jira_base_url = "" # e.g. https://your-org.atlassian.net (required for Server/PAT and shortened keys)
jira_api_email = "" # Atlassian account email (Cloud), or username (Server basic auth)
jira_api_token = "" # API token (Cloud), password (Server basic auth), or PAT (Server)
# Custom field id holding acceptance criteria / requirements, mapped to the ticket
# "requirements" section. Instance-specific (e.g. "customfield_10127"); empty disables it.
jira_requirements_field = ""

[litellm]
# use_client = false
# drop_params = false
Expand Down
149 changes: 135 additions & 14 deletions pr_agent/tools/ticket_pr_compliance_check.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import re
import traceback

from atlassian import Jira

from pr_agent.config_loader import get_settings
from pr_agent.git_providers import GithubProvider
from pr_agent.git_providers import AzureDevopsProvider
Expand All @@ -13,28 +15,144 @@
# 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})(?=-|$)")

# Max number of tickets to analyse per PR, and max characters of ticket body to keep.
MAX_TICKETS = 3
MAX_TICKET_CHARACTERS = 10000

def find_jira_tickets(text):
# Regular expression patterns for JIRA tickets
# Regular expression patterns for JIRA tickets. Matching is case-insensitive so
# lowercased branch names (e.g. bugfix/abc-123-description) are detected; keys are
# normalized to upper case to match Jira's canonical form.
patterns = [
r'\b[A-Z]{2,10}-\d{1,7}\b', # Standard JIRA ticket format (e.g., PROJ-123)
r'(?:https?://[^\s/]+/browse/)?([A-Z]{2,10}-\d{1,7})\b' # JIRA URL or just the ticket
]

tickets = set()
for pattern in patterns:
matches = re.findall(pattern, text)
matches = re.findall(pattern, text, flags=re.IGNORECASE)
for match in matches:
if isinstance(match, tuple):
# If it's a tuple (from the URL pattern), take the last non-empty group
ticket = next((m for m in reversed(match) if m), None)
else:
ticket = match
if ticket:
tickets.add(ticket)
tickets.add(ticket.upper())

return list(tickets)


def _get_jira_client():
"""
Build a Jira client from the [jira] settings. Returns None if Jira is not configured.
Cloud uses email + API token; Server/Data Center uses username + password, or a PAT
(passed as the token) together with a base url.
"""
base_url = get_settings().get("JIRA.JIRA_BASE_URL", None)
api_email = get_settings().get("JIRA.JIRA_API_EMAIL", None)
api_token = get_settings().get("JIRA.JIRA_API_TOKEN", None)
if not (base_url and api_token):
return None
try:
if api_email:
return Jira(url=base_url.rstrip("/"), username=api_email, password=api_token)
# No email/username: treat the token as a Server/Data Center PAT.
return Jira(url=base_url.rstrip("/"), token=api_token)
except Exception as e:
get_logger().error(f"Failed to initialize Jira client: {e}",
artifact={"traceback": traceback.format_exc()})
return None


def extract_jira_tickets(text, max_characters=MAX_TICKET_CHARACTERS):
"""
Find Jira ticket keys in the given text and fetch their content. Returns a list of
ticket dicts in the same shape used by the rest of the ticket-analysis flow. Returns
an empty list when Jira is not configured or no keys are found.
"""
jira_client = _get_jira_client()
if jira_client is None:
return []

base_url = get_settings().get("JIRA.JIRA_BASE_URL", "").rstrip("/")
# Custom field that holds acceptance criteria / requirements. The field id is
# instance-specific (e.g. "customfield_10127"), so it must be configured; empty
# means no requirements are extracted.
requirements_field = get_settings().get("JIRA.JIRA_REQUIREMENTS_FIELD", "") or ""
keys = find_jira_tickets(text or "")
if len(keys) > MAX_TICKETS:
get_logger().info(f"Too many Jira tickets found: {len(keys)}; limiting to {MAX_TICKETS}")
keys = keys[:MAX_TICKETS]

tickets_content = []
for key in keys:
try:
issue = jira_client.issue(key)
except Exception as e:
get_logger().warning(f"Failed to fetch Jira ticket {key}: {e}")
continue
if not issue:
continue

fields = issue.get("fields", {}) or {}
body = fields.get("description") or ""
if not isinstance(body, str):
body = ""
if len(body) > max_characters:
body = body[:max_characters] + "..."

requirements = ""
if requirements_field:
requirements = fields.get(requirements_field) or ""
if not isinstance(requirements, str):
requirements = ""

labels = fields.get("labels", []) or []
tickets_content.append({
"ticket_id": key,
"ticket_url": f"{base_url}/browse/{key}" if base_url else "",
"title": fields.get("summary", ""),
"body": body,
"requirements": requirements,
"labels": ", ".join(labels),
})
return tickets_content


def _get_pr_title(git_provider) -> str:
"""Return the PR/MR title across providers (GitHub/Bitbucket use .pr, GitLab .mr)."""
for attr in ("pr", "mr"):
obj = getattr(git_provider, attr, None)
title = getattr(obj, "title", None)
if title:
return title
return ""


def add_jira_tickets(git_provider, tickets_content):
"""
Provider-agnostic Jira lookup. Scans the PR title, description and branch name for
Jira ticket keys and appends any found tickets to tickets_content (de-duplicated by
ticket_url). No-op when Jira is not configured. Works for any git provider, since it
only relies on get_user_description() and get_pr_branch().
"""
try:
jira_context = "\n".join(filter(None, [
_get_pr_title(git_provider),
git_provider.get_user_description() or "",
git_provider.get_pr_branch() or "",
]))
existing_urls = {t.get("ticket_url") for t in tickets_content}
for jira_ticket in extract_jira_tickets(jira_context, MAX_TICKET_CHARACTERS):
if jira_ticket.get("ticket_url") not in existing_urls:
tickets_content.append(jira_ticket)
except Exception as e:
get_logger().error(f"Error extracting Jira tickets: {e}",
artifact={"traceback": traceback.format_exc()})
return tickets_content


def extract_ticket_links_from_pr_description(pr_description, repo_path, base_url_html='https://github.com'):
"""
Extract all ticket links from PR description
Expand All @@ -55,10 +173,10 @@ def extract_ticket_links_from_pr_description(pr_description, repo_path, base_url
if issue_number.isdigit() and len(issue_number) < 5 and repo_path:
github_tickets.add(f'{base_url_html.strip("/")}/{repo_path}/issues/{issue_number}')

if len(github_tickets) > 3:
if len(github_tickets) > MAX_TICKETS:
get_logger().info(f"Too many tickets found in PR description: {len(github_tickets)}")
# Limit the number of tickets to 3
github_tickets = set(list(github_tickets)[:3])
# Limit the number of tickets
github_tickets = set(list(github_tickets)[:MAX_TICKETS])
except Exception as e:
get_logger().error(f"Error extracting tickets error= {e}",
artifact={"traceback": traceback.format_exc()})
Expand Down Expand Up @@ -106,8 +224,9 @@ def extract_ticket_links_from_branch_name(branch_name, repo_path, base_url_html=


async def extract_tickets(git_provider):
MAX_TICKET_CHARACTERS = 10000
try:
tickets_content = []

if isinstance(git_provider, GithubProvider):
user_description = git_provider.get_user_description()
description_tickets = extract_ticket_links_from_pr_description(
Expand All @@ -123,12 +242,11 @@ async def extract_tickets(git_provider):
if link not in seen:
seen.add(link)
merged.append(link)
if len(merged) > 3:
if len(merged) > MAX_TICKETS:
get_logger().info(f"Too many tickets (description + branch): {len(merged)}")
tickets = merged[:3]
tickets = merged[:MAX_TICKETS]
else:
tickets = merged
tickets_content = []

if tickets:

Expand Down Expand Up @@ -188,11 +306,8 @@ async def extract_tickets(git_provider):
'sub_issues': sub_issues_content # Store sub-issues content
})

return tickets_content

elif isinstance(git_provider, AzureDevopsProvider):
tickets_info = git_provider.get_linked_work_items()
tickets_content = []
for ticket in tickets_info:
try:
ticket_body_str = ticket.get("body", "")
Expand All @@ -214,7 +329,13 @@ async def extract_tickets(git_provider):
f"Error processing Azure DevOps ticket: {e}",
artifact={"traceback": traceback.format_exc()},
)
return tickets_content

# Provider-agnostic Jira lookup. Tickets are often referenced by key in the PR
# title, description or branch name rather than via a provider-native link, so
# this runs for every provider and is a no-op when Jira is not configured.
add_jira_tickets(git_provider, tickets_content)

return tickets_content

except Exception as e:
get_logger().error(f"Error extracting tickets error= {e}",
Expand Down
Loading