mcp-gitlab-explorer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """mcp-gitlab-explorer: GitLab user push activity MCP server."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,209 @@
1
+ """GitLab REST API client (PRIVATE-TOKEN auth)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from typing import Any
9
+ from urllib.parse import quote
10
+
11
+ import httpx
12
+
13
+ DEFAULT_GITLAB_URL = "http://gitlab.beisencorp.com"
14
+ API_PREFIX = "/api/v4"
15
+ EVENTS_PAGE_SIZE = 100 # events 分页步长,GitLab per_page 上限 100
16
+ EVENTS_MAX_PAGES = 200 # events 分页保护上限,防死循环(3 个月数据远触不到)
17
+ PROJECT_CONCURRENCY = 10 # 并发查 project 详情的线程数
18
+ REQUEST_MAX_RETRIES = 3 # 429 / 网络错误重试次数
19
+
20
+
21
+ class GitLabAuthError(RuntimeError):
22
+ """Token 缺失或失效。"""
23
+
24
+
25
+ class GitLabNotFoundError(RuntimeError):
26
+ """资源不存在(404)。"""
27
+
28
+
29
+ def _seg(value: Any) -> str:
30
+ """URL-encode a single path segment."""
31
+ return quote(str(value), safe="")
32
+
33
+
34
+ class GitLabClient:
35
+ """通过 PRIVATE-TOKEN 访问 GitLab REST API。
36
+
37
+ 持有一个长生命周期的 httpx.Client(连接池复用):单次工具调用内所有请求
38
+ (含并发查 project)共享连接池,避免反复 TCP/TLS 握手。httpx.Client 线程
39
+ 安全,可被 ThreadPoolExecutor 多线程共享。project 详情额外做进程内缓存,
40
+ 同一 project 只查一次(项目元数据基本不变)。
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ base_url: str | None = None,
46
+ token: str | None = None,
47
+ timeout: float = 20.0,
48
+ ) -> None:
49
+ self.base_url = (
50
+ base_url
51
+ or os.environ.get("GITLAB_URL")
52
+ or DEFAULT_GITLAB_URL
53
+ ).rstrip("/")
54
+ self.token = (token or os.environ.get("GITLAB_PRIVATE_TOKEN", "")).strip()
55
+ if not self.token:
56
+ raise GitLabAuthError("Missing GITLAB_PRIVATE_TOKEN environment variable.")
57
+ self.timeout = timeout
58
+ self._client = httpx.Client(
59
+ timeout=timeout,
60
+ follow_redirects=False,
61
+ limits=httpx.Limits(
62
+ max_keepalive_connections=PROJECT_CONCURRENCY * 2,
63
+ max_connections=PROJECT_CONCURRENCY * 2,
64
+ ),
65
+ )
66
+ self._project_cache: dict[int, dict] = {}
67
+
68
+ def _headers(self) -> dict[str, str]:
69
+ return {"PRIVATE-TOKEN": self.token, "Accept": "application/json"}
70
+
71
+ def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
72
+ """统一 GET:401/403->AuthError,404->NotFoundError,429->重试,>=4xx->RuntimeError。
73
+
74
+ 429 优先读 Retry-After(秒),否则指数退避;网络错误同样重试。
75
+ 重试耗尽抛 RuntimeError。token 失效/404 不重试(立即抛出)。
76
+ """
77
+ url = f"{self.base_url}{API_PREFIX}{path}"
78
+ for attempt in range(REQUEST_MAX_RETRIES):
79
+ try:
80
+ resp = self._client.get(
81
+ url,
82
+ headers=self._headers(),
83
+ params=params,
84
+ timeout=self.timeout,
85
+ )
86
+ except httpx.RequestError as exc:
87
+ if attempt < REQUEST_MAX_RETRIES - 1:
88
+ time.sleep(2 ** attempt)
89
+ continue
90
+ raise RuntimeError(f"GitLab request failed: {exc}") from exc
91
+
92
+ if resp.status_code in (401, 403):
93
+ raise GitLabAuthError(
94
+ f"GitLab auth failed (HTTP {resp.status_code}). Token invalid or expired."
95
+ )
96
+ if resp.status_code == 404:
97
+ raise GitLabNotFoundError(f"GitLab 404: {path}")
98
+ if resp.status_code == 429:
99
+ retry_after = resp.headers.get("Retry-After")
100
+ try:
101
+ wait = float(retry_after) if retry_after else 2 ** (attempt + 1)
102
+ except ValueError:
103
+ wait = 2 ** (attempt + 1)
104
+ time.sleep(min(wait, 30.0))
105
+ continue
106
+ if resp.status_code >= 400:
107
+ raise RuntimeError(f"GitLab HTTP {resp.status_code}: {resp.text[:200]}")
108
+ try:
109
+ return resp.json()
110
+ except ValueError:
111
+ return resp.text
112
+ raise RuntimeError(
113
+ f"GitLab request failed after {REQUEST_MAX_RETRIES} retries (429): {path}"
114
+ )
115
+
116
+ def close(self) -> None:
117
+ self._client.close()
118
+
119
+ def __enter__(self) -> GitLabClient:
120
+ return self
121
+
122
+ def __exit__(self, *exc: object) -> None:
123
+ self.close()
124
+
125
+ # ---- events --------------------------------------------------------------
126
+
127
+ def list_pushed_events(
128
+ self,
129
+ user: int | str,
130
+ after: str,
131
+ before: str,
132
+ max_pages: int = EVENTS_MAX_PAGES,
133
+ ) -> list[dict]:
134
+ """分页拉取用户 pushed 事件,返回原始事件列表(GitLab 默认按时间倒序)。
135
+
136
+ GET /users/:id/events?action=pushed&after=&before=&per_page=100&page=N
137
+ page 1-based;三重终止: 空页 / 本页未满 / 触及 max_pages。
138
+ :id 路径段接受数字 id 或 username。
139
+
140
+ after/before 均为 YYYY-MM-DD,按 UTC 解释,均含当天(闭区间 [after, before])。
141
+ """
142
+ all_events: list[dict] = []
143
+ page = 1
144
+ while page <= max_pages:
145
+ data = self._get(
146
+ f"/users/{_seg(user)}/events",
147
+ params={
148
+ "action": "pushed",
149
+ "after": after,
150
+ "before": before,
151
+ "per_page": EVENTS_PAGE_SIZE,
152
+ "page": page,
153
+ "sort": "desc",
154
+ },
155
+ )
156
+ if not isinstance(data, list):
157
+ break
158
+ if not data:
159
+ break
160
+ all_events.extend(e for e in data if isinstance(e, dict))
161
+ if len(data) < EVENTS_PAGE_SIZE:
162
+ break
163
+ page += 1
164
+ return all_events
165
+
166
+ # ---- project(并发 + 缓存) ------------------------------------------------
167
+
168
+ def _fetch_project(self, project_id: int | str) -> dict:
169
+ """查单个 project 详情;404(已删除/无权限)容错为 {_missing: True}。
170
+
171
+ 其它异常(含 429 重试耗尽、5xx)向上冒泡,由并发层兜底记 _error。
172
+ """
173
+ try:
174
+ return self._get(f"/projects/{_seg(project_id)}")
175
+ except GitLabNotFoundError:
176
+ return {"id": project_id, "_missing": True}
177
+
178
+ def get_projects_concurrent(
179
+ self,
180
+ project_ids: list[int],
181
+ max_workers: int = PROJECT_CONCURRENCY,
182
+ ) -> dict[int, dict]:
183
+ """并发查 project 详情,返回 {project_id: project_dict}。
184
+
185
+ - 进程内缓存:同一 project 只查一次(命中缓存零开销)
186
+ - 线程池并发:httpx.Client 线程安全,共享连接池
187
+ - 404 容错:已删除/无权限的项目记 {_missing: True},不抛错
188
+ - 其它异常记 {_error: str},不影响其它 project
189
+ """
190
+ unique_ids: list[int] = list(
191
+ dict.fromkeys(pid for pid in project_ids if pid is not None)
192
+ )
193
+ miss = [pid for pid in unique_ids if pid not in self._project_cache]
194
+ if miss:
195
+ workers = max(1, min(max_workers, len(miss)))
196
+ with ThreadPoolExecutor(max_workers=workers) as pool:
197
+ future_to_pid = {
198
+ pool.submit(self._fetch_project, pid): pid for pid in miss
199
+ }
200
+ for fut in as_completed(future_to_pid):
201
+ pid = future_to_pid[fut]
202
+ try:
203
+ self._project_cache[pid] = fut.result()
204
+ except Exception as exc: # noqa: BLE001
205
+ self._project_cache[pid] = {"id": pid, "_error": str(exc)}
206
+ return {
207
+ pid: self._project_cache.get(pid, {"id": pid, "_error": "unknown"})
208
+ for pid in unique_ids
209
+ }
@@ -0,0 +1,126 @@
1
+ """MCP server for GitLab user push activity (PRIVATE-TOKEN auth)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from mcp_gitlab_explorer.gitlab_client import GitLabClient
10
+
11
+ mcp = FastMCP("mcp-gitlab-explorer")
12
+
13
+
14
+ def _client() -> GitLabClient:
15
+ """从环境变量构造 client(token / base_url)。单次调用内复用连接池。"""
16
+ return GitLabClient()
17
+
18
+
19
+ # ---- 聚合 helper -----------------------------------------------------------
20
+ # 纯函数,不依赖网络,便于单测。pushed event 的 project_id 在顶层;
21
+ # push_data.ref 为分支/tag 名。
22
+
23
+
24
+ def _event_project_id(ev: dict) -> Any:
25
+ """从 pushed event 取 project_id。
26
+
27
+ 正常 push event 顶层即 project_id;兜底看 target_type=project 时的 target_id。
28
+ """
29
+ pid = ev.get("project_id")
30
+ if pid is None:
31
+ tgt = ev.get("target_id")
32
+ if tgt and ev.get("target_type") == "project":
33
+ pid = tgt
34
+ return pid
35
+
36
+
37
+ def _aggregate_by_project(events: list[dict]) -> dict[int, dict]:
38
+ """把 pushed events 按 project_id 聚合:
39
+
40
+ - push_count: 该仓库被 push 的次数(仅用于排序,不输出)
41
+ - branches: 时间范围内推送过的分支/tag 名集合
42
+ - last_push_at: 最近一次 push 时间(created_at,ISO8601 字典序=时间序)
43
+ """
44
+ by_project: dict[int, dict] = {}
45
+ for ev in events:
46
+ pid = _event_project_id(ev)
47
+ if pid is None:
48
+ continue
49
+ entry = by_project.setdefault(
50
+ pid, {"push_count": 0, "branches": set(), "last_push_at": ""}
51
+ )
52
+ entry["push_count"] += 1
53
+ ref = (ev.get("push_data") or {}).get("ref")
54
+ if ref:
55
+ entry["branches"].add(ref)
56
+ created = ev.get("created_at") or ""
57
+ if created > entry["last_push_at"]:
58
+ entry["last_push_at"] = created
59
+ return by_project
60
+
61
+
62
+ @mcp.tool()
63
+ def gitlab_user_pushed_repos(
64
+ user: int | str,
65
+ after: str,
66
+ before: str,
67
+ ) -> dict[str, Any]:
68
+ """查询指定用户在某时间范围内 push 过代码的仓库列表。
69
+
70
+ 先分页拉取 /users/:id/events?action=pushed,按 project_id 聚合后并发查
71
+ 仓库详情(连接池复用 + 进程内缓存),返回去重仓库列表。
72
+
73
+ Args:
74
+ user: GitLab 用户 id 或 username(均支持,直接作为 /users/:id 路径段)
75
+ after: 起始日期 YYYY-MM-DD(含)
76
+ before: 结束日期 YYYY-MM-DD(含,闭区间 [after, before])
77
+
78
+ Returns:
79
+ {user, after, before, total_events, repo_count,
80
+ repos:[{name, web_url, branches, last_push_at, missing}]}
81
+ repos 按 push 次数倒序(活跃仓库在前);branches 为时间范围内推送过的
82
+ 分支/tag 列表;last_push_at 为最近一次 push 时间(ISO8601);
83
+ missing=true 表示仓库已删除或 token 无权限查看(此时 name/web_url 为 null)。
84
+
85
+ 注意:
86
+ - 适合查近 3 个月内的数据;GitLab events 有保留期,更早的历史可能查不到
87
+ - after/before 按 UTC 日期解释,跨时区边界可能有 1 天偏差
88
+ """
89
+ with _client() as client:
90
+ events = client.list_pushed_events(user, after, before)
91
+ by_project = _aggregate_by_project(events)
92
+ projects = client.get_projects_concurrent(list(by_project.keys()))
93
+
94
+ rows: list[tuple[int, dict[str, Any]]] = []
95
+ for pid, agg in by_project.items():
96
+ proj = projects.get(pid) or {}
97
+ rows.append(
98
+ (
99
+ agg["push_count"],
100
+ {
101
+ "name": proj.get("name"),
102
+ "web_url": proj.get("web_url"),
103
+ "branches": sorted(agg["branches"]),
104
+ "last_push_at": agg["last_push_at"],
105
+ "missing": bool(proj.get("_missing")),
106
+ },
107
+ )
108
+ )
109
+ rows.sort(key=lambda x: x[0], reverse=True)
110
+ repos = [repo for _, repo in rows]
111
+ return {
112
+ "user": user,
113
+ "after": after,
114
+ "before": before,
115
+ "total_events": len(events),
116
+ "repo_count": len(repos),
117
+ "repos": repos,
118
+ }
119
+
120
+
121
+ def main() -> None:
122
+ mcp.run(transport="stdio")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-gitlab-explorer
3
+ Version: 0.1.0
4
+ Summary: MCP server for GitLab user push activity (which repos a user pushed to in a time range) via PRIVATE-TOKEN
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: mcp[cli]>=1.2.0
@@ -0,0 +1,7 @@
1
+ mcp_gitlab_explorer/__init__.py,sha256=Yp2uRLEw3ZrSCYdanCwrrL7jZ1sTCR-nqbhKX42kzPQ,88
2
+ mcp_gitlab_explorer/gitlab_client.py,sha256=gQEIpWn1N1rCxTiySHiUyrTVOhVuiOtPg96V3Afn9UM,7852
3
+ mcp_gitlab_explorer/server.py,sha256=a2KzP7QUdbvm__WCAWvHUKonhoBS-s9sGi35y1gy8O8,4202
4
+ mcp_gitlab_explorer-0.1.0.dist-info/METADATA,sha256=kUqK-UyLXXIL5JRNJlbIJMAS3nNA2j4ZWhgX-EIAQlk,262
5
+ mcp_gitlab_explorer-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
6
+ mcp_gitlab_explorer-0.1.0.dist-info/entry_points.txt,sha256=GWZCH_TWtEl6SS9OZqSMLN25YsF8F6b-72ZDGYwC5qs,72
7
+ mcp_gitlab_explorer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-gitlab-explorer = mcp_gitlab_explorer.server:main