https://github.com/owayo/gitlab-mcp-server
by owayo
Verified
- gitlab-mcp-server
- src
- utils
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
from typing import Any, Dict, List, Optional
import gitlab
from gitlab.v4.objects import MergeRequest, Project
from src.utils.git_utils import get_diff_from_base
# GitLab URLの取得
def get_gitlab_url() -> str:
"""
環境変数からGitLabのURLを取得します。
Returns:
str: GitLabのURL
Raises:
ValueError: 環境変数が設定されていない場合
"""
gitlab_url = os.environ.get("GITLAB_URL")
if not gitlab_url:
raise ValueError("GITLAB_URL環境変数が設定されていません。")
return gitlab_url
# プロジェクトID
def get_gitlab_project_id() -> str:
"""
環境変数からGitLabのプロジェクトIDを取得します。
Returns:
str: GitLabのプロジェクトID
Raises:
ValueError: 環境変数が設定されていない場合
"""
project_id = os.environ.get("GITLAB_PROJECT_NAME")
if not project_id:
raise ValueError("GITLAB_PROJECT_ID環境変数が設定されていません。")
return project_id
def get_gitlab_client() -> gitlab.Gitlab:
"""
GitLabクライアントを取得します。
Returns:
gitlab.Gitlab: GitLabクライアントインスタンス
Raises:
ValueError: GitLab APIキーが見つからない場合や接続に失敗した場合
"""
# 環境変数からGitLab APIキーを取得
gitlab_api_key = os.environ.get("GITLAB_API_KEY")
if not gitlab_api_key:
raise ValueError("GITLAB_API_KEY環境変数が設定されていません。")
# GitLabのURLを取得
gitlab_url = get_gitlab_url()
# 接続方法を順番に試行
connection_urls = [
gitlab_url,
]
last_error = None
for url in connection_urls:
try:
gl = gitlab.Gitlab(url, private_token=gitlab_api_key)
gl.auth()
return gl
except Exception as e:
last_error = e
continue
# すべての接続方法が失敗した場合
raise ValueError(f"GitLabへの接続に失敗しました: {str(last_error)}")
def get_gitlab_project() -> Project:
"""
GitLabプロジェクトを取得します。
Returns:
Project: GitLabプロジェクトインスタンス
Raises:
ValueError: プロジェクトの取得に失敗した場合
"""
try:
gl = get_gitlab_client()
project_id = get_gitlab_project_id()
# プロジェクトIDを使用してプロジェクトを取得
try:
project = gl.projects.get(project_id)
return project
except gitlab.exceptions.GitlabGetError:
# IDでの取得に失敗した場合は検索を試行
projects = gl.projects.list(search=project_id)
if projects:
return projects[0]
# 検索でも見つからない場合
raise ValueError(f"プロジェクト '{project_id}' が見つかりません。")
except ValueError as e:
# 既存のValueErrorを再送出
raise e
except Exception as e:
# その他の例外は新しいValueErrorでラップ
raise ValueError(f"GitLabプロジェクトの取得に失敗しました: {str(e)}")
def get_merge_request(
branch_name: str,
) -> Optional[MergeRequest]:
"""
指定したブランチ名に関連するMerge Requestを取得します。
Args:
branch_name (str): ブランチ名
Returns:
Optional[MergeRequest]: Merge Request情報。見つからない場合はNone
Raises:
ValueError: Merge Requestの取得に失敗した場合
"""
try:
project = get_gitlab_project()
# 各状態のMRを順番に検索
for state in ["opened", "merged", "closed"]:
mrs = project.mergerequests.list(source_branch=branch_name, state=state)
if mrs:
# 最新のMRを使用
mr = mrs[0]
# パイプライン情報も取得
if hasattr(mr, "pipeline") and mr.pipeline:
mr.pipeline = project.pipelines.get(mr.pipeline["id"])
return mr
# どの状態でもMRが見つからない場合
return None
except ValueError as e:
# 既存のValueErrorを再送出
raise e
except Exception as e:
# その他の例外は新しいValueErrorでラップ
raise ValueError(f"Merge Requestの取得に失敗しました: {str(e)}")
def get_failed_jobs_output(mr_id: int) -> str:
"""
指定したMR IDに関連する最後のパイプラインで失敗したジョブのコンソール出力を取得します。
最後のパイプラインに失敗したジョブがなければ、空の文字列を返します。
Args:
mr_id (int): Merge Request ID
Returns:
str: 失敗したジョブのコンソール出力、または空文字列
Raises:
ValueError: ジョブ出力の取得に失敗した場合
"""
try:
project = get_gitlab_project()
# MRを取得
mr = project.mergerequests.get(mr_id)
# パイプライン情報を取得
if not hasattr(mr, "pipelines") or not mr.pipelines:
return ""
# パイプラインを取得し、最新のものを選択
pipelines = mr.pipelines.list()
if not pipelines:
return ""
# 最新のパイプラインを取得 (GitLabのAPIはデフォルトで降順)
latest_pipeline = pipelines[0]
pipeline_detail = project.pipelines.get(latest_pipeline.id)
# 失敗したジョブを検索
failed_jobs = [
job for job in pipeline_detail.jobs.list() if job.status == "failed"
]
if not failed_jobs:
return "" # 失敗したジョブがない場合は空文字列を返す
# 失敗したジョブのコンソール出力を取得
outputs = []
for job in failed_jobs:
job_detail = project.jobs.get(job.id)
job_output = job_detail.trace()
outputs.append(
f"# ジョブ: {job.name}\n- ステータス: {job.status}\n- 出力:\n```\n{job_output}\n```"
)
return "\n\n".join(outputs)
except gitlab.exceptions.GitlabGetError:
raise ValueError(f"MR ID #{mr_id} が見つかりません。")
except Exception as e:
raise ValueError(f"失敗したジョブの出力取得に失敗しました: {str(e)}")
def get_mr_comments(mr_id: int) -> str:
"""
指定したMR IDに関連するMRの指摘事項(コメント)を取得します。
解決済み(resolved)のコメントは除外されます。
ファイルに紐づいているコメントのみが取得されます。
Args:
mr_id (int): Merge Request ID
Returns:
str: MRへの指摘事項(AIが理解しやすい形式に整形)
Raises:
ValueError: コメントの取得に失敗した場合
"""
try:
project = get_gitlab_project()
# MRを取得
try:
mr: MergeRequest = project.mergerequests.get(mr_id)
except gitlab.exceptions.GitlabGetError:
return f"MR ID #{mr_id} が見つかりません。"
# MRのディスカッション(スレッド)を取得
discussions = mr.discussions.list()
if not discussions:
return f"MR #{mr.iid} へのコメントはありません。"
comments: List[str] = []
# 各ディスカッションを処理
for discussion in discussions:
# 個々のディスカッション内のノートを処理
discussion_comments = process_discussion(discussion)
if discussion_comments:
comments.extend(discussion_comments)
if not comments:
return f"MR #{mr.iid} への未解決の指摘事項はありません。"
return "\n---\n".join(comments)
except Exception as e:
raise ValueError(f"MRへの指摘事項の取得に失敗しました: {str(e)}")
def process_discussion(discussion: Dict[str, Any]) -> List[str]:
"""
ディスカッション(スレッド)内のノートを処理し、未解決のコメントを抽出します。
ファイルに紐づいているコメントのみを抽出します。
Args:
discussion: GitLabディスカッションオブジェクト
Returns:
List[str]: 未解決のコメント文字列のリスト
"""
discussion_comments = []
has_unresolved_notes = False
# ディスカッションにはノートの配列が含まれています
notes = (
discussion.attributes.get("notes", [])
if hasattr(discussion, "attributes")
else discussion.get("notes", [])
)
for note in notes:
# システムノートは除外
if note.get("system", False):
continue
# 解決可能で、かつ解決済みのノートは除外
if note.get("resolvable", False) and note.get("resolved", False):
continue
# コードとの関連付けがあるか確認
position = note.get("position", {})
file_path = position.get("new_path", "") if position else ""
# ファイルに紐づいていないコメントは除外
if not file_path:
continue
# ここに到達したノートは未解決かつファイルに紐づいている
has_unresolved_notes = True
author = note.get("author", {}).get("name", "不明なユーザー")
body = note.get("body", "")
line = position.get("new_line", "") if position else ""
location = (
f" (ファイル: {file_path}, 行: {line})"
if file_path and line
else f" (ファイル: {file_path})"
)
discussion_comments.append(
f"# 対象: {location}\n- コメント者: {author}\n- コメント:\n```\n{body}\n```"
)
# ディスカッションに未解決のノートがある場合のみ、そのコメントを返す
if has_unresolved_notes:
return discussion_comments
return []
def get_mr_changes(mr_id: int) -> str:
"""
指定したMR IDに関連するMRのベースコミット(base_sha)から現在のローカルの状態までの差分を取得します。
リモートの差分ではなく、ローカルの最新状態との差分を取得します。
Args:
mr_id (int): Merge Request ID
Returns:
str: MRのベースコミットから現在のローカル状態までの変更内容(差分)
Raises:
ValueError: 変更内容の取得に失敗した場合
"""
try:
project = get_gitlab_project()
# MRを取得
try:
mr: MergeRequest = project.mergerequests.get(mr_id)
except gitlab.exceptions.GitlabGetError:
return f"MR ID #{mr_id} が見つかりません。"
# MRのdiff_refsからbase_shaを取得
if not hasattr(mr, "diff_refs") or not mr.diff_refs:
return f"MR #{mr.iid} の差分情報が取得できません。"
base_sha: Optional[str] = mr.diff_refs.get("base_sha")
if not base_sha:
return f"MR #{mr.iid} のベースコミットが特定できません。"
# ローカルリポジトリでbase_shaからの差分を取得
try:
diff_output = get_diff_from_base(base_sha)
if not diff_output or diff_output == "変更されたファイルはありません。":
return f"MR #{mr.iid} (ベースコミット: {base_sha}) からの変更はありません。"
return diff_output
except Exception as e:
return f"ローカルリポジトリからの差分取得に失敗しました: {str(e)}"
except Exception as e:
raise ValueError(f"MRの変更内容の取得に失敗しました: {str(e)}")