Skip to content

Commit e4bbd15

Browse files
committed
Spliting to private functions and fixing small issues
1 parent c3df700 commit e4bbd15

File tree

3 files changed

+187
-100
lines changed

3 files changed

+187
-100
lines changed

common/models.py

Lines changed: 6 additions & 5 deletions
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
@@ -299,8 +300,8 @@ class CommentReply(BaseModel):
299300

300301
author: str = Field(description="Username of the reply author")
301302
message: str = Field(description="The reply message text")
302-
created_at: str = Field(
303-
description="ISO 8601 timestamp when reply was created"
303+
created_at: datetime = Field(
304+
description="Timestamp when reply was created"
304305
)
305306

306307

@@ -309,8 +310,8 @@ class MergeRequestComment(BaseModel):
309310

310311
author: str = Field(description="Username of the comment author")
311312
message: str = Field(description="The comment message text")
312-
created_at: str = Field(
313-
description="ISO 8601 timestamp when comment was created"
313+
created_at: datetime = Field(
314+
description="Timestamp when comment was created"
314315
)
315316
file_path: str = Field(
316317
default="",
@@ -338,4 +339,4 @@ class MergeRequestComment(BaseModel):
338339
default_factory=list,
339340
description="List of replies to this comment in the thread, "
340341
"ordered chronologically"
341-
)
342+
)

mcp_server/gitlab_tools.py

Lines changed: 100 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
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
"""
@@ -388,105 +392,122 @@ def get_latest_pipeline_jobs():
388392
raise ToolError(f"Failed to get failed jobs from merge request: {e}") from e
389393

390394

395+
def _get_authorized_member_ids(project) -> 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.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+
position = note.get("position")
414+
if not position:
415+
return "", None, ""
416+
417+
file_path = position.get("new_path", "") or position.get("old_path", "")
418+
new_line = position.get("new_line")
419+
old_line = position.get("old_line")
420+
421+
if new_line and old_line:
422+
return file_path, new_line, "unchanged"
423+
elif new_line:
424+
return file_path, new_line, "new"
425+
elif old_line:
426+
return file_path, old_line, "old"
427+
428+
return file_path, None, ""
429+
430+
431+
def _process_reply(
432+
authorized_member_ids: set[int], note: dict
433+
) -> CommentReply | None:
434+
"""Process a reply note and return CommentReply if author is authorized."""
435+
if note.get("system", False):
436+
return None
437+
438+
try:
439+
author = note.get("author", {})
440+
author_id = author.get("id")
441+
if not author_id or author_id not in authorized_member_ids:
442+
return None
443+
444+
return CommentReply(
445+
author=author.get("username", "unknown"),
446+
message=note.get("body", ""),
447+
created_at=note.get("created_at", ""),
448+
)
449+
except Exception as e:
450+
logger.warning(f"Failed to process reply note: {e}")
451+
return None
452+
453+
391454
async def get_authorized_comments_from_merge_request(
392455
merge_request_url: Annotated[str, Field(description="URL of the merge request")],
393456
) -> list[MergeRequestComment]:
394457
"""
395458
Gets all comments from a merge request, filtered to only include
396459
comments from authorized members with Developer role or higher.
397-
Access levels: Guest (10), Reporter (20), Developer (30),
398-
Maintainer (40), Owner (50).
399460
"""
400461
try:
401462
mr = await _get_merge_request_from_url(merge_request_url)
402463

403464
def get_authorized_comments():
404-
# Get discussions instead of notes to capture thread context
405465
discussions = mr._raw_pr.discussions.list(get_all=True)
406466
project = mr.target_project.gitlab_repo
407467

408-
logger.info(f"Found {len(discussions)} discussions in MR")
468+
# Fetch all authorized members once to avoid N+1 API calls
469+
authorized_member_ids = _get_authorized_member_ids(project)
409470

410471
authorized_comments = []
411472
for discussion in discussions:
412-
discussion_id = discussion.id
413-
notes = discussion.attributes.get("notes", [])
414-
415-
if not notes:
416-
continue
417-
418-
# Process first note (thread starter)
419-
first_note = notes[0]
420-
421-
# Skip system notes (like "added 1 commit")
422-
if first_note.get("system", False): continue
423-
424-
first_author_id = first_note["author"]["id"]
425-
first_author_username = first_note["author"]["username"]
426-
427473
try:
428-
first_member = project.members_all.get(first_author_id)
429-
430-
# Only process thread if starter is Developer+
431-
if first_member.access_level >= 30:
432-
file_path = ""
433-
line_number = None
434-
line_type = ""
435-
436-
position = first_note.get("position")
437-
438-
if position:
439-
file_path = (position.get("new_path", "") or position.get("old_path", ""))
440-
new_line = position.get("new_line")
441-
old_line = position.get("old_line")
442-
443-
if new_line and old_line:
444-
# Both present = unchanged/context line
445-
line_number = new_line
446-
line_type = "unchanged"
447-
elif new_line:
448-
line_number = new_line
449-
line_type = "new"
450-
elif old_line:
451-
line_number = old_line
452-
line_type = "old"
453-
454-
replies = []
455-
for reply_note in notes[1:]:
456-
if reply_note.get("system", False): continue
457-
458-
reply_author_id = reply_note["author"]["id"]
459-
reply_author_username = reply_note["author"]["username"]
460-
461-
try:
462-
reply_member = project.members_all.get(reply_author_id)
463-
464-
if reply_member.access_level >= 30:
465-
replies.append(
466-
CommentReply(
467-
author=reply_author_username,
468-
message=reply_note["body"],
469-
created_at=reply_note["created_at"],
470-
)
471-
)
472-
except Exception as e:
473-
# Reply author is not an authorized member
474-
continue
475-
476-
authorized_comments.append(
477-
MergeRequestComment(
478-
author=first_author_username,
479-
message=first_note["body"],
480-
created_at=first_note["created_at"],
481-
file_path=file_path,
482-
line_number=line_number,
483-
line_type=line_type,
484-
discussion_id=discussion_id,
485-
replies=replies,
486-
)
474+
if not (notes := discussion.attributes.get("notes")):
475+
continue
476+
477+
first_note = notes[0]
478+
479+
# Skip system notes (e.g. commit added)
480+
if first_note.get("system"):
481+
continue
482+
483+
author = first_note.get("author", {})
484+
author_id = author.get("id")
485+
if not author_id or author_id not in authorized_member_ids:
486+
continue
487+
488+
file_path, line_number, line_type = (
489+
_extract_position_info(first_note)
490+
)
491+
492+
replies = [
493+
reply for note in notes[1:]
494+
if (reply := _process_reply(authorized_member_ids, note)) is not None
495+
]
496+
497+
authorized_comments.append(
498+
MergeRequestComment(
499+
author=author.get("username", "unknown"),
500+
message=first_note.get("body", ""),
501+
created_at=first_note.get("created_at", ""),
502+
file_path=file_path,
503+
line_number=line_number,
504+
line_type=line_type,
505+
discussion_id=getattr(discussion, "id", ""),
506+
replies=replies,
487507
)
508+
)
488509
except Exception as e:
489-
# Thread starter is not an authorized member
510+
logger.warning(f"Failed to process discussion: {e}")
490511
continue
491512

492513
return authorized_comments

0 commit comments

Comments
 (0)