devlake-mcp 0.4.1__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,489 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Git 信息获取工具模块
5
+
6
+ 提供 Git 仓库信息的获取功能:
7
+ - 当前分支
8
+ - 最新 commit hash
9
+ - Git 配置的用户名和邮箱
10
+ - 项目ID提取(namespace/name)
11
+
12
+ 改进:
13
+ - 完整的类型注解
14
+ - 使用常量配置
15
+ - 更好的错误处理
16
+ - 完善的日志记录
17
+ """
18
+
19
+ import subprocess
20
+ import os
21
+ import re
22
+ import logging
23
+ from pathlib import Path
24
+ from typing import Optional, Dict
25
+
26
+ from .constants import GIT_COMMAND_TIMEOUT
27
+
28
+ # 配置日志
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def get_git_info(
33
+ cwd: str,
34
+ timeout: int = GIT_COMMAND_TIMEOUT,
35
+ include_user_info: bool = True
36
+ ) -> Dict[str, str]:
37
+ """
38
+ 获取当前项目的 Git 信息
39
+
40
+ Args:
41
+ cwd: 项目根目录路径
42
+ timeout: Git 命令超时时间(秒),默认 1 秒
43
+ include_user_info: 是否获取用户信息(git_author/git_email),默认 True
44
+ 如果环境变量已缓存,可以设为 False 提升性能
45
+
46
+ Returns:
47
+ Git 信息字典:
48
+ {
49
+ "git_branch": "feature/ai-coding", # 当前分支
50
+ "git_commit": "abc123def456...", # 最新 commit hash(完整)
51
+ "git_author": "wangzhong", # Git 配置的用户名(可选)
52
+ "git_email": "wangzhong@example.com" # Git 配置的邮箱(可选)
53
+ }
54
+
55
+ 如果不是 Git 仓库或获取失败,返回 "unknown"
56
+
57
+ 示例:
58
+ >>> git_info = get_git_info('/path/to/project')
59
+ >>> print(git_info['git_branch'])
60
+ feature/ai-coding
61
+
62
+ >>> # 如果用户信息已缓存,可以跳过获取
63
+ >>> git_info = get_git_info('/path/to/project', include_user_info=False)
64
+ """
65
+ git_info = {
66
+ "git_branch": "unknown",
67
+ "git_commit": "unknown",
68
+ "git_author": "unknown",
69
+ "git_email": "unknown"
70
+ }
71
+
72
+ try:
73
+ # 1. 检查是否是 Git 仓库
74
+ result = subprocess.run(
75
+ ['git', 'rev-parse', '--is-inside-work-tree'],
76
+ cwd=cwd,
77
+ capture_output=True,
78
+ text=True,
79
+ timeout=timeout
80
+ )
81
+
82
+ is_git_repo = (result.returncode == 0)
83
+
84
+ if not is_git_repo:
85
+ # 不是 Git 仓库,只获取全局配置
86
+ logger.debug(f"目录不是 Git 仓库: {cwd},尝试获取全局 Git 配置")
87
+
88
+ if include_user_info:
89
+ # 获取全局用户名
90
+ result_global = subprocess.run(
91
+ ['git', 'config', '--global', 'user.name'],
92
+ capture_output=True,
93
+ text=True,
94
+ timeout=timeout
95
+ )
96
+ if result_global.returncode == 0:
97
+ git_info['git_author'] = result_global.stdout.strip()
98
+ logger.debug(f"Git author (global): {git_info['git_author']}")
99
+
100
+ # 获取全局邮箱
101
+ result_global = subprocess.run(
102
+ ['git', 'config', '--global', 'user.email'],
103
+ capture_output=True,
104
+ text=True,
105
+ timeout=timeout
106
+ )
107
+ if result_global.returncode == 0:
108
+ git_info['git_email'] = result_global.stdout.strip()
109
+ logger.debug(f"Git email (global): {git_info['git_email']}")
110
+
111
+ return git_info
112
+
113
+ # 2. 获取当前分支
114
+ result = subprocess.run(
115
+ ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
116
+ cwd=cwd,
117
+ capture_output=True,
118
+ text=True,
119
+ timeout=timeout
120
+ )
121
+ if result.returncode == 0:
122
+ git_info['git_branch'] = result.stdout.strip()
123
+ logger.debug(f"Git 分支: {git_info['git_branch']}")
124
+ else:
125
+ logger.warning(f"无法获取 Git 分支: {result.stderr.strip()}")
126
+
127
+ # 3. 获取最新 commit hash(完整 40 位)
128
+ result = subprocess.run(
129
+ ['git', 'rev-parse', 'HEAD'],
130
+ cwd=cwd,
131
+ capture_output=True,
132
+ text=True,
133
+ timeout=timeout
134
+ )
135
+ if result.returncode == 0:
136
+ git_info['git_commit'] = result.stdout.strip()
137
+ logger.debug(f"Git commit: {git_info['git_commit'][:8]}")
138
+ else:
139
+ logger.warning(f"无法获取 Git commit: {result.stderr.strip()}")
140
+
141
+ # 4. 获取 Git 配置的用户名和邮箱(可选)
142
+ # 直接使用全局配置,简化逻辑并确保一致性
143
+ if include_user_info:
144
+ # 获取全局用户名
145
+ result = subprocess.run(
146
+ ['git', 'config', '--global', 'user.name'],
147
+ capture_output=True,
148
+ text=True,
149
+ timeout=timeout
150
+ )
151
+ if result.returncode == 0:
152
+ git_info['git_author'] = result.stdout.strip()
153
+ logger.debug(f"Git author (global): {git_info['git_author']}")
154
+ else:
155
+ logger.warning("未配置 git user.name (--global)")
156
+
157
+ # 获取全局邮箱
158
+ result = subprocess.run(
159
+ ['git', 'config', '--global', 'user.email'],
160
+ capture_output=True,
161
+ text=True,
162
+ timeout=timeout
163
+ )
164
+ if result.returncode == 0:
165
+ git_info['git_email'] = result.stdout.strip()
166
+ logger.debug(f"Git email (global): {git_info['git_email']}")
167
+ else:
168
+ logger.warning("未配置 git user.email (--global)")
169
+
170
+ except subprocess.TimeoutExpired as e:
171
+ # 超时,返回默认值
172
+ logger.warning(f"Git 命令超时 ({timeout}秒): {e.cmd}")
173
+ except FileNotFoundError:
174
+ # git 命令未找到
175
+ logger.error("Git 命令未找到,请确保已安装 Git")
176
+ except Exception as e:
177
+ # 其他异常
178
+ logger.error(f"获取 Git 信息失败: {e}", exc_info=True)
179
+
180
+ return git_info
181
+
182
+
183
+ def get_current_branch(cwd: str, timeout: int = GIT_COMMAND_TIMEOUT) -> str:
184
+ """
185
+ 快速获取当前 Git 分支(简化版)
186
+
187
+ Args:
188
+ cwd: 项目根目录
189
+ timeout: 超时时间(秒)
190
+
191
+ Returns:
192
+ 分支名称,失败返回 'unknown'
193
+ """
194
+ try:
195
+ result = subprocess.run(
196
+ ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
197
+ cwd=cwd,
198
+ capture_output=True,
199
+ text=True,
200
+ timeout=timeout
201
+ )
202
+ if result.returncode == 0:
203
+ return result.stdout.strip()
204
+ else:
205
+ logger.debug(f"获取分支失败: {result.stderr.strip()}")
206
+ except Exception as e:
207
+ logger.warning(f"获取当前分支失败: {e}")
208
+
209
+ return 'unknown'
210
+
211
+
212
+ def get_git_remote_url(cwd: str, timeout: int = GIT_COMMAND_TIMEOUT) -> Optional[str]:
213
+ """
214
+ 获取 Git remote URL
215
+
216
+ Args:
217
+ cwd: 项目路径
218
+ timeout: 超时时间(秒)
219
+
220
+ Returns:
221
+ Git remote URL,失败返回 None
222
+ """
223
+ try:
224
+ result = subprocess.run(
225
+ ['git', 'config', '--get', 'remote.origin.url'],
226
+ cwd=cwd,
227
+ capture_output=True,
228
+ text=True,
229
+ timeout=timeout
230
+ )
231
+ if result.returncode == 0:
232
+ return result.stdout.strip()
233
+ except Exception:
234
+ pass
235
+
236
+ return None
237
+
238
+
239
+ def extract_git_repo_path(git_remote_url: Optional[str], cwd: str) -> str:
240
+ """
241
+ 从 Git remote URL 提取 git_repo_path (namespace/name)
242
+
243
+ 支持的格式:
244
+ - https://github.com/yourorg/devlake.git -> yourorg/devlake
245
+ - git@github.com:yourorg/devlake.git -> yourorg/devlake
246
+ - https://gitlab.com/team/subteam/project.git -> team/subteam/project
247
+ - git@gitlab.com:team/project.git -> team/project
248
+
249
+ Args:
250
+ git_remote_url: Git 远程仓库 URL
251
+ cwd: 项目路径(作为 fallback)
252
+
253
+ Returns:
254
+ git_repo_path (namespace/name)
255
+ 如果无法提取,返回 'local/{directory_name}'
256
+ """
257
+ if not git_remote_url:
258
+ # 没有 Git 仓库,使用 local/{dirname}
259
+ return f"local/{Path(cwd).name}"
260
+
261
+ # 去掉 .git 后缀(修复:使用 removesuffix 避免误删末尾字符)
262
+ url = git_remote_url.removesuffix('.git')
263
+
264
+ # 提取 namespace/name (支持多级 namespace)
265
+ # 匹配格式:
266
+ # - https://github.com/yourorg/devlake -> yourorg/devlake
267
+ # - git@gitlab.com:team/project -> team/project
268
+ # - https://gitlab.com/team/subteam/project -> team/subteam/project
269
+
270
+ patterns = [
271
+ # HTTPS 格式:https://domain.com/namespace/name
272
+ r'https?://[^/]+/(.+)',
273
+ # SSH 格式:git@domain.com:namespace/name
274
+ r'git@[^:]+:(.+)',
275
+ ]
276
+
277
+ for pattern in patterns:
278
+ match = re.search(pattern, url)
279
+ if match:
280
+ return match.group(1)
281
+
282
+ # 解析失败,降级到 local/{dirname}
283
+ return f"local/{Path(cwd).name}"
284
+
285
+
286
+ def get_git_repo_path(cwd: str) -> str:
287
+ """
288
+ 获取Git仓库路径 (namespace/name)
289
+
290
+ Args:
291
+ cwd: 项目路径
292
+
293
+ Returns:
294
+ git_repo_path,如 'yourorg/devlake'
295
+ """
296
+ git_remote_url = get_git_remote_url(cwd)
297
+ return extract_git_repo_path(git_remote_url, cwd)
298
+
299
+
300
+ def get_git_root(cwd: str, timeout: int = GIT_COMMAND_TIMEOUT) -> Optional[str]:
301
+ """
302
+ 获取 Git 仓库根目录
303
+
304
+ Args:
305
+ cwd: 当前工作目录
306
+ timeout: 超时时间(秒)
307
+
308
+ Returns:
309
+ Git 仓库根目录的绝对路径,失败返回 None
310
+ """
311
+ try:
312
+ result = subprocess.run(
313
+ ['git', 'rev-parse', '--show-toplevel'],
314
+ cwd=cwd,
315
+ capture_output=True,
316
+ text=True,
317
+ timeout=timeout
318
+ )
319
+ if result.returncode == 0:
320
+ return result.stdout.strip()
321
+ except Exception:
322
+ pass
323
+
324
+ return None
325
+
326
+
327
+ def get_full_git_context(cwd: str, use_env_cache: bool = True) -> Dict[str, str]:
328
+ """
329
+ 获取完整的 Git 上下文信息(统一接口)
330
+
331
+ 这个函数整合了静态和动态 Git 信息的获取逻辑,避免重复代码。
332
+
333
+ 策略:
334
+ - 静态信息(author, email, repo_path):
335
+ * 如果 use_env_cache=True,优先从环境变量读取(避免重复执行 git config)
336
+ * 如果环境变量不存在,则执行 git 命令获取
337
+ - 动态信息(branch, commit):始终执行 git 命令获取最新值
338
+ - 衍生信息(project_name, git_root):自动计算
339
+
340
+ Args:
341
+ cwd: 当前工作目录
342
+ use_env_cache: 是否使用环境变量缓存的静态信息(默认 True)
343
+
344
+ Returns:
345
+ Dict[str, str]: 完整的 Git 上下文,包含:
346
+ - git_branch: 当前分支
347
+ - git_commit: 当前 commit hash(完整40位)
348
+ - git_author: Git 用户名
349
+ - git_email: Git 用户邮箱
350
+ - git_repo_path: 仓库路径 (namespace/name)
351
+ - project_name: 项目名称(从 repo_path 提取)
352
+ - git_root: Git 仓库根目录(绝对路径)
353
+
354
+ 示例:
355
+ >>> context = get_full_git_context('/path/to/project')
356
+ >>> print(context['git_branch'])
357
+ main
358
+ >>> print(context['git_repo_path'])
359
+ yourorg/devlake
360
+ >>> print(context['project_name'])
361
+ devlake
362
+
363
+ >>> # 不使用缓存,强制重新获取
364
+ >>> context = get_full_git_context('/path/to/project', use_env_cache=False)
365
+ """
366
+ context = {}
367
+
368
+ # 1. 静态信息:优先从环境变量读取(避免重复执行 git config)
369
+ if use_env_cache:
370
+ context['git_author'] = os.getenv('GIT_AUTHOR', 'unknown')
371
+ context['git_email'] = os.getenv('GIT_EMAIL', 'unknown')
372
+ context['git_repo_path'] = os.getenv('GIT_REPO_PATH', 'unknown')
373
+
374
+ # 如果环境变量不存在,则执行 git 命令获取
375
+ if context['git_author'] == 'unknown' or context['git_email'] == 'unknown':
376
+ git_info = get_git_info(cwd, include_user_info=True)
377
+ if context['git_author'] == 'unknown':
378
+ context['git_author'] = git_info.get('git_author', 'unknown')
379
+ if context['git_email'] == 'unknown':
380
+ context['git_email'] = git_info.get('git_email', 'unknown')
381
+
382
+ if context['git_repo_path'] == 'unknown':
383
+ context['git_repo_path'] = get_git_repo_path(cwd)
384
+ else:
385
+ # 不使用缓存,直接获取
386
+ git_info = get_git_info(cwd, include_user_info=True)
387
+ context['git_author'] = git_info.get('git_author', 'unknown')
388
+ context['git_email'] = git_info.get('git_email', 'unknown')
389
+ context['git_repo_path'] = get_git_repo_path(cwd)
390
+
391
+ # 2. 动态信息:每次获取最新值(确保 branch/commit 正确)
392
+ git_info = get_git_info(cwd, include_user_info=False)
393
+ context['git_branch'] = git_info.get('git_branch', 'unknown')
394
+ context['git_commit'] = git_info.get('git_commit', 'unknown')
395
+
396
+ # 3. 衍生信息:自动计算
397
+ # 提取 project_name(从 repo_path 最后一段)
398
+ repo_path = context['git_repo_path']
399
+ context['project_name'] = repo_path.split('/')[-1] if '/' in repo_path else repo_path
400
+
401
+ # 获取 git_root
402
+ context['git_root'] = get_git_root(cwd) or ''
403
+
404
+ logger.debug(
405
+ f"Git 上下文: {context['project_name']} "
406
+ f"({context['git_branch']}@{context['git_commit'][:8]})"
407
+ )
408
+
409
+ return context
410
+
411
+
412
+ def get_git_context_from_file(file_path: str, use_env_cache: bool = True) -> Dict[str, str]:
413
+ """
414
+ 从文件路径获取 Git 上下文(支持 workspace 多项目环境)
415
+
416
+ 这个函数专门用于处理文件变更场景,能够正确识别 workspace 中不同子项目的 Git 仓库。
417
+
418
+ 工作原理:
419
+ 1. 从文件所在目录向上查找 .git 目录(获取 git_root)
420
+ 2. 基于 git_root 获取完整的 Git 上下文
421
+ 3. 如果找不到 git_root,降级到文件所在目录
422
+
423
+ 使用场景:
424
+ - 文件编辑/创建操作(Edit/Write tool)
425
+ - Shell 命令修改文件
426
+ - 任何有具体文件路径的操作
427
+
428
+ Workspace 支持:
429
+ 在 workspace 环境下,不同的文件可能属于不同的 git 仓库:
430
+ ```
431
+ workspace/
432
+ ├── project-a/ (git: yourorg/project-a)
433
+ │ └── main.py
434
+ └── project-b/ (git: yourorg/project-b)
435
+ └── app.py
436
+
437
+ get_git_context_from_file('workspace/project-a/main.py')
438
+ → git_repo_path = 'yourorg/project-a' ✓
439
+
440
+ get_git_context_from_file('workspace/project-b/app.py')
441
+ → git_repo_path = 'yourorg/project-b' ✓
442
+ ```
443
+
444
+ Args:
445
+ file_path: 文件路径(可以是相对路径或绝对路径)
446
+ use_env_cache: 是否使用环境变量缓存的静态信息(默认 True)
447
+
448
+ Returns:
449
+ Dict[str, str]: 完整的 Git 上下文,包含:
450
+ - git_branch: 当前分支
451
+ - git_commit: 当前 commit hash
452
+ - git_author: Git 用户名
453
+ - git_email: Git 用户邮箱
454
+ - git_repo_path: 仓库路径 (namespace/name)
455
+ - project_name: 项目名称
456
+ - git_root: Git 仓库根目录
457
+
458
+ 示例:
459
+ >>> # Workspace 环境
460
+ >>> context = get_git_context_from_file('/workspace/project-a/src/main.py')
461
+ >>> print(context['git_repo_path'])
462
+ yourorg/project-a
463
+
464
+ >>> # 单项目环境(向后兼容)
465
+ >>> context = get_git_context_from_file('/project/src/utils.py')
466
+ >>> print(context['git_repo_path'])
467
+ yourorg/project
468
+ """
469
+ # 1. 转换为绝对路径
470
+ abs_file_path = os.path.abspath(file_path)
471
+
472
+ # 2. 获取文件所在目录
473
+ file_dir = os.path.dirname(abs_file_path)
474
+
475
+ # 3. 从文件所在目录向上查找 Git 仓库根目录
476
+ git_root = get_git_root(file_dir)
477
+
478
+ # 4. 基于 git_root 获取完整的 Git 上下文
479
+ if git_root:
480
+ # 找到了 git root,使用它作为工作目录
481
+ logger.debug(f'从文件 {abs_file_path} 找到 Git root: {git_root}')
482
+ return get_full_git_context(git_root, use_env_cache=use_env_cache)
483
+ else:
484
+ # 降级方案:使用文件所在目录
485
+ logger.warning(
486
+ f'文件 {abs_file_path} 不在 Git 仓库中,'
487
+ f'使用文件所在目录 {file_dir} 作为 fallback'
488
+ )
489
+ return get_full_git_context(file_dir, use_env_cache=use_env_cache)
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ DevLake MCP - Claude Code Hooks 模块
5
+
6
+ 提供完整的 Claude Code hooks 功能,用于收集 AI 编码数据并上报到 DevLake。
7
+
8
+ 使用方法:
9
+ from devlake_mcp.hooks import session_start
10
+ session_start.main()
11
+
12
+ 所有可用的 hooks:
13
+ - session_start: 会话启动时触发
14
+ - pre_tool_use: 工具执行前触发
15
+ - post_tool_use: 工具执行后触发
16
+ - stop: Claude 完成回复时触发
17
+ - record_session: 会话结束时触发
18
+ """
19
+
20
+ # 配置将在实际使用时加载,而不是在导入时
21
+ # 这样可以避免在测试环境中导致问题
22
+ from devlake_mcp.config import DevLakeConfig
23
+
24
+ def _initialize_hooks_config():
25
+ """初始化 Hooks 配置(按需调用)"""
26
+ return DevLakeConfig.from_env(include_git=True)
27
+
28
+ # 导入所有 hook 模块
29
+ from devlake_mcp.hooks import (
30
+ hook_utils,
31
+ session_start,
32
+ pre_tool_use,
33
+ post_tool_use,
34
+ stop,
35
+ record_session,
36
+ )
37
+
38
+ __all__ = [
39
+ # 工具模块
40
+ "hook_utils",
41
+ # Hook 模块
42
+ "session_start",
43
+ "pre_tool_use",
44
+ "post_tool_use",
45
+ "stop",
46
+ "record_session",
47
+ ]
48
+
49
+ __version__ = "0.1.0"