Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
34 changes: 33 additions & 1 deletion packages/python-packages/apiview-copilot/src/_apiview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 42 additions & 3 deletions packages/python-packages/apiview-copilot/src/_report_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -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))
Comment thread
helen229 marked this conversation as resolved.
return "\n".join(parts)


Expand All @@ -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*")
Expand Down Expand Up @@ -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"),
Expand All @@ -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,
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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",
Expand All @@ -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")
Expand Down