From bafe543aeae4ced9453fd15c19b720a538539346 Mon Sep 17 00:00:00 2001 From: Oxygen <1391083091@qq.com> Date: Sat, 6 Jun 2026 09:56:17 +0800 Subject: [PATCH 01/17] fix: preserve original indentation character in /improve suggestions The dedent_code method only adjusted indentation when delta_spaces > 0 and never normalized the indentation character. This caused /improve to replace tabs with spaces in Go, Makefile, and other tab-indented codebases. Changes: - Handle delta_spaces < 0 case with dedent (not just > 0) - Normalize all suggestion lines to use the original file's indentation character (tab or space), auto-detected from the existing code Fixes #1858 Co-Authored-By: Claude Opus 4.8 --- pr_agent/tools/pr_code_suggestions.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index 0fdc8019fe..1b5ada89f3 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -609,10 +609,26 @@ def dedent_code(self, relevant_file, relevant_lines_start, new_code_snippet): original_initial_spaces = len(original_initial_line) - len(original_initial_line.lstrip()) # lstrip works both for spaces and tabs suggested_initial_spaces = len(suggested_initial_line) - len(suggested_initial_line.lstrip()) delta_spaces = original_initial_spaces - suggested_initial_spaces + # Detect indentation character from original line + indent_char = '\t' if original_initial_line.startswith('\t') else ' ' if delta_spaces > 0: - # Detect indentation character from original line - indent_char = '\t' if original_initial_line.startswith('\t') else ' ' new_code_snippet = textwrap.indent(new_code_snippet, delta_spaces * indent_char).rstrip('\n') + elif delta_spaces < 0: + new_code_snippet = textwrap.dedent(new_code_snippet).rstrip('\n') + # Normalize all lines in the suggestion to use the original's + # indentation character. This fixes the bug where /improve + # replaces tabs with spaces in Go, Makefile, and other + # tab-indented codebases (#1858). + if indent_char == '\t': + new_lines = [] + for line in new_code_snippet.split('\n'): + stripped = line.lstrip(' ') + leading_spaces = len(line) - len(stripped) + if leading_spaces > 0: + new_lines.append('\t' * (leading_spaces // 4) + stripped) + else: + new_lines.append(line) + new_code_snippet = '\n'.join(new_lines) except Exception as e: get_logger().error(f"Error when dedenting code snippet for file {relevant_file}, error: {e}") From 5a70b98f57493010309b6921dd2be499368ea32d Mon Sep 17 00:00:00 2001 From: Oxygen <1391083091@qq.com> Date: Sat, 6 Jun 2026 10:07:27 +0800 Subject: [PATCH 02/17] feat: add Asana ticket detection to ticket compliance check Adds support for detecting Asana task references in PR descriptions and branch names. Supports both full URLs and ASANA-123456789 shorthand format. - find_asana_tickets() extracts Asana task URLs from text - Asana tickets are included in the compliance check's related tickets - Graceful handling: Asana URLs are shown with a placeholder body since they cannot be fetched via the GitHub API Fixes #2002 Co-Authored-By: Claude Opus 4.8 --- pr_agent/tools/ticket_pr_compliance_check.py | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 6d25d76b19..4e86cb67ec 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -35,6 +35,37 @@ def find_jira_tickets(text): return list(tickets) +# Compiled Asana task patterns +_ASANA_TASK_URL_PATTERN = re.compile( + r'https://app\.asana\.com/0/(\d+)/(\d+)' +) +_ASANA_TASK_SHORT_PATTERN = re.compile( + r'\b(?:ASANA|asana)[- ]?(\d{12,20})\b' + r'|https://app\.asana\.com/0/\d+/\d+' +) + + +def find_asana_tickets(text: str) -> list: + """Extract Asana task references from text. + + Supports both full Asana URLs and shorthand ``ASANA-123456789012`` + format. 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 re.finditer(r'https://app\.asana\.com/0/(\d+)/(\d+)', text): + tickets.add(match.group(0)) + for match in re.finditer(r'(?:^|[^A-Za-z0-9])(?:ASANA|asana)[- ]?(\d{12,20})', text): + task_id = match.group(1) + tickets.add(f"https://app.asana.com/0/0/{task_id}") + 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,6 +154,13 @@ 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) if len(merged) > 3: get_logger().info(f"Too many tickets (description + branch): {len(merged)}") tickets = merged[:3] @@ -133,6 +171,17 @@ 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 "app.asana.com" in ticket: + tickets_content.append({ + "title": f"Asana Task: {ticket}", + "url": ticket, + "body": ("Asana task referenced in PR description. " + "Fetch task details from Asana for full context."), + }) + continue + repo_name, original_issue_number = git_provider._parse_issue_url(ticket) try: From 99b9f745a4b6041ce95c7ff3df56065760aeaa12 Mon Sep 17 00:00:00 2001 From: Willow Lopez <100782273+Oxygen56@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:47:21 +0800 Subject: [PATCH 03/17] fix: align Asana ticket schema with Jinja template expectations - Use ticket_url instead of url (templates access ticket.ticket_url) - Add labels field (templates access ticket.labels under StrictUndefined) - Add ticket_id field for consistency with GitHub ticket entries --- pr_agent/tools/ticket_pr_compliance_check.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 4e86cb67ec..8d8e39a1ff 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -175,10 +175,12 @@ async def extract_tickets(git_provider): # included for visibility but cannot be fetched via GitHub API. if "app.asana.com" in ticket: tickets_content.append({ + "ticket_id": ticket, + "ticket_url": ticket, "title": f"Asana Task: {ticket}", - "url": ticket, "body": ("Asana task referenced in PR description. " "Fetch task details from Asana for full context."), + "labels": "", }) continue From 3586598018a91197695558403bf8c327bfbd2955 Mon Sep 17 00:00:00 2001 From: Willow Lopez <100782273+Oxygen56@users.noreply.github.com> Date: Sun, 7 Jun 2026 00:32:16 +0800 Subject: [PATCH 04/17] fix: use exact dedent and preserve tab remainder in dedent_code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace textwrap.dedent() with exact -delta_spaces removal to avoid stripping too much indentation when lines have varying levels - Preserve remainder spaces when converting spaces to tabs (e.g. 6 spaces → 1 tab + 2 spaces instead of silently dropping 2) --- pr_agent/tools/pr_code_suggestions.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index 1b5ada89f3..d836840fa3 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -614,7 +614,18 @@ def dedent_code(self, relevant_file, relevant_lines_start, new_code_snippet): if delta_spaces > 0: new_code_snippet = textwrap.indent(new_code_snippet, delta_spaces * indent_char).rstrip('\n') elif delta_spaces < 0: - new_code_snippet = textwrap.dedent(new_code_snippet).rstrip('\n') + # Remove exactly -delta_spaces leading spaces from each + # non-empty line. textwrap.dedent() strips the *common* + # indent which may remove too much when lines have + # varying indentation levels (Qodo bug #3). + remove_count = -delta_spaces + dedented_lines = [] + for dline in new_code_snippet.split('\n'): + if dline.strip(): + dedented_lines.append(dline[remove_count:]) + else: + dedented_lines.append(dline) + new_code_snippet = '\n'.join(dedented_lines).rstrip('\n') # Normalize all lines in the suggestion to use the original's # indentation character. This fixes the bug where /improve # replaces tabs with spaces in Go, Makefile, and other @@ -625,7 +636,13 @@ def dedent_code(self, relevant_file, relevant_lines_start, new_code_snippet): stripped = line.lstrip(' ') leading_spaces = len(line) - len(stripped) if leading_spaces > 0: - new_lines.append('\t' * (leading_spaces // 4) + stripped) + # Preserve remainder spaces when converting + # to tabs. e.g. 6 spaces → 1 tab + 2 spaces + # instead of silently dropping 2 spaces + # (Qodo bug #3). + tabs = leading_spaces // 4 + remainder = leading_spaces % 4 + new_lines.append('\t' * tabs + ' ' * remainder + stripped) else: new_lines.append(line) new_code_snippet = '\n'.join(new_lines) From 2b76ed6bec7dd2eb9ca44accba3877ce06effbe6 Mon Sep 17 00:00:00 2001 From: Willow Lopez <100782273+Oxygen56@users.noreply.github.com> Date: Sun, 7 Jun 2026 00:33:31 +0800 Subject: [PATCH 05/17] test: add unit tests for Asana ticket detection in ticket compliance check - Covers full Asana URLs, shorthand ASANA- prefix, case-insensitivity - Tests deduplication, sorting, empty input, and mixed PR description content - Validates GitHub URLs are not misidentified as Asana tickets --- tests/unittest/test_ticket_compliance.py | 83 ++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/unittest/test_ticket_compliance.py diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py new file mode 100644 index 0000000000..0474de6793 --- /dev/null +++ b/tests/unittest/test_ticket_compliance.py @@ -0,0 +1,83 @@ +""" +Unit tests for Asana ticket detection in ticket_pr_compliance_check.py. + +Tests cover: +- Full Asana URL detection +- Shorthand ASANA- prefix detection +- Edge cases (mixed content, no tickets, duplicates) +""" +import pytest +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_asana_shorthand(self): + """ASANA-123456789012 shorthand format should be detected.""" + text = "Task ASANA-123456789012 is complete" + tickets = find_asana_tickets(text) + assert any("123456789012" in t for t in tickets) + + def test_detects_multiple_tickets(self): + """Multiple Asana references should all be found.""" + text = ( + "See ASANA-111111111111 and https://app.asana.com/0/22/333333333333" + ) + tickets = find_asana_tickets(text) + assert len(tickets) == 2 + + def test_deduplicates_identical_tickets(self): + """Duplicate references to the same task should be deduplicated.""" + text = ( + "ASANA-123456789012 mentioned twice: ASANA-123456789012 again" + ) + 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_shorthand_is_case_insensitive(self): + """Both ASANA- and asana- should be detected.""" + text = "asana-999999999999 lowercase works too" + tickets = find_asana_tickets(text) + assert len(tickets) == 1 + assert "999999999999" in tickets[0] + + def test_tickets_are_sorted(self): + """Returned list should be sorted alphabetically.""" + text = "ASANA-222222222222 ASANA-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 + Fixes ASANA-123456789012 + Related to https://app.asana.com/0/99/888888888888 + + Also see GitHub issue #42 + """ + tickets = find_asana_tickets(text) + assert len(tickets) == 2 From 3ce5450d3846302a05baa50184843d0ad1340915 Mon Sep 17 00:00:00 2001 From: Oxygen <1391083091@qq.com> Date: Mon, 8 Jun 2026 23:10:11 +0800 Subject: [PATCH 06/17] fix: address all bot review feedback for Asana ticket detection - Use compiled Asana patterns (_ASANA_TASK_URL_PATTERN, _ASANA_TASK_SHORT_PATTERN) in find_asana_tickets() instead of inline regex (CodeQL unused-variable, comments #5/#6). - Add re.IGNORECASE flag to _ASANA_TASK_SHORT_PATTERN for proper case-insensitive matching. - Replace substring match ('app.asana.com' in ticket) with prefix check (ticket.startswith()) to fix CodeQL URL sanitization warning (comment #4). - Reserve at least one slot for Asana tickets during truncation when there are 3+ GitHub/branch tickets (comment #8). - Dedent only actual leading whitespace instead of blindly slicing dline[remove_count:] to avoid deleting real code on lines with less indentation (comments #3/#9). - Remove unused 'import pytest' from test file (comments #10/#20). - Add test_shorthand_mixed_case_asana and test_shorthand_respects_word_boundary test cases. --- pr_agent/tools/pr_code_suggestions.py | 3 ++- pr_agent/tools/ticket_pr_compliance_check.py | 21 +++++++++++++------- tests/unittest/test_ticket_compliance.py | 15 +++++++++++++- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index d836840fa3..802ffe68cd 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -622,7 +622,8 @@ def dedent_code(self, relevant_file, relevant_lines_start, new_code_snippet): dedented_lines = [] for dline in new_code_snippet.split('\n'): if dline.strip(): - dedented_lines.append(dline[remove_count:]) + leading = len(dline) - len(dline.lstrip()) + dedented_lines.append(dline[min(remove_count, leading):]) else: dedented_lines.append(dline) new_code_snippet = '\n'.join(dedented_lines).rstrip('\n') diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 8d8e39a1ff..1df60e70e5 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -40,8 +40,8 @@ def find_jira_tickets(text): r'https://app\.asana\.com/0/(\d+)/(\d+)' ) _ASANA_TASK_SHORT_PATTERN = re.compile( - r'\b(?:ASANA|asana)[- ]?(\d{12,20})\b' - r'|https://app\.asana\.com/0/\d+/\d+' + r'(?:^|[^A-Za-z0-9])(?:ASANA|asana)[- ]?(\d{12,20})', + re.IGNORECASE, ) @@ -58,11 +58,12 @@ def find_asana_tickets(text: str) -> list: A list of Asana task URLs. """ tickets = set() - for match in re.finditer(r'https://app\.asana\.com/0/(\d+)/(\d+)', text): + for match in _ASANA_TASK_URL_PATTERN.finditer(text): tickets.add(match.group(0)) - for match in re.finditer(r'(?:^|[^A-Za-z0-9])(?:ASANA|asana)[- ]?(\d{12,20})', text): + for match in _ASANA_TASK_SHORT_PATTERN.finditer(text): task_id = match.group(1) - tickets.add(f"https://app.asana.com/0/0/{task_id}") + if task_id: + tickets.add(f"https://app.asana.com/0/0/{task_id}") return sorted(tickets) @@ -161,9 +162,15 @@ async def extract_tickets(git_provider): 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 = [] @@ -173,7 +180,7 @@ async def extract_tickets(git_provider): for ticket in tickets: # Skip Asana URLs — these are external references, # included for visibility but cannot be fetched via GitHub API. - if "app.asana.com" in ticket: + if ticket.startswith("https://app.asana.com/"): tickets_content.append({ "ticket_id": ticket, "ticket_url": ticket, diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py index 0474de6793..8a85e2c4ab 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -6,7 +6,6 @@ - Shorthand ASANA- prefix detection - Edge cases (mixed content, no tickets, duplicates) """ -import pytest from pr_agent.tools.ticket_pr_compliance_check import find_asana_tickets @@ -65,6 +64,20 @@ def test_shorthand_is_case_insensitive(self): assert len(tickets) == 1 assert "999999999999" in tickets[0] + def test_shorthand_mixed_case_asana(self): + """Mixed-case shorthand like Asana-123... should also be detected.""" + text = "Asana-888888888888 mixed case" + tickets = find_asana_tickets(text) + assert len(tickets) == 1 + assert "888888888888" in tickets[0] + + def test_shorthand_respects_word_boundary(self): + """Text preceding the shorthand must not be alphanumeric. + NOTASANA-123 (no delimiter before ASANA) should not match.""" + text = "Check NOTASANA-123456789012 in logs" + tickets = find_asana_tickets(text) + assert tickets == [] + def test_tickets_are_sorted(self): """Returned list should be sorted alphabetically.""" text = "ASANA-222222222222 ASANA-111111111111" From 0573be2f64947821f941915b49abf74a02d91c52 Mon Sep 17 00:00:00 2001 From: Willow Lopez <100782273+Oxygen56@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:16:06 +0800 Subject: [PATCH 07/17] Address review feedback: revert unrelated indentation changes in pr_code_suggestions.py, update docs with Asana support --- .../core-abilities/fetching_ticket_context.md | 20 ++++++++++ pr_agent/tools/pr_code_suggestions.py | 38 +------------------ 2 files changed, 22 insertions(+), 36 deletions(-) diff --git a/docs/docs/core-abilities/fetching_ticket_context.md b/docs/docs/core-abilities/fetching_ticket_context.md index de06ba2172..fe13da5480 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,24 @@ 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 formats:** + +- Full Asana URLs: `https://app.asana.com/0/{project_id}/{task_id}` +- Shorthand format: `ASANA-123456789012` (case-insensitive, with or without a hyphen between "ASANA" and the task ID) + +**How to link a PR to an Asana task:** + +Include an Asana task reference in your PR description using either the full URL or the shorthand format. 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/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index 802ffe68cd..0fdc8019fe 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -609,44 +609,10 @@ def dedent_code(self, relevant_file, relevant_lines_start, new_code_snippet): original_initial_spaces = len(original_initial_line) - len(original_initial_line.lstrip()) # lstrip works both for spaces and tabs suggested_initial_spaces = len(suggested_initial_line) - len(suggested_initial_line.lstrip()) delta_spaces = original_initial_spaces - suggested_initial_spaces - # Detect indentation character from original line - indent_char = '\t' if original_initial_line.startswith('\t') else ' ' if delta_spaces > 0: + # Detect indentation character from original line + indent_char = '\t' if original_initial_line.startswith('\t') else ' ' new_code_snippet = textwrap.indent(new_code_snippet, delta_spaces * indent_char).rstrip('\n') - elif delta_spaces < 0: - # Remove exactly -delta_spaces leading spaces from each - # non-empty line. textwrap.dedent() strips the *common* - # indent which may remove too much when lines have - # varying indentation levels (Qodo bug #3). - remove_count = -delta_spaces - dedented_lines = [] - for dline in new_code_snippet.split('\n'): - if dline.strip(): - leading = len(dline) - len(dline.lstrip()) - dedented_lines.append(dline[min(remove_count, leading):]) - else: - dedented_lines.append(dline) - new_code_snippet = '\n'.join(dedented_lines).rstrip('\n') - # Normalize all lines in the suggestion to use the original's - # indentation character. This fixes the bug where /improve - # replaces tabs with spaces in Go, Makefile, and other - # tab-indented codebases (#1858). - if indent_char == '\t': - new_lines = [] - for line in new_code_snippet.split('\n'): - stripped = line.lstrip(' ') - leading_spaces = len(line) - len(stripped) - if leading_spaces > 0: - # Preserve remainder spaces when converting - # to tabs. e.g. 6 spaces → 1 tab + 2 spaces - # instead of silently dropping 2 spaces - # (Qodo bug #3). - tabs = leading_spaces // 4 - remainder = leading_spaces % 4 - new_lines.append('\t' * tabs + ' ' * remainder + stripped) - else: - new_lines.append(line) - new_code_snippet = '\n'.join(new_lines) except Exception as e: get_logger().error(f"Error when dedenting code snippet for file {relevant_file}, error: {e}") From 4caee4c11ea2530553df1465822a9bca62666580 Mon Sep 17 00:00:00 2001 From: Oxygen <1391083091@qq.com> Date: Tue, 23 Jun 2026 09:14:59 +0800 Subject: [PATCH 08/17] Drop ASANA- shorthand format (keep full URL only) Per maintainer feedback (naorpeled), remove the invented ASANA- prefix shorthand since Asana has no native shorthand format like Jira's PROJ-123. Only full Asana URLs are now supported. - Remove _ASANA_TASK_SHORT_PATTERN regex and matching loop - Update find_asana_tickets() docstring - Update docs to remove shorthand format references - Update tests to use full URLs only, drop shorthand-specific cases --- .../core-abilities/fetching_ticket_context.md | 5 +- pr_agent/tools/ticket_pr_compliance_check.py | 13 +---- tests/unittest/test_ticket_compliance.py | 49 +++++-------------- 3 files changed, 17 insertions(+), 50 deletions(-) diff --git a/docs/docs/core-abilities/fetching_ticket_context.md b/docs/docs/core-abilities/fetching_ticket_context.md index fe13da5480..5f30f69169 100644 --- a/docs/docs/core-abilities/fetching_ticket_context.md +++ b/docs/docs/core-abilities/fetching_ticket_context.md @@ -98,14 +98,13 @@ Since PR-Agent is integrated with GitHub, it doesn't require any additional conf PR-Agent can detect Asana task references in PR descriptions and include them in the ticket compliance check. -**Supported reference formats:** +**Supported reference format:** - Full Asana URLs: `https://app.asana.com/0/{project_id}/{task_id}` -- Shorthand format: `ASANA-123456789012` (case-insensitive, with or without a hyphen between "ASANA" and the task ID) **How to link a PR to an Asana task:** -Include an Asana task reference in your PR description using either the full URL or the shorthand format. PR-Agent will detect it automatically and include it in the related tickets list. +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. diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 1df60e70e5..981d16e69f 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -35,21 +35,16 @@ def find_jira_tickets(text): return list(tickets) -# Compiled Asana task patterns _ASANA_TASK_URL_PATTERN = re.compile( r'https://app\.asana\.com/0/(\d+)/(\d+)' ) -_ASANA_TASK_SHORT_PATTERN = re.compile( - r'(?:^|[^A-Za-z0-9])(?:ASANA|asana)[- ]?(\d{12,20})', - re.IGNORECASE, -) def find_asana_tickets(text: str) -> list: """Extract Asana task references from text. - Supports both full Asana URLs and shorthand ``ASANA-123456789012`` - format. Returns a list of unique task URLs. + 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. @@ -60,10 +55,6 @@ def find_asana_tickets(text: str) -> list: tickets = set() for match in _ASANA_TASK_URL_PATTERN.finditer(text): tickets.add(match.group(0)) - for match in _ASANA_TASK_SHORT_PATTERN.finditer(text): - task_id = match.group(1) - if task_id: - tickets.add(f"https://app.asana.com/0/0/{task_id}") return sorted(tickets) diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py index 8a85e2c4ab..aecaaf1a20 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -3,7 +3,6 @@ Tests cover: - Full Asana URL detection -- Shorthand ASANA- prefix detection - Edge cases (mixed content, no tickets, duplicates) """ from pr_agent.tools.ticket_pr_compliance_check import find_asana_tickets @@ -18,24 +17,20 @@ def test_detects_full_asana_url(self): tickets = find_asana_tickets(text) assert "https://app.asana.com/0/123456/789012" in tickets - def test_detects_asana_shorthand(self): - """ASANA-123456789012 shorthand format should be detected.""" - text = "Task ASANA-123456789012 is complete" - tickets = find_asana_tickets(text) - assert any("123456789012" in t for t in tickets) - - def test_detects_multiple_tickets(self): - """Multiple Asana references should all be found.""" + def test_detects_multiple_urls(self): + """Multiple Asana URLs should all be found.""" text = ( - "See ASANA-111111111111 and https://app.asana.com/0/22/333333333333" + "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_tickets(self): - """Duplicate references to the same task should be deduplicated.""" + def test_deduplicates_identical_urls(self): + """Duplicate references to the same URL should be deduplicated.""" text = ( - "ASANA-123456789012 mentioned twice: ASANA-123456789012 again" + "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 @@ -57,38 +52,20 @@ def test_ignores_github_urls(self): tickets = find_asana_tickets(text) assert tickets == [] - def test_shorthand_is_case_insensitive(self): - """Both ASANA- and asana- should be detected.""" - text = "asana-999999999999 lowercase works too" - tickets = find_asana_tickets(text) - assert len(tickets) == 1 - assert "999999999999" in tickets[0] - - def test_shorthand_mixed_case_asana(self): - """Mixed-case shorthand like Asana-123... should also be detected.""" - text = "Asana-888888888888 mixed case" - tickets = find_asana_tickets(text) - assert len(tickets) == 1 - assert "888888888888" in tickets[0] - - def test_shorthand_respects_word_boundary(self): - """Text preceding the shorthand must not be alphanumeric. - NOTASANA-123 (no delimiter before ASANA) should not match.""" - text = "Check NOTASANA-123456789012 in logs" - tickets = find_asana_tickets(text) - assert tickets == [] - def test_tickets_are_sorted(self): """Returned list should be sorted alphabetically.""" - text = "ASANA-222222222222 ASANA-111111111111" + 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 - Fixes ASANA-123456789012 Related to https://app.asana.com/0/99/888888888888 + and https://app.asana.com/0/77/777777777777 Also see GitHub issue #42 """ From 65460ce489e0c7e11e40c2239b750eb720f51206 Mon Sep 17 00:00:00 2001 From: Oxygen <100782273+Oxygen56@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:33:42 +0800 Subject: [PATCH 09/17] test: cover Asana ticket extraction integration Add extract_tickets coverage for Asana references, including the capped-ticket path that reserves a slot for an Asana URL. --- tests/unittest/test_ticket_compliance.py | 74 +++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py index aecaaf1a20..4bfe04e346 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -5,7 +5,42 @@ - Full Asana URL detection - Edge cases (mixed content, no tickets, duplicates) """ -from pr_agent.tools.ticket_pr_compliance_check import find_asana_tickets +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 _GithubProvider(GithubProvider): + repo = "owner/repo" + base_url_html = "https://github.com" + repo_obj = _Repo() + + def __init__(self, description): + self.description = description + + 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 TestFindAsanaTickets: @@ -71,3 +106,40 @@ def test_tickets_in_pr_description_mixed_content(self): """ 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 From bc043c7d4e16070713217819b7d1d7c4f30f34b6 Mon Sep 17 00:00:00 2001 From: Oxygen <100782273+Oxygen56@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:43:14 +0800 Subject: [PATCH 10/17] fix: harden Asana ticket extraction edge cases --- pr_agent/tools/ticket_pr_compliance_check.py | 11 ++++++---- tests/unittest/test_ticket_compliance.py | 23 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 981d16e69f..c9d1d14aa8 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -40,7 +40,7 @@ def find_jira_tickets(text): ) -def find_asana_tickets(text: str) -> list: +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}``). @@ -52,6 +52,9 @@ def find_asana_tickets(text: str) -> list: 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)) @@ -159,9 +162,9 @@ async def extract_tickets(git_provider): get_logger().info(f"Too many tickets (description + branch): {len(merged)}") # 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 + reserved_asana_slots = 1 if asana_links else 0 + github_slots = 3 - reserved_asana_slots + tickets = (github_like[:github_slots] + asana_links)[:3] else: tickets = merged tickets_content = [] diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py index 4bfe04e346..d2242caba6 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -81,6 +81,11 @@ def test_returns_empty_for_empty_string(self): 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" @@ -143,3 +148,21 @@ async def test_extract_tickets_reserves_slot_for_asana_when_truncated(self): 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 + ) From a0963034c8e6f00552e6e3b50acdcb20033ed963 Mon Sep 17 00:00:00 2001 From: Oxygen <100782273+Oxygen56@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:19:14 +0800 Subject: [PATCH 11/17] fix: support Asana references across providers --- pr_agent/tools/ticket_pr_compliance_check.py | 51 ++++++++++++++------ tests/unittest/test_ticket_compliance.py | 32 ++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index c9d1d14aa8..549ea80702 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -61,6 +61,23 @@ def find_asana_tickets(text: str | None) -> list: return sorted(tickets) +def _is_asana_ticket(ticket: str) -> bool: + return ticket.startswith("https://app.asana.com/") + + +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 extract_ticket_links_from_pr_description(pr_description, repo_path, base_url_html='https://github.com'): """ Extract all ticket links from PR description @@ -134,8 +151,16 @@ 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: - if isinstance(git_provider, GithubProvider): + user_description = "" + try: user_description = git_provider.get_user_description() + except Exception as e: + get_logger().warning( + f"Failed to get PR description for Asana ticket detection: {e}" + ) + asana_tickets = find_asana_tickets(user_description) + + if isinstance(git_provider, GithubProvider): description_tickets = extract_ticket_links_from_pr_description( user_description, git_provider.repo, git_provider.base_url_html ) @@ -150,14 +175,12 @@ async def extract_tickets(git_provider): 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/")] + asana_links = [t for t in merged if _is_asana_ticket(t)] + github_like = [t for t in merged if not _is_asana_ticket(t)] if len(merged) > 3: get_logger().info(f"Too many tickets (description + branch): {len(merged)}") # Reserve at least one slot for an Asana reference when @@ -174,15 +197,8 @@ async def extract_tickets(git_provider): 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": "", - }) + if _is_asana_ticket(ticket): + tickets_content.append(_make_asana_ticket_content(ticket)) continue repo_name, original_issue_number = git_provider._parse_issue_url(ticket) @@ -266,8 +282,15 @@ async def extract_tickets(git_provider): f"Error processing Azure DevOps ticket: {e}", artifact={"traceback": traceback.format_exc()}, ) + seen_ticket_urls = {ticket.get("ticket_url") for ticket in tickets_content} + for ticket in asana_tickets: + if ticket not in seen_ticket_urls: + tickets_content.append(_make_asana_ticket_content(ticket)) return tickets_content + if asana_tickets: + return [_make_asana_ticket_content(ticket) for ticket in asana_tickets] + except Exception as e: get_logger().error(f"Error extracting tickets error= {e}", artifact={"traceback": traceback.format_exc()}) diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py index d2242caba6..565e9b1111 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -43,6 +43,17 @@ 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 TestFindAsanaTickets: """Tests for find_asana_tickets().""" @@ -166,3 +177,24 @@ async def test_extract_tickets_backfills_with_asana_when_truncated(self): ticket["ticket_url"].startswith("https://app.asana.com/") for ticket in tickets ) + + 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": "", + } + ] From 800a5c26bcf038787521b3ec28f8b523782b8e87 Mon Sep 17 00:00:00 2001 From: Oxygen <100782273+Oxygen56@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:23:06 +0800 Subject: [PATCH 12/17] refactor: append Asana references after provider fetch --- pr_agent/tools/ticket_pr_compliance_check.py | 44 +++++++++----------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 549ea80702..13ae6e053f 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -61,10 +61,6 @@ def find_asana_tickets(text: str | None) -> list: return sorted(tickets) -def _is_asana_ticket(ticket: str) -> bool: - return ticket.startswith("https://app.asana.com/") - - def _make_asana_ticket_content(ticket: str) -> dict: return { "ticket_id": ticket, @@ -78,6 +74,14 @@ def _make_asana_ticket_content(ticket: str) -> dict: } +def _append_asana_ticket_content(tickets_content: list, asana_tickets: list) -> None: + seen_ticket_urls = {ticket.get("ticket_url") for ticket in tickets_content} + for ticket in asana_tickets: + if ticket not in seen_ticket_urls: + tickets_content.append(_make_asana_ticket_content(ticket)) + seen_ticket_urls.add(ticket) + + def extract_ticket_links_from_pr_description(pr_description, repo_path, base_url_html='https://github.com'): """ Extract all ticket links from PR description @@ -175,19 +179,18 @@ async def extract_tickets(git_provider): seen.add(link) merged.append(link) - for link in asana_tickets: - if link not in seen: - seen.add(link) - merged.append(link) - asana_links = [t for t in merged if _is_asana_ticket(t)] - github_like = [t for t in merged if not _is_asana_ticket(t)] - if len(merged) > 3: - get_logger().info(f"Too many tickets (description + branch): {len(merged)}") + total_tickets = len(merged) + len(asana_tickets) + asana_tickets_to_include = asana_tickets + if total_tickets > 3: + 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_links else 0 + reserved_asana_slots = 1 if asana_tickets else 0 github_slots = 3 - reserved_asana_slots - tickets = (github_like[:github_slots] + asana_links)[:3] + tickets = merged[:github_slots] + asana_tickets_to_include = asana_tickets[: 3 - len(tickets)] else: tickets = merged tickets_content = [] @@ -195,12 +198,6 @@ 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 _is_asana_ticket(ticket): - tickets_content.append(_make_asana_ticket_content(ticket)) - continue - repo_name, original_issue_number = git_provider._parse_issue_url(ticket) try: @@ -256,6 +253,8 @@ async def extract_tickets(git_provider): 'sub_issues': sub_issues_content # Store sub-issues content }) + _append_asana_ticket_content(tickets_content, asana_tickets_to_include) + if tickets_content: return tickets_content elif isinstance(git_provider, AzureDevopsProvider): @@ -282,10 +281,7 @@ async def extract_tickets(git_provider): f"Error processing Azure DevOps ticket: {e}", artifact={"traceback": traceback.format_exc()}, ) - seen_ticket_urls = {ticket.get("ticket_url") for ticket in tickets_content} - for ticket in asana_tickets: - if ticket not in seen_ticket_urls: - tickets_content.append(_make_asana_ticket_content(ticket)) + _append_asana_ticket_content(tickets_content, asana_tickets) return tickets_content if asana_tickets: From cf17141901cde918aecd9f1776fcd066e9dd570b Mon Sep 17 00:00:00 2001 From: Oxygen <100782273+Oxygen56@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:26:29 +0800 Subject: [PATCH 13/17] fix: cap provider-agnostic Asana ticket context --- pr_agent/tools/ticket_pr_compliance_check.py | 47 +++++++------- tests/unittest/test_ticket_compliance.py | 68 ++++++++++++++++++++ 2 files changed, 93 insertions(+), 22 deletions(-) diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 13ae6e053f..2079572da1 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -74,9 +74,15 @@ def _make_asana_ticket_content(ticket: str) -> dict: } -def _append_asana_ticket_content(tickets_content: list, asana_tickets: list) -> None: +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) @@ -154,15 +160,12 @@ 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 = 3 try: - user_description = "" - try: - user_description = git_provider.get_user_description() - except Exception as e: - get_logger().warning( - f"Failed to get PR description for Asana ticket detection: {e}" - ) + 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): description_tickets = extract_ticket_links_from_pr_description( @@ -180,20 +183,20 @@ async def extract_tickets(git_provider): merged.append(link) total_tickets = len(merged) + len(asana_tickets) - asana_tickets_to_include = asana_tickets - if total_tickets > 3: + 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 = 3 - reserved_asana_slots + github_slots = MAX_TICKETS - reserved_asana_slots tickets = merged[:github_slots] - asana_tickets_to_include = asana_tickets[: 3 - len(tickets)] + asana_tickets_to_include = asana_tickets[ + : MAX_TICKETS - len(tickets) + ] else: tickets = merged - tickets_content = [] if tickets: @@ -253,13 +256,8 @@ async def extract_tickets(git_provider): 'sub_issues': sub_issues_content # Store sub-issues content }) - _append_asana_ticket_content(tickets_content, asana_tickets_to_include) - if tickets_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", "") @@ -281,12 +279,17 @@ async def extract_tickets(git_provider): f"Error processing Azure DevOps ticket: {e}", artifact={"traceback": traceback.format_exc()}, ) - _append_asana_ticket_content(tickets_content, asana_tickets) + if asana_tickets and len(tickets_content) >= MAX_TICKETS: + tickets_content = tickets_content[: MAX_TICKETS - 1] + + _append_asana_ticket_content( + tickets_content, + asana_tickets_to_include, + MAX_TICKETS, + ) + if tickets_content: return tickets_content - if asana_tickets: - return [_make_asana_ticket_content(ticket) for ticket in asana_tickets] - except Exception as e: get_logger().error(f"Error extracting tickets error= {e}", artifact={"traceback": traceback.format_exc()}) diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py index 565e9b1111..cdd7f7bc49 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -5,6 +5,7 @@ - Full Asana URL detection - Edge cases (mixed content, no tickets, duplicates) """ +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 @@ -54,6 +55,18 @@ def get_pr_branch(self): return "" +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().""" @@ -198,3 +211,58 @@ async def test_extract_tickets_includes_asana_for_non_github_provider(self): "labels": "", } ] + + 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" From 1c1a693c05137ce270f6b72273b0fc9e73d783c5 Mon Sep 17 00:00:00 2001 From: Oxygen <100782273+Oxygen56@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:52:04 +0800 Subject: [PATCH 14/17] fix: make related ticket cap configurable --- pr_agent/settings/configuration.toml | 1 + pr_agent/tools/ticket_pr_compliance_check.py | 35 +++++++---- tests/unittest/test_ticket_compliance.py | 63 ++++++++++++++++++++ 3 files changed, 88 insertions(+), 11 deletions(-) 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 2079572da1..81b40d0c35 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 @@ -36,8 +35,9 @@ def find_jira_tickets(text): _ASANA_TASK_URL_PATTERN = re.compile( - r'https://app\.asana\.com/0/(\d+)/(\d+)' + r"https://app\.asana\.com/0/(\d+)/(\d+)" ) +DEFAULT_MAX_RELATED_TICKETS = 3 def find_asana_tickets(text: str | None) -> list: @@ -88,6 +88,18 @@ def _append_asana_ticket_content( 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 @@ -138,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: @@ -160,7 +173,7 @@ 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 = 3 + max_tickets = _get_max_related_tickets() try: user_description = git_provider.get_user_description() asana_tickets = find_asana_tickets(user_description) @@ -183,17 +196,17 @@ async def extract_tickets(git_provider): merged.append(link) total_tickets = len(merged) + len(asana_tickets) - if total_tickets > MAX_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 + github_slots = max_tickets - reserved_asana_slots tickets = merged[:github_slots] asana_tickets_to_include = asana_tickets[ - : MAX_TICKETS - len(tickets) + : max_tickets - len(tickets) ] else: tickets = merged @@ -279,13 +292,13 @@ async def extract_tickets(git_provider): f"Error processing Azure DevOps ticket: {e}", artifact={"traceback": traceback.format_exc()}, ) - if asana_tickets and len(tickets_content) >= MAX_TICKETS: - tickets_content = tickets_content[: MAX_TICKETS - 1] + 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, + max_tickets, ) if tickets_content: return tickets_content diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py index cdd7f7bc49..3100f45c0d 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -5,6 +5,7 @@ - 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 @@ -266,3 +267,65 @@ async def test_extract_tickets_caps_azure_asana_references(self): 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" From 61df3f9e4c51ed20529d882e1933623d44e2304e Mon Sep 17 00:00:00 2001 From: Oxygen <100782273+Oxygen56@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:58:31 +0800 Subject: [PATCH 15/17] fix: return empty ticket list when none found --- pr_agent/tools/ticket_pr_compliance_check.py | 1 + tests/unittest/test_ticket_compliance.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 81b40d0c35..50ffeec84a 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -306,6 +306,7 @@ async def extract_tickets(git_provider): 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 index 3100f45c0d..256ff3ba65 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -56,6 +56,11 @@ 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 @@ -213,6 +218,20 @@ async def test_extract_tickets_includes_asana_for_non_github_provider(self): } ] + 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( From c928e6cd9fc28217872cdf55966d89c6a99de80e Mon Sep 17 00:00:00 2001 From: Oxygen <100782273+Oxygen56@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:02:05 +0800 Subject: [PATCH 16/17] fix: always return ticket list --- pr_agent/tools/ticket_pr_compliance_check.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 50ffeec84a..8d2858b8f7 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -300,8 +300,7 @@ async def extract_tickets(git_provider): asana_tickets_to_include, max_tickets, ) - if tickets_content: - return tickets_content + return tickets_content except Exception as e: get_logger().error(f"Error extracting tickets error= {e}", From 557b28c6d9862e721cc84f8a2a218d8c05567f86 Mon Sep 17 00:00:00 2001 From: Oxygen <100782273+Oxygen56@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:24:46 +0800 Subject: [PATCH 17/17] fix: backfill asana tickets after fetch failures --- pr_agent/tools/ticket_pr_compliance_check.py | 3 -- tests/unittest/test_ticket_compliance.py | 33 +++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 8d2858b8f7..dea292184a 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -205,9 +205,6 @@ async def extract_tickets(git_provider): reserved_asana_slots = 1 if asana_tickets else 0 github_slots = max_tickets - reserved_asana_slots tickets = merged[:github_slots] - asana_tickets_to_include = asana_tickets[ - : max_tickets - len(tickets) - ] else: tickets = merged diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py index 256ff3ba65..4760030e03 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -24,13 +24,22 @@ 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): + 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 @@ -197,6 +206,28 @@ async def test_extract_tickets_backfills_with_asana_when_truncated(self): 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(