Skip to content

Commit 1e8447e

Browse files
helen229Copilot
andauthored
[APIView Copilot] Include full comment thread in Report Issue context (#15524)
* Include full comment thread in Report Issue context get_comment_with_context now also fetches sibling comments sharing the anchor's ThreadId, ordered by CreatedOn, and exposes them as 'thread_comments'. _lookup_comment_context forwards the list (id, text, source, author, created_on) to the prompt context, and _format_comment_context_for_prompt renders them as a chronological 'Thread:' transcript when there is more than one comment so the LLM sees the full conversation, not just the anchor comment. Single-comment threads keep the existing single-line 'Comment:' rendering. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Address Copilot review feedback on PR #15524 1. Avoid GitHub mention spam in fallback issue body: _format_comment_context_for_prompt now accepts escape_mentions=True (used by _build_fallback_body) to wrap thread-author tokens in backticks (e.g. `@alice`) so the deterministic fallback body can never accidentally @-notify real users. The LLM prompt path still gets bare @author so the model sees authorship clearly. 2. Push the IsDeleted filter into the Cosmos thread query (AND (NOT IS_DEFINED(c.IsDeleted) OR c.IsDeleted = false)) so we no longer transfer/sort tombstoned comments client-side. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 1c60131 commit 1e8447e

4 files changed

Lines changed: 150 additions & 5 deletions

File tree

packages/python-packages/apiview-copilot/prompts/report_issue/generate_issue.prompty

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ You receive these inputs:
3737
* **language** — optional language hint (e.g. `python`, `C#`). May be empty.
3838
* **comment_context** — optional structured snapshot of the comment thread
3939
the user was viewing (source, language, comment text, code snippet,
40-
element id). Use it to infer what the user is talking about.
40+
element id). When the thread has more than one comment, an additional
41+
`Thread:` block lists every comment in chronological order as
42+
`@author (timestamp): text` so you can see the full conversation, not
43+
just the anchor comment. Use it to infer what the user is talking about.
4144

4245
You must determine the **category** yourself. Allowed categories:
4346

packages/python-packages/apiview-copilot/src/_apiview.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1466,7 +1466,10 @@ def get_comment_with_context(comment_id: str, environment: str = "production") -
14661466
14671467
Returns:
14681468
A dict containing:
1469-
- comment: The full comment object from the database
1469+
- comment: The full comment object from the database (the anchor comment)
1470+
- thread_comments: List of non-deleted comments sharing the same
1471+
ThreadId, in chronological order. Deleted comments, including the
1472+
anchor comment, may be excluded from this list.
14701473
- language: The pretty language name (e.g., "Python")
14711474
- package_name: The package name from the review
14721475
- code: The API code from the revision (if available)
@@ -1501,6 +1504,34 @@ def get_comment_with_context(comment_id: str, environment: str = "production") -
15011504
comment = results[0]
15021505
review_id = comment.get("ReviewId")
15031506
revision_id = comment.get("APIRevisionId")
1507+
thread_id = comment.get("ThreadId")
1508+
1509+
# Fetch sibling comments in the same thread (chronological order) so
1510+
# callers can render the full conversation, not just the anchor comment.
1511+
# Filter out tombstoned (IsDeleted) entries server-side so we only
1512+
# transfer / sort live comments.
1513+
thread_comments: list[dict] = [comment]
1514+
if thread_id:
1515+
thread_query = """
1516+
SELECT c.id, c.CommentText, c.CommentSource, c.CreatedBy, c.CreatedOn,
1517+
c.IsResolved, c.IsDeleted, c.ThreadId
1518+
FROM c
1519+
WHERE c.ThreadId = @thread_id
1520+
AND (NOT IS_DEFINED(c.IsDeleted) OR c.IsDeleted = false)
1521+
ORDER BY c.CreatedOn ASC
1522+
"""
1523+
try:
1524+
thread_results = list(
1525+
comments_container.query_items(
1526+
query=thread_query,
1527+
parameters=[{"name": "@thread_id", "value": thread_id}],
1528+
enable_cross_partition_query=True,
1529+
)
1530+
)
1531+
thread_comments = thread_results or [comment]
1532+
except Exception as e:
1533+
print(f"Warning: Could not fetch thread siblings for {thread_id}: {e}")
1534+
thread_comments = [comment]
15041535

15051536
# Get language and package name from Reviews container
15061537
language = None
@@ -1568,6 +1599,7 @@ def get_comment_with_context(comment_id: str, environment: str = "production") -
15681599

15691600
return {
15701601
"comment": comment,
1602+
"thread_comments": thread_comments,
15711603
"language": language,
15721604
"package_name": package_name,
15731605
"code": code,

packages/python-packages/apiview-copilot/src/_report_issue.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,14 @@ def _build_labels(category: str, language: Optional[str]) -> list[str]:
6767
return ["APIView"]
6868

6969

70-
def _format_comment_context_for_prompt(ctx: Optional[dict]) -> str:
71-
"""Render the optional comment context as plain text for the LLM."""
70+
def _format_comment_context_for_prompt(ctx: Optional[dict], *, escape_mentions: bool = False) -> str:
71+
"""Render the optional comment context as plain text.
72+
73+
When ``escape_mentions`` is True (use this for content that will be
74+
posted to GitHub, e.g. the deterministic fallback issue body), author
75+
names in the rendered thread transcript are wrapped so they cannot
76+
trigger GitHub ``@mention`` notifications.
77+
"""
7278
if not ctx:
7379
return ""
7480
parts: list[str] = []
@@ -82,6 +88,27 @@ def _format_comment_context_for_prompt(ctx: Optional[dict]) -> str:
8288
value = ctx.get(key)
8389
if value:
8490
parts.append(f"{label}: {value}")
91+
thread = ctx.get("thread_comments") or []
92+
# Only render a transcript when there is more than the anchor comment;
93+
# the single-comment case is already covered by the "Comment" line above.
94+
if len(thread) > 1:
95+
transcript_lines: list[str] = []
96+
for entry in thread:
97+
author = entry.get("created_by") or "unknown"
98+
created_on = entry.get("created_on") or ""
99+
text = (entry.get("comment_text") or "").strip()
100+
if not text:
101+
continue
102+
# In the GitHub issue body we wrap the author in backticks so
103+
# the literal ``@author`` text does not turn into a real mention
104+
# / notification. The LLM prompt keeps the bare ``@author``
105+
# form so the model can still see authorship clearly.
106+
header = f"`@{author}`" if escape_mentions else f"@{author}"
107+
if created_on:
108+
header = f"{header} ({created_on})"
109+
transcript_lines.append(f"{header}: {text}")
110+
if transcript_lines:
111+
parts.append("Thread:\n" + "\n".join(transcript_lines))
85112
return "\n".join(parts)
86113

87114

@@ -91,7 +118,7 @@ def _build_fallback_body(description: str, review_link: Optional[str], comment_c
91118
if review_link:
92119
sections.append(f"## Review Link\n\n{review_link}")
93120
sections.append(f"## Description\n\n{description}")
94-
ctx_text = _format_comment_context_for_prompt(comment_context)
121+
ctx_text = _format_comment_context_for_prompt(comment_context, escape_mentions=True)
95122
if ctx_text:
96123
sections.append("## Comment Context\n\n" + ctx_text)
97124
sections.append("---\n*Reported via APIView*")
@@ -127,6 +154,17 @@ def _lookup_comment_context(comment_id: str) -> Optional[dict]:
127154
if not ctx:
128155
return None
129156
comment_obj = ctx.get("comment") or {}
157+
thread_raw = ctx.get("thread_comments") or []
158+
thread_comments = [
159+
{
160+
"id": entry.get("id"),
161+
"comment_text": entry.get("CommentText"),
162+
"comment_source": entry.get("CommentSource"),
163+
"created_by": entry.get("CreatedBy"),
164+
"created_on": entry.get("CreatedOn"),
165+
}
166+
for entry in thread_raw
167+
]
130168
return {
131169
"comment_text": comment_obj.get("CommentText"),
132170
"comment_source": comment_obj.get("CommentSource"),
@@ -135,6 +173,7 @@ def _lookup_comment_context(comment_id: str) -> Optional[dict]:
135173
"element_id": comment_obj.get("ElementId"),
136174
"review_id": comment_obj.get("ReviewId"),
137175
"revision_id": comment_obj.get("APIRevisionId"),
176+
"thread_comments": thread_comments,
138177
}
139178

140179

packages/python-packages/apiview-copilot/tests/report_issue_test.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,45 @@ def test_empty_dict(self):
101101
def test_none(self):
102102
assert _format_comment_context_for_prompt(None) == ""
103103

104+
def test_single_comment_thread_does_not_emit_transcript(self):
105+
text = _format_comment_context_for_prompt({
106+
"comment_text": "lone comment",
107+
"thread_comments": [
108+
{"comment_text": "lone comment", "created_by": "alice", "created_on": "2026-05-01T00:00:00Z"},
109+
],
110+
})
111+
assert "Thread:" not in text
112+
assert "Comment: lone comment" in text
113+
114+
def test_multi_comment_thread_emits_transcript(self):
115+
text = _format_comment_context_for_prompt({
116+
"comment_text": "first",
117+
"thread_comments": [
118+
{"comment_text": "first", "created_by": "alice", "created_on": "2026-05-01T00:00:00Z"},
119+
{"comment_text": "second", "created_by": "bob", "created_on": "2026-05-02T00:00:00Z"},
120+
],
121+
})
122+
assert "Thread:" in text
123+
assert "@alice (2026-05-01T00:00:00Z): first" in text
124+
assert "@bob (2026-05-02T00:00:00Z): second" in text
125+
126+
def test_escape_mentions_wraps_authors_in_backticks(self):
127+
text = _format_comment_context_for_prompt(
128+
{
129+
"comment_text": "first",
130+
"thread_comments": [
131+
{"comment_text": "first", "created_by": "alice", "created_on": "2026-05-01T00:00:00Z"},
132+
{"comment_text": "second", "created_by": "bob", "created_on": "2026-05-02T00:00:00Z"},
133+
],
134+
},
135+
escape_mentions=True,
136+
)
137+
assert "`@alice` (2026-05-01T00:00:00Z): first" in text
138+
assert "`@bob` (2026-05-02T00:00:00Z): second" in text
139+
# No bare @author tokens that GitHub would turn into mentions.
140+
assert "@alice" not in text.replace("`@alice`", "")
141+
assert "@bob" not in text.replace("`@bob`", "")
142+
104143

105144
class TestBuildFallbackTitleSnippet:
106145
def test_short(self):
@@ -280,6 +319,22 @@ def test_maps_db_payload_to_comment_context(self, mock_get):
280319
"ReviewId": "r-1",
281320
"APIRevisionId": "rev-2",
282321
},
322+
"thread_comments": [
323+
{
324+
"id": "c1",
325+
"CommentText": "remove async",
326+
"CommentSource": "copilot",
327+
"CreatedBy": "azure-sdk",
328+
"CreatedOn": "2026-05-01T00:00:00Z",
329+
},
330+
{
331+
"id": "c2",
332+
"CommentText": "actually it should stay async",
333+
"CommentSource": "UserGenerated",
334+
"CreatedBy": "alice",
335+
"CreatedOn": "2026-05-02T00:00:00Z",
336+
},
337+
],
283338
"code": "async def upload_blob(self, name: str, data: bytes) -> None: ...",
284339
"language": "Python",
285340
"package_name": "azure-storage-blob",
@@ -293,6 +348,22 @@ def test_maps_db_payload_to_comment_context(self, mock_get):
293348
"element_id": "AsyncBlobClient.upload_blob",
294349
"review_id": "r-1",
295350
"revision_id": "rev-2",
351+
"thread_comments": [
352+
{
353+
"id": "c1",
354+
"comment_text": "remove async",
355+
"comment_source": "copilot",
356+
"created_by": "azure-sdk",
357+
"created_on": "2026-05-01T00:00:00Z",
358+
},
359+
{
360+
"id": "c2",
361+
"comment_text": "actually it should stay async",
362+
"comment_source": "UserGenerated",
363+
"created_by": "alice",
364+
"created_on": "2026-05-02T00:00:00Z",
365+
},
366+
],
296367
}
297368

298369
@patch("src._report_issue.os.getenv")

0 commit comments

Comments
 (0)