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/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 6d25d76b19..981d16e69f 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -35,6 +35,29 @@ def find_jira_tickets(text): return list(tickets) +_ASANA_TASK_URL_PATTERN = re.compile( + r'https://app\.asana\.com/0/(\d+)/(\d+)' +) + + +def find_asana_tickets(text: str) -> 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. + """ + tickets = set() + for match in _ASANA_TASK_URL_PATTERN.finditer(text): + tickets.add(match.group(0)) + return sorted(tickets) + + def extract_ticket_links_from_pr_description(pr_description, repo_path, base_url_html='https://github.com'): """ Extract all ticket links from PR description @@ -123,9 +146,22 @@ async def extract_tickets(git_provider): if link not in seen: seen.add(link) merged.append(link) + + # Also detect Asana ticket references in the PR description + asana_tickets = find_asana_tickets(user_description) + for link in asana_tickets: + if link not in seen: + seen.add(link) + merged.append(link) + asana_links = [t for t in merged if t.startswith("https://app.asana.com/")] + github_like = [t for t in merged if not t.startswith("https://app.asana.com/")] if len(merged) > 3: get_logger().info(f"Too many tickets (description + branch): {len(merged)}") - tickets = merged[:3] + # Reserve at least one slot for an Asana reference when + # present so it is not systematically dropped. + asana_slot = asana_links[:1] + gh_slots = 3 - len(asana_slot) + tickets = github_like[:gh_slots] + asana_slot else: tickets = merged tickets_content = [] @@ -133,6 +169,19 @@ async def extract_tickets(git_provider): if tickets: for ticket in tickets: + # Skip Asana URLs — these are external references, + # included for visibility but cannot be fetched via GitHub API. + if ticket.startswith("https://app.asana.com/"): + tickets_content.append({ + "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": "", + }) + continue + repo_name, original_issue_number = git_provider._parse_issue_url(ticket) try: diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py new file mode 100644 index 0000000000..aecaaf1a20 --- /dev/null +++ b/tests/unittest/test_ticket_compliance.py @@ -0,0 +1,73 @@ +""" +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.tools.ticket_pr_compliance_check import find_asana_tickets + + +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_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