diff --git a/packages/python-packages/apiview-copilot/prompts/report_issue/generate_issue.prompty b/packages/python-packages/apiview-copilot/prompts/report_issue/generate_issue.prompty index f37f4b647be..ba47d863e8e 100644 --- a/packages/python-packages/apiview-copilot/prompts/report_issue/generate_issue.prompty +++ b/packages/python-packages/apiview-copilot/prompts/report_issue/generate_issue.prompty @@ -37,7 +37,10 @@ You receive these inputs: * **language** — optional language hint (e.g. `python`, `C#`). May be empty. * **comment_context** — optional structured snapshot of the comment thread the user was viewing (source, language, comment text, code snippet, - element id). Use it to infer what the user is talking about. + element id). When the thread has more than one comment, an additional + `Thread:` block lists every comment in chronological order as + `@author (timestamp): text` so you can see the full conversation, not + just the anchor comment. Use it to infer what the user is talking about. You must determine the **category** yourself. Allowed categories: diff --git a/packages/python-packages/apiview-copilot/src/_apiview.py b/packages/python-packages/apiview-copilot/src/_apiview.py index 1fe7799dc24..b5fabca9fa4 100644 --- a/packages/python-packages/apiview-copilot/src/_apiview.py +++ b/packages/python-packages/apiview-copilot/src/_apiview.py @@ -1323,7 +1323,10 @@ def get_comment_with_context(comment_id: str, environment: str = "production") - Returns: A dict containing: - - comment: The full comment object from the database + - comment: The full comment object from the database (the anchor comment) + - thread_comments: List of non-deleted comments sharing the same + ThreadId, in chronological order. Deleted comments, including the + anchor comment, may be excluded from this list. - language: The pretty language name (e.g., "Python") - package_name: The package name from the review - code: The API code from the revision (if available) @@ -1358,6 +1361,34 @@ def get_comment_with_context(comment_id: str, environment: str = "production") - comment = results[0] review_id = comment.get("ReviewId") revision_id = comment.get("APIRevisionId") + thread_id = comment.get("ThreadId") + + # Fetch sibling comments in the same thread (chronological order) so + # callers can render the full conversation, not just the anchor comment. + # Filter out tombstoned (IsDeleted) entries server-side so we only + # transfer / sort live comments. + thread_comments: list[dict] = [comment] + if thread_id: + thread_query = """ + SELECT c.id, c.CommentText, c.CommentSource, c.CreatedBy, c.CreatedOn, + c.IsResolved, c.IsDeleted, c.ThreadId + FROM c + WHERE c.ThreadId = @thread_id + AND (NOT IS_DEFINED(c.IsDeleted) OR c.IsDeleted = false) + ORDER BY c.CreatedOn ASC + """ + try: + thread_results = list( + comments_container.query_items( + query=thread_query, + parameters=[{"name": "@thread_id", "value": thread_id}], + enable_cross_partition_query=True, + ) + ) + thread_comments = thread_results or [comment] + except Exception as e: + print(f"Warning: Could not fetch thread siblings for {thread_id}: {e}") + thread_comments = [comment] # Get language and package name from Reviews container language = None @@ -1425,6 +1456,7 @@ def get_comment_with_context(comment_id: str, environment: str = "production") - return { "comment": comment, + "thread_comments": thread_comments, "language": language, "package_name": package_name, "code": code, diff --git a/packages/python-packages/apiview-copilot/src/_report_issue.py b/packages/python-packages/apiview-copilot/src/_report_issue.py index 469036d19b4..1ded3b184d6 100644 --- a/packages/python-packages/apiview-copilot/src/_report_issue.py +++ b/packages/python-packages/apiview-copilot/src/_report_issue.py @@ -67,8 +67,14 @@ def _build_labels(category: str, language: Optional[str]) -> list[str]: return ["APIView"] -def _format_comment_context_for_prompt(ctx: Optional[dict]) -> str: - """Render the optional comment context as plain text for the LLM.""" +def _format_comment_context_for_prompt(ctx: Optional[dict], *, escape_mentions: bool = False) -> str: + """Render the optional comment context as plain text. + + When ``escape_mentions`` is True (use this for content that will be + posted to GitHub, e.g. the deterministic fallback issue body), author + names in the rendered thread transcript are wrapped so they cannot + trigger GitHub ``@mention`` notifications. + """ if not ctx: return "" parts: list[str] = [] @@ -82,6 +88,27 @@ def _format_comment_context_for_prompt(ctx: Optional[dict]) -> str: value = ctx.get(key) if value: parts.append(f"{label}: {value}") + thread = ctx.get("thread_comments") or [] + # Only render a transcript when there is more than the anchor comment; + # the single-comment case is already covered by the "Comment" line above. + if len(thread) > 1: + transcript_lines: list[str] = [] + for entry in thread: + author = entry.get("created_by") or "unknown" + created_on = entry.get("created_on") or "" + text = (entry.get("comment_text") or "").strip() + if not text: + continue + # In the GitHub issue body we wrap the author in backticks so + # the literal ``@author`` text does not turn into a real mention + # / notification. The LLM prompt keeps the bare ``@author`` + # form so the model can still see authorship clearly. + header = f"`@{author}`" if escape_mentions else f"@{author}" + if created_on: + header = f"{header} ({created_on})" + transcript_lines.append(f"{header}: {text}") + if transcript_lines: + parts.append("Thread:\n" + "\n".join(transcript_lines)) return "\n".join(parts) @@ -91,7 +118,7 @@ def _build_fallback_body(description: str, review_link: Optional[str], comment_c if review_link: sections.append(f"## Review Link\n\n{review_link}") sections.append(f"## Description\n\n{description}") - ctx_text = _format_comment_context_for_prompt(comment_context) + ctx_text = _format_comment_context_for_prompt(comment_context, escape_mentions=True) if ctx_text: sections.append("## Comment Context\n\n" + ctx_text) sections.append("---\n*Reported via APIView*") @@ -127,6 +154,17 @@ def _lookup_comment_context(comment_id: str) -> Optional[dict]: if not ctx: return None comment_obj = ctx.get("comment") or {} + thread_raw = ctx.get("thread_comments") or [] + thread_comments = [ + { + "id": entry.get("id"), + "comment_text": entry.get("CommentText"), + "comment_source": entry.get("CommentSource"), + "created_by": entry.get("CreatedBy"), + "created_on": entry.get("CreatedOn"), + } + for entry in thread_raw + ] return { "comment_text": comment_obj.get("CommentText"), "comment_source": comment_obj.get("CommentSource"), @@ -135,6 +173,7 @@ def _lookup_comment_context(comment_id: str) -> Optional[dict]: "element_id": comment_obj.get("ElementId"), "review_id": comment_obj.get("ReviewId"), "revision_id": comment_obj.get("APIRevisionId"), + "thread_comments": thread_comments, } diff --git a/packages/python-packages/apiview-copilot/tests/report_issue_test.py b/packages/python-packages/apiview-copilot/tests/report_issue_test.py index d42db7aaf56..9bde946b5b3 100644 --- a/packages/python-packages/apiview-copilot/tests/report_issue_test.py +++ b/packages/python-packages/apiview-copilot/tests/report_issue_test.py @@ -101,6 +101,45 @@ def test_empty_dict(self): def test_none(self): assert _format_comment_context_for_prompt(None) == "" + def test_single_comment_thread_does_not_emit_transcript(self): + text = _format_comment_context_for_prompt({ + "comment_text": "lone comment", + "thread_comments": [ + {"comment_text": "lone comment", "created_by": "alice", "created_on": "2026-05-01T00:00:00Z"}, + ], + }) + assert "Thread:" not in text + assert "Comment: lone comment" in text + + def test_multi_comment_thread_emits_transcript(self): + text = _format_comment_context_for_prompt({ + "comment_text": "first", + "thread_comments": [ + {"comment_text": "first", "created_by": "alice", "created_on": "2026-05-01T00:00:00Z"}, + {"comment_text": "second", "created_by": "bob", "created_on": "2026-05-02T00:00:00Z"}, + ], + }) + assert "Thread:" in text + assert "@alice (2026-05-01T00:00:00Z): first" in text + assert "@bob (2026-05-02T00:00:00Z): second" in text + + def test_escape_mentions_wraps_authors_in_backticks(self): + text = _format_comment_context_for_prompt( + { + "comment_text": "first", + "thread_comments": [ + {"comment_text": "first", "created_by": "alice", "created_on": "2026-05-01T00:00:00Z"}, + {"comment_text": "second", "created_by": "bob", "created_on": "2026-05-02T00:00:00Z"}, + ], + }, + escape_mentions=True, + ) + assert "`@alice` (2026-05-01T00:00:00Z): first" in text + assert "`@bob` (2026-05-02T00:00:00Z): second" in text + # No bare @author tokens that GitHub would turn into mentions. + assert "@alice" not in text.replace("`@alice`", "") + assert "@bob" not in text.replace("`@bob`", "") + class TestBuildFallbackTitleSnippet: def test_short(self): @@ -280,6 +319,22 @@ def test_maps_db_payload_to_comment_context(self, mock_get): "ReviewId": "r-1", "APIRevisionId": "rev-2", }, + "thread_comments": [ + { + "id": "c1", + "CommentText": "remove async", + "CommentSource": "copilot", + "CreatedBy": "azure-sdk", + "CreatedOn": "2026-05-01T00:00:00Z", + }, + { + "id": "c2", + "CommentText": "actually it should stay async", + "CommentSource": "UserGenerated", + "CreatedBy": "alice", + "CreatedOn": "2026-05-02T00:00:00Z", + }, + ], "code": "async def upload_blob(self, name: str, data: bytes) -> None: ...", "language": "Python", "package_name": "azure-storage-blob", @@ -293,6 +348,22 @@ def test_maps_db_payload_to_comment_context(self, mock_get): "element_id": "AsyncBlobClient.upload_blob", "review_id": "r-1", "revision_id": "rev-2", + "thread_comments": [ + { + "id": "c1", + "comment_text": "remove async", + "comment_source": "copilot", + "created_by": "azure-sdk", + "created_on": "2026-05-01T00:00:00Z", + }, + { + "id": "c2", + "comment_text": "actually it should stay async", + "comment_source": "UserGenerated", + "created_by": "alice", + "created_on": "2026-05-02T00:00:00Z", + }, + ], } @patch("src._report_issue.os.getenv")