Skip to content

Commit b296d5b

Browse files
ljavorsknforro
andauthored
Add get_authorized_comments_from_merge_request function
* Add `get_authorized_comments_from_merge_request` function This function helps to scrape relevant comments on the MR * Spliting to private functions and fixing small issues * Update mcp_server/gitlab_tools.py Co-authored-by: Nikola Forró <[email protected]> * Reduce duplicates in the tests for comments in MR --------- Co-authored-by: Nikola Forró <[email protected]>
1 parent e53874d commit b296d5b

File tree

3 files changed

+263
-2
lines changed

3 files changed

+263
-2
lines changed

common/models.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
and components to ensure consistency and type safety.
66
"""
77

8+
from datetime import datetime
89
from typing import Optional, Dict, Any, Union
910
from pydantic import BaseModel, Field
1011
from pathlib import Path
@@ -278,7 +279,7 @@ class CachedMRMetadata(BaseModel):
278279

279280

280281
# ============================================================================
281-
# GitLab Failed Pipeline Jobs Schemas
282+
# GitLab Tools Schemas
282283
# ============================================================================
283284

284285
class FailedPipelineJob(BaseModel):
@@ -292,3 +293,50 @@ class FailedPipelineJob(BaseModel):
292293
artifacts_url: str = Field(
293294
description="URL to browse job artifacts, empty string if no artifacts"
294295
)
296+
297+
298+
class CommentReply(BaseModel):
299+
"""Represents a reply comment in a discussion thread."""
300+
301+
author: str | None = Field(description="Username of the reply author")
302+
message: str | None = Field(description="The reply message text")
303+
created_at: datetime | None = Field(
304+
description="Timestamp when reply was created"
305+
)
306+
307+
308+
class MergeRequestComment(BaseModel):
309+
"""Represents a comment from a GitLab merge request by an authorized member."""
310+
311+
author: str | None = Field(description="Username of the comment author")
312+
message: str | None = Field(description="The comment message text")
313+
created_at: datetime | None = Field(
314+
description="Timestamp when comment was created"
315+
)
316+
file_path: str = Field(
317+
default="",
318+
description="File path if comment targets specific code, "
319+
"empty for general comments"
320+
)
321+
line_number: int | None = Field(
322+
default=None,
323+
description="Line number in the current state of the file. "
324+
"WARNING: If subsequent commits modified the file after this comment "
325+
"was made, this line number may differ from where the comment was "
326+
"originally placed. None for general comments."
327+
)
328+
line_type: str = Field(
329+
default="",
330+
description="Type of line in the diff: 'new' (added line), "
331+
"'old' (removed line), 'unchanged' (context line), "
332+
"or empty for general comments"
333+
)
334+
discussion_id: str = Field(
335+
default="",
336+
description="Discussion/thread ID this comment belongs to"
337+
)
338+
replies: list[CommentReply] = Field(
339+
default_factory=list,
340+
description="List of replies to this comment in the thread, "
341+
"ordered chronologically"
342+
)

mcp_server/gitlab_tools.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@
1212
from ogr.services.gitlab.pull_request import GitlabPullRequest
1313
from pydantic import Field
1414

15-
from common.models import FailedPipelineJob
15+
from common.models import FailedPipelineJob, MergeRequestComment, CommentReply
1616
from common.validators import AbsolutePath
1717
from utils import clean_stale_repositories
1818

1919

2020
logger = logging.getLogger(__name__)
2121

22+
# GitLab access levels: Guest (10), Reporter (20), Developer (30),
23+
# Maintainer (40), Owner (50)
24+
DEVELOPER_ACCESS_LEVEL = 30
25+
2226

2327
def _get_authenticated_url(repository_url: str) -> str:
2428
"""
@@ -386,3 +390,127 @@ def get_latest_pipeline_jobs():
386390
except Exception as e:
387391
logger.error(f"Failed to get failed jobs from MR {merge_request_url}: {e}")
388392
raise ToolError(f"Failed to get failed jobs from merge request: {e}") from e
393+
394+
395+
def _get_authorized_member_ids(project: GitlabProject) -> set[int]:
396+
"""
397+
Fetch all project members and return a set of IDs for members
398+
with Developer role or higher. This avoids N+1 API calls.
399+
"""
400+
try:
401+
members = project.gitlab_repo.members_all.list(get_all=True)
402+
return {
403+
member.id for member in members
404+
if member.access_level >= DEVELOPER_ACCESS_LEVEL
405+
}
406+
except Exception as e:
407+
logger.warning(f"Failed to fetch project members: {e}")
408+
return set()
409+
410+
411+
def _extract_position_info(note: dict) -> tuple[str, int | None, str]:
412+
"""Extract file path, line number, and line type from a note's position."""
413+
if not (position := note.get("position")):
414+
return "", None, ""
415+
416+
file_path = position.get("new_path", "") or position.get("old_path", "")
417+
new_line = position.get("new_line")
418+
old_line = position.get("old_line")
419+
420+
if new_line and old_line:
421+
return file_path, new_line, "unchanged"
422+
elif new_line:
423+
return file_path, new_line, "new"
424+
elif old_line:
425+
return file_path, old_line, "old"
426+
427+
return file_path, None, ""
428+
429+
430+
def _process_reply(
431+
authorized_member_ids: set[int], note: dict
432+
) -> CommentReply | None:
433+
"""Process a reply note and return CommentReply if author is authorized."""
434+
if note.get("system", False):
435+
return None
436+
437+
try:
438+
author = note.get("author", {})
439+
author_id = author.get("id")
440+
if not author_id or author_id not in authorized_member_ids:
441+
return None
442+
443+
return CommentReply(
444+
author=author.get("username"),
445+
message=note.get("body"),
446+
created_at=note.get("created_at"),
447+
)
448+
except Exception as e:
449+
logger.warning(f"Failed to process reply note: {e}")
450+
return None
451+
452+
453+
async def get_authorized_comments_from_merge_request(
454+
merge_request_url: Annotated[str, Field(description="URL of the merge request")],
455+
) -> list[MergeRequestComment]:
456+
"""
457+
Gets all comments from a merge request, filtered to only include
458+
comments from authorized members with Developer role or higher.
459+
"""
460+
try:
461+
mr = await _get_merge_request_from_url(merge_request_url)
462+
463+
def get_authorized_comments():
464+
discussions = mr._raw_pr.discussions.list(get_all=True)
465+
466+
authorized_member_ids = _get_authorized_member_ids(mr.target_project)
467+
468+
authorized_comments = []
469+
for discussion in discussions:
470+
try:
471+
if not (notes := discussion.attributes.get("notes")):
472+
continue
473+
474+
first_note = notes[0]
475+
476+
# Skip system notes (e.g. commit added)
477+
if first_note.get("system"):
478+
continue
479+
480+
author = first_note.get("author", {})
481+
author_id = author.get("id")
482+
if not author_id or author_id not in authorized_member_ids:
483+
continue
484+
485+
file_path, line_number, line_type = (
486+
_extract_position_info(first_note)
487+
)
488+
489+
replies = [
490+
reply for note in notes[1:]
491+
if (reply := _process_reply(authorized_member_ids, note)) is not None
492+
]
493+
494+
authorized_comments.append(
495+
MergeRequestComment(
496+
author=author.get("username"),
497+
message=first_note.get("body"),
498+
created_at=first_note.get("created_at"),
499+
file_path=file_path,
500+
line_number=line_number,
501+
line_type=line_type,
502+
discussion_id=getattr(discussion, "id", ""),
503+
replies=replies,
504+
)
505+
)
506+
except Exception as e:
507+
logger.warning(f"Failed to process discussion: {e}")
508+
continue
509+
510+
return authorized_comments
511+
512+
comments = await asyncio.to_thread(get_authorized_comments)
513+
return comments
514+
515+
except Exception as e:
516+
raise ToolError(f"Failed to get authorized comments from merge request: {e}") from e

mcp_server/tests/unit/test_gitlab_tools.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
add_blocking_merge_request_comment,
2323
retry_pipeline_job,
2424
get_failed_pipeline_jobs_from_merge_request,
25+
get_authorized_comments_from_merge_request,
2526
)
2627
from test_utils import mock_git_repo_basepath
2728

@@ -446,3 +447,87 @@ async def test_get_failed_pipeline_jobs_from_merge_request_invalid_url():
446447
await get_failed_pipeline_jobs_from_merge_request(merge_request_url=merge_request_url)
447448

448449
assert "Could not parse merge request URL" in str(exc_info.value)
450+
451+
452+
@pytest.mark.parametrize(
453+
"discussions,members,expected_count",
454+
[
455+
pytest.param(
456+
[
457+
flexmock(id="d1", attributes={"notes": [{"author": {"id": 1, "username": "dev"}, "body": "Dev", "created_at": "2024-01-15T10:00:00Z", "system": False}]}),
458+
flexmock(id="d2", attributes={"notes": [{"author": {"id": 2, "username": "reporter"}, "body": "Rep", "created_at": "2024-01-15T11:00:00Z", "system": False}]}),
459+
flexmock(id="d3", attributes={"notes": [{"author": {"id": 3, "username": "guest"}, "body": "Guest", "created_at": "2024-01-15T12:00:00Z", "system": False}]}),
460+
],
461+
[flexmock(id=1, access_level=30), flexmock(id=2, access_level=20)],
462+
1,
463+
id="filters_unauthorized",
464+
),
465+
pytest.param(
466+
[],
467+
[],
468+
0,
469+
id="no_comments",
470+
),
471+
pytest.param(
472+
[
473+
flexmock(id="d1", attributes={"notes": [{"author": {"id": 1, "username": "dev1"}, "body": "General", "created_at": "2024-01-15T10:00:00Z", "system": False}]}),
474+
flexmock(id="d2", attributes={"notes": [{"author": {"id": 2, "username": "dev2"}, "body": "Line", "created_at": "2024-01-15T11:00:00Z", "system": False, "position": {"new_path": "f.py", "old_path": "f.py", "new_line": 42, "old_line": None}}]}),
475+
],
476+
[flexmock(id=1, access_level=30), flexmock(id=2, access_level=30)],
477+
2,
478+
id="with_line_context",
479+
),
480+
pytest.param(
481+
[
482+
flexmock(id="d1", attributes={"notes": [
483+
{"author": {"id": 1, "username": "dev1"}, "body": "Q", "created_at": "2024-01-15T10:00:00Z", "system": False},
484+
{"author": {"id": 2, "username": "dev2"}, "body": "A", "created_at": "2024-01-15T10:30:00Z", "system": False},
485+
{"author": {"id": 3, "username": "guest"}, "body": "?", "created_at": "2024-01-15T10:45:00Z", "system": False},
486+
]}),
487+
],
488+
[flexmock(id=1, access_level=30), flexmock(id=2, access_level=30), flexmock(id=3, access_level=10)],
489+
1,
490+
id="with_replies",
491+
),
492+
],
493+
)
494+
@pytest.mark.asyncio
495+
async def test_get_authorized_comments_from_merge_request(discussions, members, expected_count):
496+
merge_request_url = "https://gitlab.com/redhat/centos-stream/rpms/bash/-/merge_requests/123"
497+
498+
flexmock(GitlabService).should_receive("get_project_from_url").with_args(
499+
url="https://gitlab.com/redhat/centos-stream/rpms/bash"
500+
).and_return(
501+
flexmock().should_receive("get_pr").with_args(123).and_return(
502+
flexmock(
503+
_raw_pr=flexmock(
504+
discussions=flexmock().should_receive("list").with_args(
505+
get_all=True
506+
).and_return(discussions).mock()
507+
),
508+
target_project=flexmock(
509+
namespace="redhat/centos-stream/rpms",
510+
repo="bash",
511+
gitlab_repo=flexmock(
512+
members_all=flexmock().should_receive("list").with_args(
513+
get_all=True
514+
).and_return(members).mock()
515+
)
516+
),
517+
)
518+
).mock()
519+
)
520+
521+
result = await get_authorized_comments_from_merge_request(merge_request_url=merge_request_url)
522+
523+
assert len(result) == expected_count
524+
525+
526+
@pytest.mark.asyncio
527+
async def test_get_authorized_comments_invalid_url():
528+
"""Test that invalid URLs raise appropriate errors."""
529+
with pytest.raises(Exception) as exc_info:
530+
await get_authorized_comments_from_merge_request(
531+
merge_request_url="https://github.com/user/repo/pull/123"
532+
)
533+
assert "Could not parse merge request URL" in str(exc_info.value)

0 commit comments

Comments
 (0)