|
19 | 19 |
|
20 | 20 | logger = logging.getLogger(__name__) |
21 | 21 |
|
| 22 | +# GitLab access levels: Guest (10), Reporter (20), Developer (30), |
| 23 | +# Maintainer (40), Owner (50) |
| 24 | +DEVELOPER_ACCESS_LEVEL = 30 |
| 25 | + |
22 | 26 |
|
23 | 27 | def _get_authenticated_url(repository_url: str) -> str: |
24 | 28 | """ |
@@ -388,105 +392,122 @@ def get_latest_pipeline_jobs(): |
388 | 392 | raise ToolError(f"Failed to get failed jobs from merge request: {e}") from e |
389 | 393 |
|
390 | 394 |
|
| 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 | + |
391 | 454 | async def get_authorized_comments_from_merge_request( |
392 | 455 | merge_request_url: Annotated[str, Field(description="URL of the merge request")], |
393 | 456 | ) -> list[MergeRequestComment]: |
394 | 457 | """ |
395 | 458 | Gets all comments from a merge request, filtered to only include |
396 | 459 | comments from authorized members with Developer role or higher. |
397 | | - Access levels: Guest (10), Reporter (20), Developer (30), |
398 | | - Maintainer (40), Owner (50). |
399 | 460 | """ |
400 | 461 | try: |
401 | 462 | mr = await _get_merge_request_from_url(merge_request_url) |
402 | 463 |
|
403 | 464 | def get_authorized_comments(): |
404 | | - # Get discussions instead of notes to capture thread context |
405 | 465 | discussions = mr._raw_pr.discussions.list(get_all=True) |
406 | 466 | project = mr.target_project.gitlab_repo |
407 | 467 |
|
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) |
409 | 470 |
|
410 | 471 | authorized_comments = [] |
411 | 472 | 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 | | - |
427 | 473 | 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, |
487 | 507 | ) |
| 508 | + ) |
488 | 509 | except Exception as e: |
489 | | - # Thread starter is not an authorized member |
| 510 | + logger.warning(f"Failed to process discussion: {e}") |
490 | 511 | continue |
491 | 512 |
|
492 | 513 | return authorized_comments |
|
0 commit comments