Skip to content

Commit 2ddc9fe

Browse files
Adds a new MCP resource endpoint to read bugs from Bugzilla (#5293)
1 parent 9fcf749 commit 2ddc9fe

File tree

4 files changed

+388
-8
lines changed

4 files changed

+388
-8
lines changed

bugbug/tools/code_review.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,202 @@ def patch_title(self) -> str:
517517
return self._revision_metadata["fields"]["title"]
518518

519519

520+
def create_bug_timeline(comments: list[dict], history: list[dict]) -> list[str]:
521+
"""Create a unified timeline from bug history and comments."""
522+
events = []
523+
524+
ignored_fields = {"cc", "flagtypes.name"}
525+
526+
# Add history events
527+
for event in history:
528+
changes = [
529+
change
530+
for change in event["changes"]
531+
if change["field_name"] not in ignored_fields
532+
]
533+
if not changes:
534+
continue
535+
536+
events.append(
537+
{
538+
"time": event["when"],
539+
"type": "change",
540+
"who": event["who"],
541+
"details": changes,
542+
}
543+
)
544+
545+
# Add comments
546+
for comment in comments:
547+
events.append(
548+
{
549+
"time": comment["time"],
550+
"type": "comment",
551+
"who": comment["author"],
552+
"id": comment["id"],
553+
"count": comment["count"],
554+
"text": comment["text"],
555+
}
556+
)
557+
558+
# Sort by timestamp
559+
events.sort(key=lambda x: (x["time"], x["type"] == "change"))
560+
561+
# Format timeline
562+
timeline = []
563+
564+
last_event = None
565+
for event in events:
566+
date = event["time"][:10]
567+
time = event["time"][11:19]
568+
569+
if last_event and last_event["time"] != event["time"]:
570+
timeline.append("---\n")
571+
572+
last_event = event
573+
574+
if event["type"] == "comment":
575+
timeline.append(
576+
f"**{date} {time}** - Comment #{event['count']} by {event['who']}"
577+
)
578+
timeline.append(f"{event['text']}\n")
579+
else:
580+
timeline.append(f"**{date} {time}** - Changes by {event['who']}")
581+
for change in event["details"]:
582+
field = change.get("field_name", "unknown")
583+
old = change.get("removed", "")
584+
new = change.get("added", "")
585+
if old or new:
586+
timeline.append(f" - {field}: '{old}' → '{new}'")
587+
timeline.append("")
588+
589+
return timeline
590+
591+
592+
def bug_dict_to_markdown(bug):
593+
md_lines = []
594+
595+
# Header with bug ID and summary
596+
md_lines.append(
597+
f"# Bug {bug.get('id', 'Unknown')} - {bug.get('summary', 'No summary')}"
598+
)
599+
md_lines.append("")
600+
601+
# Basic Information
602+
md_lines.append("## Basic Information")
603+
md_lines.append(f"- **Status**: {bug.get('status', 'Unknown')}")
604+
md_lines.append(f"- **Severity**: {bug.get('severity', 'Unknown')}")
605+
md_lines.append(f"- **Product**: {bug.get('product', 'Unknown')}")
606+
md_lines.append(f"- **Component**: {bug.get('component', 'Unknown')}")
607+
md_lines.append(f"- **Version**: {bug.get('version', 'Unknown')}")
608+
md_lines.append(f"- **Platform**: {bug.get('platform', 'Unknown')}")
609+
md_lines.append(f"- **OS**: {bug.get('op_sys', 'Unknown')}")
610+
md_lines.append(f"- **Created**: {bug.get('creation_time', 'Unknown')}")
611+
md_lines.append(f"- **Last Updated**: {bug.get('last_change_time', 'Unknown')}")
612+
613+
if bug.get("url"):
614+
md_lines.append(f"- **Related URL**: {bug['url']}")
615+
616+
if bug.get("keywords"):
617+
md_lines.append(f"- **Keywords**: {', '.join(bug['keywords'])}")
618+
619+
md_lines.append("")
620+
621+
# People Involved
622+
md_lines.append("## People Involved")
623+
624+
creator_detail = bug.get("creator_detail", {})
625+
if creator_detail:
626+
creator_name = creator_detail.get(
627+
"real_name",
628+
creator_detail.get("nick", creator_detail.get("email", "Unknown")),
629+
)
630+
md_lines.append(
631+
f"- **Reporter**: {creator_name} ({creator_detail.get('email', 'No email')})"
632+
)
633+
634+
assignee_detail = bug.get("assigned_to_detail", {})
635+
if assignee_detail:
636+
assignee_name = assignee_detail.get(
637+
"real_name",
638+
assignee_detail.get("nick", assignee_detail.get("email", "Unknown")),
639+
)
640+
md_lines.append(
641+
f"- **Assignee**: {assignee_name} ({assignee_detail.get('email', 'No email')})"
642+
)
643+
644+
# CC List (summarized)
645+
cc_count = len(bug.get("cc", []))
646+
if cc_count > 0:
647+
md_lines.append(f"- **CC Count**: {cc_count} people")
648+
649+
md_lines.append("")
650+
651+
# Dependencies and Relationships
652+
relationships = []
653+
if bug.get("blocks"):
654+
relationships.append(f"**Blocks**: {', '.join(map(str, bug['blocks']))}")
655+
if bug.get("depends_on"):
656+
relationships.append(
657+
f"**Depends on**: {', '.join(map(str, bug['depends_on']))}"
658+
)
659+
if bug.get("regressed_by"):
660+
relationships.append(
661+
f"**Regressed by**: {', '.join(map(str, bug['regressed_by']))}"
662+
)
663+
if bug.get("duplicates"):
664+
relationships.append(
665+
f"**Duplicates**: {', '.join(map(str, bug['duplicates']))}"
666+
)
667+
if bug.get("see_also"):
668+
relationships.append(f"**See also**: {', '.join(bug['see_also'])}")
669+
670+
if relationships:
671+
md_lines.append("## Bug Relationships")
672+
for rel in relationships:
673+
md_lines.append(f"- {rel}")
674+
md_lines.append("")
675+
676+
timeline = create_bug_timeline(bug["comments"], bug["history"])
677+
if timeline:
678+
md_lines.append("## Bug Timeline")
679+
md_lines.append("")
680+
md_lines.extend(timeline)
681+
682+
return "\n".join(md_lines)
683+
684+
685+
class Bug:
686+
"""Represents a Bugzilla bug from bugzilla.mozilla.org."""
687+
688+
def __init__(self, data: dict):
689+
self.metadata = data
690+
691+
@staticmethod
692+
def get(bug_id: int) -> "Bug":
693+
from libmozdata.bugzilla import Bugzilla
694+
695+
bugs: list[dict] = []
696+
Bugzilla(
697+
bug_id,
698+
include_fields=["_default", "comments", "history"],
699+
bughandler=lambda bug, data: data.append(bug),
700+
bugdata=bugs,
701+
).get_data().wait()
702+
703+
if not bugs:
704+
raise ValueError(f"Bug {bug_id} not found")
705+
706+
bug_data = bugs[0]
707+
assert bug_data["id"] == bug_id
708+
709+
return Bug(bug_data)
710+
711+
def to_md(self) -> str:
712+
"""Return a markdown representation of the bug."""
713+
return bug_dict_to_markdown(self.metadata)
714+
715+
520716
class ReviewData(ABC):
521717
NIT_PATTERN = re.compile(r"[^a-zA-Z0-9]nit[\s:,]", re.IGNORECASE)
522718

mcp/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ dependencies = [
77
"bugbug>=0.0.590",
88
"fastmcp>=2.12.0",
99
]
10+
11+
[tool.uv.sources]
12+
bugbug = { path = "..", editable = true }

mcp/src/bugbug_mcp/server.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from bugbug import phabricator, utils
1515
from bugbug.code_search.searchfox_api import FunctionSearchSearchfoxAPI
16-
from bugbug.tools.code_review import PhabricatorPatch
16+
from bugbug.tools.code_review import Bug, PhabricatorPatch
1717
from bugbug.utils import get_secret
1818

1919
mcp = FastMCP("BugBug Code Review MCP Server")
@@ -105,10 +105,15 @@ def get_code_review_tool():
105105
from bugbug.tools.code_review import CodeReviewTool, ReviewCommentsDB
106106
from bugbug.vectordb import QdrantVectorDB
107107

108+
# FIXME: This is a workaround, we should refactor CodeReviewTool to avoid this.
109+
class MockLLM(RunnablePassthrough):
110+
def bind_tools(self, *args, **kwargs):
111+
return self
112+
108113
review_comments_db = ReviewCommentsDB(QdrantVectorDB("diff_comments"))
109114

110115
tool = CodeReviewTool(
111-
[RunnablePassthrough()],
116+
MockLLM(),
112117
review_comments_db=review_comments_db,
113118
)
114119

@@ -212,6 +217,16 @@ def find_function_definition(
212217
return functions[0].source
213218

214219

220+
@mcp.resource(
221+
uri="bugzilla://bug/{bug_id}",
222+
name="Bugzilla Bug Content",
223+
mime_type="text/markdown",
224+
)
225+
def handle_bug_view_resource(bug_id: int) -> str:
226+
"""Retrieve a bug from Bugzilla alongside its change history and comments."""
227+
return Bug.get(bug_id).to_md()
228+
229+
215230
def main():
216231
phabricator.set_api_key(
217232
get_secret("PHABRICATOR_URL"), get_secret("PHABRICATOR_TOKEN")

0 commit comments

Comments
 (0)