diff --git a/docs/docs/core-abilities/fetching_ticket_context.md b/docs/docs/core-abilities/fetching_ticket_context.md index de06ba2172..5f30f69169 100644 --- a/docs/docs/core-abilities/fetching_ticket_context.md +++ b/docs/docs/core-abilities/fetching_ticket_context.md @@ -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:** @@ -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 @@ -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. diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 675f5dad9e..44336cd91d 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -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 diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 6d25d76b19..dea292184a 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -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 @@ -35,6 +34,72 @@ def find_jira_tickets(text): return list(tickets) +_ASANA_TASK_URL_PATTERN = re.compile( + 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 @@ -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: @@ -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 ) @@ -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: @@ -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", "") @@ -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: 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): diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py new file mode 100644 index 0000000000..4760030e03 --- /dev/null +++ b/tests/unittest/test_ticket_compliance.py @@ -0,0 +1,381 @@ +""" +Unit tests for Asana ticket detection in ticket_pr_compliance_check.py. + +Tests cover: +- Full Asana URL detection +- Edge cases (mixed content, no tickets, duplicates) +""" +from pr_agent.config_loader import get_settings +from pr_agent.git_providers import AzureDevopsProvider +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.tools.ticket_pr_compliance_check import extract_tickets, find_asana_tickets + + +class _Issue: + def __init__(self, number): + self.number = number + self.title = f"Issue {number}" + self.body = f"Issue {number} body" + self.labels = [] + + +class _Repo: + def get_issue(self, number): + return _Issue(number) + + +class _PartiallyFailingRepo: + def get_issue(self, number): + if number == 2: + raise RuntimeError("issue unavailable") + return _Issue(number) + + +class _GithubProvider(GithubProvider): + repo = "owner/repo" + base_url_html = "https://github.com" + repo_obj = _Repo() + + def __init__(self, description, repo_obj=None): + self.description = description + if repo_obj is not None: + self.repo_obj = repo_obj + + def get_user_description(self): + return self.description + + def get_pr_branch(self): + return "" + + def _parse_issue_url(self, ticket): + return self.repo, int(ticket.rsplit("/", 1)[-1]) + + def fetch_sub_issues(self, ticket): + return [] + + +class _GenericProvider: + def __init__(self, description): + self.description = description + + def get_user_description(self): + return self.description + + def get_pr_branch(self): + return "" + + +class _FailingProvider: + def get_user_description(self): + raise RuntimeError("description unavailable") + + +class _AzureProvider(AzureDevopsProvider): + def __init__(self, description, work_items): + self.description = description + self.work_items = work_items + + def get_user_description(self): + return self.description + + def get_linked_work_items(self): + return self.work_items + + +class TestFindAsanaTickets: + """Tests for find_asana_tickets().""" + + def test_detects_full_asana_url(self): + """Full Asana task URLs should be detected.""" + text = "See https://app.asana.com/0/123456/789012 for details" + tickets = find_asana_tickets(text) + assert "https://app.asana.com/0/123456/789012" in tickets + + def test_detects_multiple_urls(self): + """Multiple Asana URLs should all be found.""" + text = ( + "See https://app.asana.com/0/11/111111111111" + " and https://app.asana.com/0/22/333333333333" + ) + tickets = find_asana_tickets(text) + assert len(tickets) == 2 + + def test_deduplicates_identical_urls(self): + """Duplicate references to the same URL should be deduplicated.""" + text = ( + "https://app.asana.com/0/1/123456789012" + " mentioned twice: https://app.asana.com/0/1/123456789012" + ) + tickets = find_asana_tickets(text) + assert len(tickets) == 1 + + def test_returns_empty_for_no_tickets(self): + """Text without Asana references returns an empty list.""" + text = "No tickets here, just regular text" + tickets = find_asana_tickets(text) + assert tickets == [] + + def test_returns_empty_for_empty_string(self): + """Empty string returns an empty list.""" + tickets = find_asana_tickets("") + assert tickets == [] + + def test_returns_empty_for_none_input(self): + """None input returns an empty list.""" + tickets = find_asana_tickets(None) + assert tickets == [] + + def test_ignores_github_urls(self): + """GitHub issue URLs should not be mistaken for Asana tickets.""" + text = "Fix https://github.com/owner/repo/issues/42" + tickets = find_asana_tickets(text) + assert tickets == [] + + def test_tickets_are_sorted(self): + """Returned list should be sorted alphabetically.""" + text = ( + "https://app.asana.com/0/2/222222222222" + " https://app.asana.com/0/1/111111111111" + ) + tickets = find_asana_tickets(text) + assert tickets == sorted(tickets) + + def test_tickets_in_pr_description_mixed_content(self): + """Asana tickets mixed with other content in a PR description.""" + text = """## Summary + Related to https://app.asana.com/0/99/888888888888 + and https://app.asana.com/0/77/777777777777 + + Also see GitHub issue #42 + """ + tickets = find_asana_tickets(text) + assert len(tickets) == 2 + + async def test_extract_tickets_includes_asana_reference(self): + """extract_tickets() should include Asana references in ticket content.""" + provider = _GithubProvider( + "Related Asana task: https://app.asana.com/0/99/888888888888" + ) + + tickets = await extract_tickets(provider) + + assert tickets == [ + { + "ticket_id": "https://app.asana.com/0/99/888888888888", + "ticket_url": "https://app.asana.com/0/99/888888888888", + "title": "Asana Task: https://app.asana.com/0/99/888888888888", + "body": ( + "Asana task referenced in PR description. " + "Fetch task details from Asana for full context." + ), + "labels": "", + } + ] + + async def test_extract_tickets_reserves_slot_for_asana_when_truncated(self): + """Asana references should not be dropped when the ticket list is capped.""" + provider = _GithubProvider( + "Fixes #1 and #2 and #3. " + "Related Asana task: https://app.asana.com/0/99/888888888888" + ) + + tickets = await extract_tickets(provider) + + assert len(tickets) == 3 + asana_tickets = [ + ticket for ticket in tickets + if ticket["ticket_url"].startswith("https://app.asana.com/") + ] + assert len(asana_tickets) == 1 + + async def test_extract_tickets_backfills_with_asana_when_truncated(self): + """Ticket truncation should still return up to 3 available Asana tickets.""" + provider = _GithubProvider( + "Related Asana tasks: " + "https://app.asana.com/0/99/111111111111 " + "https://app.asana.com/0/99/222222222222 " + "https://app.asana.com/0/99/333333333333 " + "https://app.asana.com/0/99/444444444444" + ) + + tickets = await extract_tickets(provider) + + assert len(tickets) == 3 + assert all( + ticket["ticket_url"].startswith("https://app.asana.com/") + for ticket in tickets + ) + + async def test_extract_tickets_backfills_asana_after_github_fetch_failure(self): + """Asana tickets should fill available slots after GitHub issue fetch failures.""" + provider = _GithubProvider( + "Fixes #1 and #2. " + "Related Asana tasks: " + "https://app.asana.com/0/99/111111111111 " + "https://app.asana.com/0/99/222222222222 " + "https://app.asana.com/0/99/333333333333", + repo_obj=_PartiallyFailingRepo(), + ) + + tickets = await extract_tickets(provider) + + assert len(tickets) == 3 + assert tickets[0]["ticket_url"] == "https://github.com/owner/repo/issues/1" + assert [ + ticket["ticket_url"] for ticket in tickets[1:] + ] == [ + "https://app.asana.com/0/99/111111111111", + "https://app.asana.com/0/99/222222222222", + ] + + async def test_extract_tickets_includes_asana_for_non_github_provider(self): + """Asana detection should not be limited to the GitHub provider path.""" + provider = _GenericProvider( + "Related Asana task: https://app.asana.com/0/99/888888888888" + ) + + tickets = await extract_tickets(provider) + + assert tickets == [ + { + "ticket_id": "https://app.asana.com/0/99/888888888888", + "ticket_url": "https://app.asana.com/0/99/888888888888", + "title": "Asana Task: https://app.asana.com/0/99/888888888888", + "body": ( + "Asana task referenced in PR description. " + "Fetch task details from Asana for full context." + ), + "labels": "", + } + ] + + async def test_extract_tickets_returns_empty_list_when_no_tickets_found(self): + """No detected tickets should return an empty list rather than None.""" + provider = _GenericProvider("No related ticket references.") + + tickets = await extract_tickets(provider) + + assert tickets == [] + + async def test_extract_tickets_returns_empty_list_on_provider_error(self): + """Provider errors should not make extract_tickets() return None.""" + tickets = await extract_tickets(_FailingProvider()) + + assert tickets == [] + + async def test_extract_tickets_caps_non_github_asana_references(self): + """Provider-agnostic Asana fallback should keep ticket context bounded.""" + provider = _GenericProvider( + "Related Asana tasks: " + "https://app.asana.com/0/99/111111111111 " + "https://app.asana.com/0/99/222222222222 " + "https://app.asana.com/0/99/333333333333 " + "https://app.asana.com/0/99/444444444444" + ) + + tickets = await extract_tickets(provider) + + assert len(tickets) == 3 + assert all( + ticket["ticket_url"].startswith("https://app.asana.com/") + for ticket in tickets + ) + + async def test_extract_tickets_caps_azure_asana_references(self): + """Azure work items and Asana references should share the ticket cap.""" + provider = _AzureProvider( + "Related Asana task: https://app.asana.com/0/99/888888888888", + [ + { + "id": 1, + "url": "https://dev.azure.com/org/project/_workitems/edit/1", + "title": "Issue 1", + "body": "Issue 1 body", + "acceptance_criteria": "", + "labels": [], + }, + { + "id": 2, + "url": "https://dev.azure.com/org/project/_workitems/edit/2", + "title": "Issue 2", + "body": "Issue 2 body", + "acceptance_criteria": "", + "labels": [], + }, + { + "id": 3, + "url": "https://dev.azure.com/org/project/_workitems/edit/3", + "title": "Issue 3", + "body": "Issue 3 body", + "acceptance_criteria": "", + "labels": [], + }, + ], + ) + + tickets = await extract_tickets(provider) + + assert len(tickets) == 3 + assert tickets[-1]["ticket_url"] == "https://app.asana.com/0/99/888888888888" + + async def test_extract_tickets_caps_azure_work_items_without_asana(self): + """Azure-only work items should also respect the configured ticket cap.""" + provider = _AzureProvider( + "", + [ + { + "id": 1, + "url": "https://dev.azure.com/org/project/_workitems/edit/1", + "title": "Issue 1", + "body": "Issue 1 body", + "acceptance_criteria": "", + "labels": [], + }, + { + "id": 2, + "url": "https://dev.azure.com/org/project/_workitems/edit/2", + "title": "Issue 2", + "body": "Issue 2 body", + "acceptance_criteria": "", + "labels": [], + }, + { + "id": 3, + "url": "https://dev.azure.com/org/project/_workitems/edit/3", + "title": "Issue 3", + "body": "Issue 3 body", + "acceptance_criteria": "", + "labels": [], + }, + { + "id": 4, + "url": "https://dev.azure.com/org/project/_workitems/edit/4", + "title": "Issue 4", + "body": "Issue 4 body", + "acceptance_criteria": "", + "labels": [], + }, + ], + ) + + tickets = await extract_tickets(provider) + + assert len(tickets) == 3 + assert [ticket["ticket_id"] for ticket in tickets] == [1, 2, 3] + + async def test_extract_tickets_uses_configured_ticket_cap(self): + """The ticket cap should be configurable via pr_reviewer.max_related_tickets.""" + previous_max = get_settings().get("pr_reviewer.max_related_tickets", 3) + get_settings().set("pr_reviewer.max_related_tickets", 4) + try: + provider = _GithubProvider( + "Fixes #1, #2 and #3. " + "Related Asana task: https://app.asana.com/0/99/888888888888" + ) + + tickets = await extract_tickets(provider) + finally: + get_settings().set("pr_reviewer.max_related_tickets", previous_max) + + assert len(tickets) == 4 + assert tickets[-1]["ticket_url"] == "https://app.asana.com/0/99/888888888888"