Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
19 changes: 19 additions & 0 deletions docs/docs/core-abilities/fetching_ticket_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This integration enriches the review process by automatically surfacing relevant

- [GitHub/Gitlab Issues](#githubgitlab-issues-integration)
- [Jira](#jira-integration)
- [Asana](#asana-integration)

**Ticket data fetched:**

Expand All @@ -30,6 +31,7 @@ Ticket Recognition Requirements:

- The PR description should contain a link to the ticket or if the branch name starts with the ticket id / number.
- For Jira tickets, you should follow the instructions in [Jira Integration](#jira-integration) in order to authenticate with Jira.
- For Asana tickets, see [Asana Integration](#asana-integration).

### Describe tool

Expand Down Expand Up @@ -92,6 +94,23 @@ This branch-name detection applies **only when the git provider is GitHub**. Sup

Since PR-Agent is integrated with GitHub, it doesn't require any additional configuration to fetch GitHub issues.

## Asana Integration

PR-Agent can detect Asana task references in PR descriptions and include them in the ticket compliance check.

**Supported reference format:**

- Full Asana URLs: `https://app.asana.com/0/{project_id}/{task_id}`

**How to link a PR to an Asana task:**

Include an Asana task URL in your PR description. PR-Agent will detect it automatically and include it in the related tickets list.

!!! note "Asana content fetching"
Asana task references are included for visibility and ticket compliance checking, but PR-Agent does **not** fetch the full task details from Asana (unlike GitHub and Jira tickets, which are fetched via API). The compliance check will note the reference and suggest reviewing the task in Asana for full context.

No additional configuration is required for Asana detection — it works out of the box.

## Jira Integration

We support both Jira Cloud and Jira Server/Data Center.
Expand Down
1 change: 1 addition & 0 deletions pr_agent/settings/configuration.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ require_security_review=true
require_estimate_contribution_time_cost=false
require_todo_scan=false
require_ticket_analysis_review=true
max_related_tickets=3
# general options
publish_output_no_suggestions=true # Set to "false" if you only need the reviewer's remarks (not labels, not "security audit", etc.) and want to avoid noisy "No major issues detected" comments.
persistent_comment=true
Expand Down
108 changes: 96 additions & 12 deletions pr_agent/tools/ticket_pr_compliance_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
import traceback

from pr_agent.config_loader import get_settings
from pr_agent.git_providers import GithubProvider
from pr_agent.git_providers import AzureDevopsProvider
from pr_agent.git_providers import AzureDevopsProvider, GithubProvider
from pr_agent.log import get_logger

# Compile the regex pattern once, outside the function
Expand Down Expand Up @@ -35,6 +34,72 @@ def find_jira_tickets(text):
return list(tickets)


_ASANA_TASK_URL_PATTERN = re.compile(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
r"https://app\.asana\.com/0/(\d+)/(\d+)"
)
DEFAULT_MAX_RELATED_TICKETS = 3


def find_asana_tickets(text: str | None) -> list:
"""Extract Asana task references from text.

Supports full Asana URLs (``https://app.asana.com/0/{project_id}/{task_id}``).
Returns a list of unique task URLs.

Args:
text: The text to scan for Asana task references.

Returns:
A list of Asana task URLs.
"""
if not isinstance(text, str) or not text:
return []

tickets = set()
for match in _ASANA_TASK_URL_PATTERN.finditer(text):
tickets.add(match.group(0))
return sorted(tickets)


def _make_asana_ticket_content(ticket: str) -> dict:
return {
"ticket_id": ticket,
"ticket_url": ticket,
"title": f"Asana Task: {ticket}",
"body": (
"Asana task referenced in PR description. "
"Fetch task details from Asana for full context."
),
"labels": "",
}


def _append_asana_ticket_content(
tickets_content: list,
asana_tickets: list,
max_tickets: int,
) -> None:
seen_ticket_urls = {ticket.get("ticket_url") for ticket in tickets_content}
for ticket in asana_tickets:
if len(tickets_content) >= max_tickets:
break
if ticket not in seen_ticket_urls:
tickets_content.append(_make_asana_ticket_content(ticket))
seen_ticket_urls.add(ticket)


def _get_max_related_tickets() -> int:
max_tickets = get_settings().get(
"pr_reviewer.max_related_tickets",
DEFAULT_MAX_RELATED_TICKETS,
)
try:
max_tickets = int(max_tickets)
except (TypeError, ValueError):
return DEFAULT_MAX_RELATED_TICKETS
return max(max_tickets, 1)


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 Down Expand Up @@ -85,7 +150,8 @@ def extract_ticket_links_from_branch_name(branch_name, repo_path, base_url_html=
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."
"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:
Expand All @@ -107,9 +173,14 @@ def extract_ticket_links_from_branch_name(branch_name, repo_path, base_url_html=

async def extract_tickets(git_provider):
MAX_TICKET_CHARACTERS = 10000
max_tickets = _get_max_related_tickets()
try:
user_description = git_provider.get_user_description()
asana_tickets = find_asana_tickets(user_description)
tickets_content = []
asana_tickets_to_include = asana_tickets

if isinstance(git_provider, GithubProvider):
user_description = git_provider.get_user_description()
description_tickets = extract_ticket_links_from_pr_description(
user_description, git_provider.repo, git_provider.base_url_html
)
Expand All @@ -123,12 +194,19 @@ async def extract_tickets(git_provider):
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]

total_tickets = len(merged) + len(asana_tickets)
if total_tickets > max_tickets:
get_logger().info(
f"Too many tickets (description + branch + Asana): {total_tickets}"
)
# Reserve at least one slot for an Asana reference when
# present so it is not systematically dropped.
reserved_asana_slots = 1 if asana_tickets else 0
github_slots = max_tickets - reserved_asana_slots
tickets = merged[:github_slots]
else:
tickets = merged
tickets_content = []

if tickets:

Expand Down Expand Up @@ -188,11 +266,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,11 +289,20 @@ async def extract_tickets(git_provider):
f"Error processing Azure DevOps ticket: {e}",
artifact={"traceback": traceback.format_exc()},
)
return tickets_content
azure_slots = max_tickets - (1 if asana_tickets else 0)
tickets_content = tickets_content[:azure_slots]

_append_asana_ticket_content(
tickets_content,
asana_tickets_to_include,
max_tickets,
)
return tickets_content

except Exception as e:
Comment on lines +295 to 302

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. extract_tickets() can return none 📘 Rule violation ☼ Reliability

extract_tickets() only returns when tickets_content is truthy, so callers can receive None
when no tickets are found or when an exception occurs. This violates the requirement to handle edge
cases gracefully and can cause downstream errors when code expects a list.
Agent Prompt
## Issue description
`extract_tickets()` may implicitly return `None` (no explicit return) when no tickets are found or when an exception is raised, because it only returns inside `if tickets_content:`.

## Issue Context
Callers of `extract_tickets()` generally expect a list (possibly empty). Returning `None` can break iteration/serialization and makes behavior inconsistent across providers and edge cases.

## Fix Focus Areas
- pr_agent/tools/ticket_pr_compliance_check.py[298-310]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

get_logger().error(f"Error extracting tickets error= {e}",
artifact={"traceback": traceback.format_exc()})
return []


async def extract_and_cache_pr_tickets(git_provider, vars):
Expand Down
Loading
Loading