mm-qa-mcp 3.0.2__py3-none-any.whl → 3.0.4__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,1150 @@
1
+ """
2
+ coding:utf-8
3
+ @Software: PyCharm
4
+ @Time: 2026/2/4
5
+ @Author: xingyun
6
+ @Desc: GitLab分支查询服务 - 根据产品线获取最近创建的分支
7
+ """
8
+
9
+ import json
10
+ import requests
11
+ from datetime import datetime, timedelta, timezone
12
+ from typing import List, Dict, Optional, Union
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
+
15
+ from minimax_qa_mcp.utils.logger import logger
16
+ from minimax_qa_mcp.utils.utils import Utils
17
+
18
+
19
+ class GitlabBranchService:
20
+ """
21
+ GitLab分支查询服务
22
+ 功能:
23
+ 1. 从Apollo获取产品线对应的GitLab Group ID
24
+ 2. 自动获取该Group下的所有项目
25
+ 3. 查询这些项目最近N天内新创建的分支
26
+ 4. 返回仓库地址+分支名称
27
+ """
28
+
29
+ def __init__(self, product_line: str):
30
+ """
31
+ 初始化GitLab分支查询服务
32
+
33
+ Args:
34
+ product_line: 产品线名称,如 xingye, talkie, hailuo
35
+ """
36
+ self.product_line = product_line
37
+
38
+ # 从配置文件读取GitLab相关配置
39
+ try:
40
+ self.gitlab_url = Utils.get_conf('git_info', 'gitlab_url')
41
+ except KeyError:
42
+ self.gitlab_url = "https://gitlab.xaminim.com"
43
+ logger.warning(f"未找到gitlab_url配置,使用默认值: {self.gitlab_url}")
44
+
45
+ try:
46
+ self.access_token = Utils.get_conf('git_info', 'access_token')
47
+ except KeyError:
48
+ logger.error("未找到GitLab access_token配置,请在conf.ini中配置[git_info] access_token")
49
+ raise ValueError("缺少GitLab access_token配置")
50
+
51
+ self.headers = {'PRIVATE-TOKEN': self.access_token}
52
+
53
+ # Apollo配置
54
+ try:
55
+ self.apollo_base_url = Utils.get_conf('apollo_info', 'apollo_base_url')
56
+ except KeyError:
57
+ self.apollo_base_url = "http://swing-babel-ali-prod.xaminim.com/swing/api/get_apollo_value_by_key"
58
+ logger.info(f"使用默认Apollo URL: {self.apollo_base_url}")
59
+
60
+ try:
61
+ self.apollo_key = Utils.get_conf('apollo_info', 'product_line_gitlab_key')
62
+ except KeyError:
63
+ self.apollo_key = "product_line_gitlab_projects"
64
+ logger.info(f"使用默认Apollo key: {self.apollo_key}")
65
+
66
+ # 并发配置
67
+ self.max_workers = 5
68
+
69
+ logger.info(f"初始化GitlabBranchService: product_line={product_line}, gitlab_url={self.gitlab_url}")
70
+
71
+ def get_projects_from_group(self, group_id: int) -> List[Dict]:
72
+ """
73
+ 从GitLab Group获取所有项目
74
+
75
+ Args:
76
+ group_id: GitLab Group ID
77
+
78
+ Returns:
79
+ list: 项目列表,每项包含 project_id, project_name, project_path
80
+ """
81
+ logger.info(f"从GitLab Group {group_id} 获取所有项目")
82
+
83
+ projects = []
84
+ page = 1
85
+ per_page = 100
86
+
87
+ try:
88
+ while True:
89
+ # GitLab API: 获取Group下的所有项目
90
+ group_projects_url = f"{self.gitlab_url}/api/v4/groups/{group_id}/projects"
91
+ params = {
92
+ 'per_page': per_page,
93
+ 'page': page,
94
+ 'include_subgroups': True, # 包含子Group的项目
95
+ 'archived': False # 排除已归档的项目
96
+ }
97
+
98
+ response = requests.get(group_projects_url, headers=self.headers, params=params, timeout=30)
99
+
100
+ if response.status_code != 200:
101
+ logger.error(f"获取Group项目列表失败: {response.status_code} - {response.text}")
102
+ break
103
+
104
+ page_projects = response.json()
105
+
106
+ if not page_projects:
107
+ break
108
+
109
+ for proj in page_projects:
110
+ projects.append({
111
+ 'project_id': proj.get('id'),
112
+ 'project_name': proj.get('name', ''),
113
+ 'project_path': proj.get('path_with_namespace', ''),
114
+ 'web_url': proj.get('web_url', ''),
115
+ 'default_branch': proj.get('default_branch', 'main')
116
+ })
117
+
118
+ # 检查是否还有下一页
119
+ if len(page_projects) < per_page:
120
+ break
121
+
122
+ page += 1
123
+
124
+ logger.info(f"Group {group_id} 共找到 {len(projects)} 个项目")
125
+ return projects
126
+
127
+ except requests.RequestException as e:
128
+ logger.error(f"请求GitLab Group项目列表失败: {str(e)}")
129
+ return []
130
+ except Exception as e:
131
+ logger.error(f"处理Group项目数据失败: {str(e)}")
132
+ return []
133
+
134
+ def get_group_config_from_apollo(self) -> Union[List[int], None]:
135
+ """
136
+ 从Apollo获取产品线对应的GitLab Group ID列表
137
+
138
+ Apollo配置格式示例:
139
+ {
140
+ "qa_group": [{"group_id": 1117, "group_name": "qa"}],
141
+ "xingye": [{"group_id": 123, "group_name": "xingye-server"}],
142
+ "talkie": [{"group_id": 456, "group_name": "talkie"}, {"group_id": 789, "group_name": "talkie-common"}]
143
+ }
144
+
145
+ Returns:
146
+ list: Group ID列表,如果未找到返回None
147
+ """
148
+ logger.info(f"从Apollo获取产品线 {self.product_line} 的GitLab Group配置")
149
+
150
+ try:
151
+ # 构建Apollo请求URL
152
+ apollo_url = f"{self.apollo_base_url}?key={self.apollo_key}"
153
+ logger.info(f"请求Apollo URL: {apollo_url}")
154
+
155
+ response = requests.get(apollo_url, timeout=10)
156
+
157
+ if response.status_code != 200:
158
+ logger.error(f"Apollo请求失败: {response.status_code} - {response.text}")
159
+ return None
160
+
161
+ # 解析响应
162
+ result = response.json()
163
+ logger.info(f"Apollo响应: {result}")
164
+
165
+ # Apollo返回的数据格式: {"value": "...", "base_resp": {...}}
166
+ if isinstance(result, dict):
167
+ if 'value' in result:
168
+ config_data = result['value']
169
+ elif 'data' in result:
170
+ config_data = result['data']
171
+ else:
172
+ config_data = result
173
+ else:
174
+ config_data = result
175
+
176
+ # 如果config_data是字符串,尝试解析为JSON
177
+ if isinstance(config_data, str):
178
+ try:
179
+ # 移除可能存在的尾随逗号(非标准JSON)
180
+ import re
181
+ cleaned_data = re.sub(r',\s*}', '}', config_data)
182
+ cleaned_data = re.sub(r',\s*\]', ']', cleaned_data)
183
+ config_data = json.loads(cleaned_data)
184
+ except json.JSONDecodeError as e:
185
+ logger.error(f"Apollo返回的数据不是有效的JSON: {config_data}, 错误: {e}")
186
+ return None
187
+
188
+ # 获取对应产品线的Group配置
189
+ if self.product_line in config_data:
190
+ group_config = config_data[self.product_line]
191
+ group_ids = []
192
+
193
+ # 格式: [{"group_id": 1117, "group_name": "qa"}, ...]
194
+ if isinstance(group_config, list):
195
+ for item in group_config:
196
+ if isinstance(item, dict) and 'group_id' in item:
197
+ group_ids.append(item['group_id'])
198
+ elif isinstance(item, int):
199
+ # 兼容直接配置数字的情况
200
+ group_ids.append(item)
201
+
202
+ if group_ids:
203
+ logger.info(f"产品线 {self.product_line} 配置了 {len(group_ids)} 个Group: {group_ids}")
204
+ return group_ids
205
+ else:
206
+ logger.warning(f"产品线 {self.product_line} 的Group配置为空")
207
+ return None
208
+ elif isinstance(group_config, int):
209
+ # 兼容直接配置单个group_id的情况
210
+ return [group_config]
211
+ else:
212
+ logger.warning(f"产品线 {self.product_line} 的Group配置格式不正确: {group_config}")
213
+ return None
214
+ else:
215
+ logger.warning(f"未找到产品线 {self.product_line} 的配置,可用的产品线: {list(config_data.keys())}")
216
+ return None
217
+
218
+ except requests.RequestException as e:
219
+ logger.error(f"请求Apollo失败: {str(e)}")
220
+ return None
221
+ except Exception as e:
222
+ logger.error(f"解析Apollo响应失败: {str(e)}")
223
+ return None
224
+
225
+ def get_all_projects(self) -> List[Dict]:
226
+ """
227
+ 获取产品线对应的所有GitLab项目
228
+
229
+ 通过Apollo获取Group ID列表,然后自动获取这些Group下的所有项目
230
+
231
+ Returns:
232
+ list: 项目列表,每项包含 project_id, project_name, project_path
233
+ """
234
+ logger.info(f"获取产品线 {self.product_line} 的所有GitLab项目")
235
+
236
+ # 从Apollo获取Group ID列表
237
+ group_ids = self.get_group_config_from_apollo()
238
+
239
+ if not group_ids:
240
+ logger.warning(f"产品线 {self.product_line} 未配置任何GitLab Group")
241
+ return []
242
+
243
+ all_projects = []
244
+
245
+ # 遍历所有Group,获取其下的项目
246
+ for group_id in group_ids:
247
+ projects = self.get_projects_from_group(group_id)
248
+ all_projects.extend(projects)
249
+
250
+ # 去重(按project_id)
251
+ seen_ids = set()
252
+ unique_projects = []
253
+ for proj in all_projects:
254
+ if proj['project_id'] not in seen_ids:
255
+ seen_ids.add(proj['project_id'])
256
+ unique_projects.append(proj)
257
+
258
+ logger.info(f"产品线 {self.product_line} 共找到 {len(unique_projects)} 个唯一项目")
259
+ return unique_projects
260
+
261
+ def get_recent_branches(self, project_id: int, project_name: str,
262
+ project_path: str, days: int = 3) -> List[Dict]:
263
+ """
264
+ 获取项目最近N天内新创建的分支
265
+
266
+ Args:
267
+ project_id: GitLab项目ID
268
+ project_name: 项目名称
269
+ project_path: 项目路径
270
+ days: 查询最近多少天的分支,默认3天
271
+
272
+ Returns:
273
+ list: 分支列表,每项包含 repo_url, project_name, branch_name, created_at, author
274
+ """
275
+ logger.info(f"获取项目 {project_name} (ID: {project_id}) 最近 {days} 天的分支")
276
+
277
+ recent_branches = []
278
+
279
+ try:
280
+ # 计算时间阈值
281
+ now = datetime.now(timezone(timedelta(hours=8)))
282
+ threshold = now - timedelta(days=days)
283
+
284
+ # 获取分支列表
285
+ branches_url = f"{self.gitlab_url}/api/v4/projects/{project_id}/repository/branches"
286
+ params = {
287
+ 'per_page': 100 # 获取更多分支
288
+ }
289
+
290
+ response = requests.get(branches_url, headers=self.headers, params=params, timeout=30)
291
+
292
+ if response.status_code != 200:
293
+ logger.error(f"获取分支列表失败: {response.status_code} - {response.text}")
294
+ return []
295
+
296
+ branches = response.json()
297
+ logger.info(f"项目 {project_name} 共有 {len(branches)} 个分支")
298
+
299
+ # 筛选最近N天创建的分支
300
+ for branch in branches:
301
+ branch_name = branch.get('name', '')
302
+ commit = branch.get('commit', {})
303
+ committed_date_str = commit.get('committed_date', '')
304
+ author_name = commit.get('author_name', 'unknown')
305
+
306
+ if not committed_date_str:
307
+ continue
308
+
309
+ # 解析提交时间
310
+ try:
311
+ # GitLab返回的时间格式: 2026-02-03T10:30:00.000+08:00
312
+ committed_date = datetime.fromisoformat(committed_date_str.replace('Z', '+00:00'))
313
+
314
+ # 检查是否在时间范围内
315
+ if committed_date >= threshold:
316
+ # 排除主分支
317
+ if branch_name not in ['main', 'master', 'develop', 'release']:
318
+ repo_url = f"{self.gitlab_url}/{project_path}"
319
+ recent_branches.append({
320
+ 'repo_url': repo_url,
321
+ 'project_name': project_name,
322
+ 'branch_name': branch_name,
323
+ 'created_at': committed_date_str,
324
+ 'author': author_name
325
+ })
326
+ logger.info(f"找到新分支: {branch_name} (创建于 {committed_date_str})")
327
+ except (ValueError, TypeError) as e:
328
+ logger.warning(f"解析分支 {branch_name} 的时间失败: {e}")
329
+ continue
330
+
331
+ logger.info(f"项目 {project_name} 最近 {days} 天有 {len(recent_branches)} 个新分支")
332
+ return recent_branches
333
+
334
+ except requests.RequestException as e:
335
+ logger.error(f"请求GitLab分支列表失败: {str(e)}")
336
+ return []
337
+ except Exception as e:
338
+ logger.error(f"处理分支数据失败: {str(e)}")
339
+ return []
340
+
341
+ def analyze_all_projects(self, days: int = 3) -> List[Dict]:
342
+ """
343
+ 分析所有项目的新分支
344
+
345
+ Args:
346
+ days: 查询最近多少天的分支,默认3天
347
+
348
+ Returns:
349
+ list: 所有新分支的列表
350
+ """
351
+ logger.info(f"开始分析产品线 {self.product_line} 所有项目最近 {days} 天的新分支")
352
+
353
+ # 从Apollo获取Group配置,然后自动获取Group下所有项目
354
+ projects = self.get_all_projects()
355
+
356
+ if not projects:
357
+ logger.warning(f"产品线 {self.product_line} 没有找到任何GitLab项目")
358
+ return []
359
+
360
+ all_branches = []
361
+
362
+ # 使用线程池并发查询各项目的分支
363
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
364
+ future_to_project = {}
365
+
366
+ for project in projects:
367
+ project_id = project.get('project_id')
368
+ project_name = project.get('project_name', '')
369
+ project_path = project.get('project_path', '')
370
+
371
+ if not project_id:
372
+ logger.warning(f"项目配置缺少project_id: {project}")
373
+ continue
374
+
375
+ future = executor.submit(
376
+ self.get_recent_branches,
377
+ project_id,
378
+ project_name,
379
+ project_path,
380
+ days
381
+ )
382
+ future_to_project[future] = project_name
383
+
384
+ # 收集结果
385
+ for future in as_completed(future_to_project):
386
+ project_name = future_to_project[future]
387
+ try:
388
+ branches = future.result()
389
+ all_branches.extend(branches)
390
+ except Exception as e:
391
+ logger.error(f"获取项目 {project_name} 分支失败: {str(e)}")
392
+
393
+ # 按创建时间排序(最新的在前)
394
+ all_branches.sort(key=lambda x: x.get('created_at', ''), reverse=True)
395
+
396
+ logger.info(f"产品线 {self.product_line} 共找到 {len(all_branches)} 个新分支")
397
+ return all_branches
398
+
399
+
400
+ def get_recent_branches_by_product_line(product_line: str, days: int = 3) -> List[Dict]:
401
+ """
402
+ 获取产品线最近N天新创建的分支列表(供外部调用的便捷函数)
403
+
404
+ Args:
405
+ product_line: 产品线名称,如 xingye, talkie, hailuo
406
+ days: 查询最近多少天的分支,默认3天
407
+
408
+ Returns:
409
+ 列表,包含 [{"repo_url": "xxx", "branch_name": "xxx", "created_at": "xxx", "author": "xxx"}, ...]
410
+ """
411
+ service = GitlabBranchService(product_line)
412
+ return service.analyze_all_projects(days)
413
+
414
+
415
+ class GitlabRepoTypeDetector:
416
+ """
417
+ GitLab仓库类型检测器
418
+ 通过分析仓库的文件结构判断是 Server 还是 FE 项目
419
+ """
420
+
421
+ # Server 项目特征文件和目录
422
+ SERVER_INDICATORS = {
423
+ 'go': {
424
+ 'files': ['go.mod', 'go.sum'],
425
+ 'dirs': ['cmd/', 'pkg/', 'internal/', 'api/'],
426
+ 'weight': 1.0
427
+ },
428
+ 'java': {
429
+ 'files': ['pom.xml', 'build.gradle', 'build.gradle.kts'],
430
+ 'dirs': ['src/main/java/', 'src/main/resources/'],
431
+ 'weight': 1.0
432
+ },
433
+ 'python': {
434
+ 'files': ['requirements.txt', 'pyproject.toml', 'setup.py', 'Pipfile'],
435
+ 'dirs': ['src/', 'app/', 'api/'],
436
+ 'weight': 0.8 # Python 也可能是脚本项目
437
+ },
438
+ 'rust': {
439
+ 'files': ['Cargo.toml', 'Cargo.lock'],
440
+ 'dirs': ['src/'],
441
+ 'weight': 1.0
442
+ },
443
+ 'csharp': {
444
+ 'files': ['*.csproj', '*.sln'],
445
+ 'dirs': ['Controllers/', 'Services/'],
446
+ 'weight': 1.0
447
+ }
448
+ }
449
+
450
+ # FE 项目特征文件和目录
451
+ FE_INDICATORS = {
452
+ 'react': {
453
+ 'files': ['package.json'],
454
+ 'dirs': ['src/components/', 'src/pages/', 'public/'],
455
+ 'package_deps': ['react', 'react-dom'],
456
+ 'weight': 1.0
457
+ },
458
+ 'vue': {
459
+ 'files': ['package.json', 'vue.config.js', 'vite.config.ts'],
460
+ 'dirs': ['src/components/', 'src/views/', 'public/'],
461
+ 'package_deps': ['vue'],
462
+ 'weight': 1.0
463
+ },
464
+ 'angular': {
465
+ 'files': ['package.json', 'angular.json'],
466
+ 'dirs': ['src/app/', 'src/assets/'],
467
+ 'package_deps': ['@angular/core'],
468
+ 'weight': 1.0
469
+ },
470
+ 'nextjs': {
471
+ 'files': ['package.json', 'next.config.js', 'next.config.mjs'],
472
+ 'dirs': ['pages/', 'app/', 'components/'],
473
+ 'package_deps': ['next'],
474
+ 'weight': 1.0
475
+ }
476
+ }
477
+
478
+ def __init__(self):
479
+ """初始化检测器"""
480
+ try:
481
+ self.gitlab_url = Utils.get_conf('git_info', 'gitlab_url')
482
+ except KeyError:
483
+ self.gitlab_url = "https://gitlab.xaminim.com"
484
+
485
+ try:
486
+ self.access_token = Utils.get_conf('git_info', 'access_token')
487
+ except KeyError:
488
+ raise ValueError("缺少GitLab access_token配置")
489
+
490
+ self.headers = {'PRIVATE-TOKEN': self.access_token}
491
+
492
+ def get_repo_tree(self, project_id: int, branch: str = None, path: str = '', recursive: bool = False) -> List[Dict]:
493
+ """
494
+ 获取仓库文件树
495
+
496
+ Args:
497
+ project_id: GitLab 项目 ID
498
+ branch: 分支名,默认使用默认分支
499
+ path: 子路径
500
+ recursive: 是否递归获取
501
+
502
+ Returns:
503
+ 文件树列表
504
+ """
505
+ url = f"{self.gitlab_url}/api/v4/projects/{project_id}/repository/tree"
506
+ params = {
507
+ 'per_page': 100,
508
+ 'recursive': recursive
509
+ }
510
+ if branch:
511
+ params['ref'] = branch
512
+ if path:
513
+ params['path'] = path
514
+
515
+ try:
516
+ response = requests.get(url, headers=self.headers, params=params, timeout=30)
517
+ if response.status_code == 200:
518
+ return response.json()
519
+ else:
520
+ logger.warning(f"获取仓库文件树失败: {response.status_code}")
521
+ return []
522
+ except Exception as e:
523
+ logger.error(f"请求GitLab文件树失败: {e}")
524
+ return []
525
+
526
+ def get_file_content(self, project_id: int, file_path: str, branch: str = None) -> Optional[str]:
527
+ """
528
+ 获取文件内容
529
+
530
+ Args:
531
+ project_id: GitLab 项目 ID
532
+ file_path: 文件路径
533
+ branch: 分支名
534
+
535
+ Returns:
536
+ 文件内容字符串
537
+ """
538
+ import urllib.parse
539
+ encoded_path = urllib.parse.quote(file_path, safe='')
540
+ url = f"{self.gitlab_url}/api/v4/projects/{project_id}/repository/files/{encoded_path}/raw"
541
+ params = {}
542
+ if branch:
543
+ params['ref'] = branch
544
+
545
+ try:
546
+ response = requests.get(url, headers=self.headers, params=params, timeout=30)
547
+ if response.status_code == 200:
548
+ return response.text
549
+ else:
550
+ return None
551
+ except Exception as e:
552
+ logger.error(f"获取文件内容失败: {e}")
553
+ return None
554
+
555
+ def detect_repo_type(self, project_id: int, branch: str = None) -> Dict:
556
+ """
557
+ 检测仓库类型
558
+
559
+ Args:
560
+ project_id: GitLab 项目 ID
561
+ branch: 分支名,默认使用默认分支
562
+
563
+ Returns:
564
+ {
565
+ "type": "server" | "fe" | "mixed" | "unknown",
566
+ "language": "go" | "java" | "python" | "react" | "vue" | ...,
567
+ "framework": 框架名称,
568
+ "confidence": 0.0-1.0,
569
+ "indicators": ["go.mod", "cmd/", ...]
570
+ }
571
+ """
572
+ logger.info(f"检测项目 {project_id} 的仓库类型")
573
+
574
+ # 获取根目录文件列表
575
+ tree = self.get_repo_tree(project_id, branch)
576
+ if not tree:
577
+ return {
578
+ "type": "unknown",
579
+ "language": None,
580
+ "framework": None,
581
+ "confidence": 0.0,
582
+ "indicators": [],
583
+ "error": "无法获取仓库文件树"
584
+ }
585
+
586
+ # 提取文件名和目录名
587
+ files = set()
588
+ dirs = set()
589
+ for item in tree:
590
+ name = item.get('name', '')
591
+ item_type = item.get('type', '')
592
+ if item_type == 'blob':
593
+ files.add(name)
594
+ elif item_type == 'tree':
595
+ dirs.add(name + '/')
596
+
597
+ logger.info(f"根目录文件: {files}")
598
+ logger.info(f"根目录目录: {dirs}")
599
+
600
+ # 检测 Server 特征
601
+ server_score = 0.0
602
+ server_lang = None
603
+ server_indicators = []
604
+
605
+ for lang, indicators in self.SERVER_INDICATORS.items():
606
+ lang_score = 0
607
+ lang_indicators = []
608
+
609
+ # 检查特征文件
610
+ for f in indicators.get('files', []):
611
+ if f in files:
612
+ lang_score += 1
613
+ lang_indicators.append(f)
614
+
615
+ # 检查特征目录
616
+ for d in indicators.get('dirs', []):
617
+ dir_name = d.rstrip('/')
618
+ if dir_name + '/' in dirs:
619
+ lang_score += 0.5
620
+ lang_indicators.append(d)
621
+
622
+ if lang_score > server_score:
623
+ server_score = lang_score * indicators.get('weight', 1.0)
624
+ server_lang = lang
625
+ server_indicators = lang_indicators
626
+
627
+ # 检测 FE 特征
628
+ fe_score = 0.0
629
+ fe_framework = None
630
+ fe_indicators = []
631
+
632
+ # 先检查是否有 package.json
633
+ has_package_json = 'package.json' in files
634
+
635
+ if has_package_json:
636
+ # 读取 package.json 分析依赖
637
+ package_content = self.get_file_content(project_id, 'package.json', branch)
638
+ deps = set()
639
+ if package_content:
640
+ try:
641
+ package_json = json.loads(package_content)
642
+ deps.update(package_json.get('dependencies', {}).keys())
643
+ deps.update(package_json.get('devDependencies', {}).keys())
644
+ except json.JSONDecodeError:
645
+ pass
646
+
647
+ for framework, indicators in self.FE_INDICATORS.items():
648
+ framework_score = 0
649
+ framework_indicators = []
650
+
651
+ # 检查 package.json 依赖
652
+ for dep in indicators.get('package_deps', []):
653
+ if dep in deps:
654
+ framework_score += 2
655
+ framework_indicators.append(f"dep:{dep}")
656
+
657
+ # 检查特征文件
658
+ for f in indicators.get('files', []):
659
+ if f in files and f != 'package.json':
660
+ framework_score += 0.5
661
+ framework_indicators.append(f)
662
+
663
+ # 检查特征目录
664
+ for d in indicators.get('dirs', []):
665
+ dir_name = d.rstrip('/')
666
+ if dir_name + '/' in dirs:
667
+ framework_score += 0.5
668
+ framework_indicators.append(d)
669
+
670
+ if framework_score > fe_score:
671
+ fe_score = framework_score * indicators.get('weight', 1.0)
672
+ fe_framework = framework
673
+ fe_indicators = framework_indicators
674
+
675
+ # 判断类型
676
+ logger.info(f"Server 得分: {server_score}, FE 得分: {fe_score}")
677
+
678
+ if server_score > 0 and fe_score > 0:
679
+ # 混合项目
680
+ if server_score > fe_score * 1.5:
681
+ repo_type = "server"
682
+ language = server_lang
683
+ framework = None
684
+ confidence = min(server_score / 3, 1.0)
685
+ indicators = server_indicators
686
+ elif fe_score > server_score * 1.5:
687
+ repo_type = "fe"
688
+ language = fe_framework
689
+ framework = fe_framework
690
+ confidence = min(fe_score / 3, 1.0)
691
+ indicators = fe_indicators
692
+ else:
693
+ repo_type = "mixed"
694
+ language = f"{server_lang}+{fe_framework}"
695
+ framework = fe_framework
696
+ confidence = min((server_score + fe_score) / 5, 1.0)
697
+ indicators = server_indicators + fe_indicators
698
+ elif server_score > 0:
699
+ repo_type = "server"
700
+ language = server_lang
701
+ framework = None
702
+ confidence = min(server_score / 2, 1.0)
703
+ indicators = server_indicators
704
+ elif fe_score > 0:
705
+ repo_type = "fe"
706
+ language = fe_framework
707
+ framework = fe_framework
708
+ confidence = min(fe_score / 2, 1.0)
709
+ indicators = fe_indicators
710
+ else:
711
+ repo_type = "unknown"
712
+ language = None
713
+ framework = None
714
+ confidence = 0.0
715
+ indicators = []
716
+
717
+ result = {
718
+ "type": repo_type,
719
+ "language": language,
720
+ "framework": framework,
721
+ "confidence": round(confidence, 2),
722
+ "indicators": indicators
723
+ }
724
+
725
+ logger.info(f"检测结果: {result}")
726
+ return result
727
+
728
+
729
+ def detect_repo_type(project_id: int, branch: str = None) -> Dict:
730
+ """
731
+ 检测仓库类型(供外部调用的便捷函数)
732
+
733
+ Args:
734
+ project_id: GitLab 项目 ID
735
+ branch: 分支名,默认使用默认分支
736
+
737
+ Returns:
738
+ {
739
+ "type": "server" | "fe" | "mixed" | "unknown",
740
+ "language": 语言/框架名称,
741
+ "framework": 框架名称(FE 项目),
742
+ "confidence": 置信度 0.0-1.0,
743
+ "indicators": 检测到的特征列表
744
+ }
745
+ """
746
+ detector = GitlabRepoTypeDetector()
747
+ return detector.detect_repo_type(project_id, branch)
748
+
749
+
750
+ class GitlabBedrockResolver:
751
+ """
752
+ GitLab 仓库与 Bedrock 服务关联解析器
753
+
754
+ 通过解析仓库的 CI 配置文件来获取 Bedrock 部署信息
755
+ """
756
+
757
+ def __init__(self):
758
+ """初始化解析器"""
759
+ try:
760
+ self.gitlab_url = Utils.get_conf('git_info', 'gitlab_url')
761
+ except KeyError:
762
+ self.gitlab_url = "https://gitlab.xaminim.com"
763
+
764
+ try:
765
+ self.access_token = Utils.get_conf('git_info', 'access_token')
766
+ except KeyError:
767
+ raise ValueError("缺少GitLab access_token配置")
768
+
769
+ self.headers = {'PRIVATE-TOKEN': self.access_token}
770
+
771
+ def get_file_content(self, project_id: int, file_path: str, branch: str = None) -> Optional[str]:
772
+ """
773
+ 获取文件内容
774
+
775
+ Args:
776
+ project_id: GitLab 项目 ID
777
+ file_path: 文件路径
778
+ branch: 分支名
779
+
780
+ Returns:
781
+ 文件内容字符串
782
+ """
783
+ import urllib.parse
784
+ encoded_path = urllib.parse.quote(file_path, safe='')
785
+ url = f"{self.gitlab_url}/api/v4/projects/{project_id}/repository/files/{encoded_path}/raw"
786
+ params = {}
787
+ if branch:
788
+ params['ref'] = branch
789
+
790
+ try:
791
+ response = requests.get(url, headers=self.headers, params=params, timeout=30)
792
+ if response.status_code == 200:
793
+ return response.text
794
+ else:
795
+ logger.debug(f"获取文件 {file_path} 失败: {response.status_code}")
796
+ return None
797
+ except Exception as e:
798
+ logger.error(f"获取文件内容失败: {e}")
799
+ return None
800
+
801
+ def get_project_info(self, project_id: int) -> Optional[Dict]:
802
+ """
803
+ 获取项目基本信息
804
+
805
+ Args:
806
+ project_id: GitLab 项目 ID
807
+
808
+ Returns:
809
+ 项目信息字典
810
+ """
811
+ url = f"{self.gitlab_url}/api/v4/projects/{project_id}"
812
+
813
+ try:
814
+ response = requests.get(url, headers=self.headers, timeout=30)
815
+ if response.status_code == 200:
816
+ return response.json()
817
+ else:
818
+ logger.warning(f"获取项目信息失败: {response.status_code}")
819
+ return None
820
+ except Exception as e:
821
+ logger.error(f"请求项目信息失败: {e}")
822
+ return None
823
+
824
+ def parse_gitlab_ci(self, ci_content: str) -> Dict:
825
+ """
826
+ 解析 .gitlab-ci.yml 内容,提取 Bedrock 相关配置
827
+
828
+ Args:
829
+ ci_content: .gitlab-ci.yml 文件内容
830
+
831
+ Returns:
832
+ Bedrock 配置信息
833
+ """
834
+ import re
835
+ import yaml
836
+
837
+ result = {
838
+ 'psm': None,
839
+ 'bedrock_project': None,
840
+ 'bedrock_app': None,
841
+ 'image_repo': None,
842
+ 'deploy_stages': [],
843
+ 'raw_variables': {}
844
+ }
845
+
846
+ try:
847
+ # 尝试解析 YAML
848
+ ci_config = yaml.safe_load(ci_content)
849
+
850
+ if not ci_config:
851
+ return result
852
+
853
+ # 1. 检查全局 variables
854
+ global_vars = ci_config.get('variables', {})
855
+ if global_vars:
856
+ result['raw_variables'].update(global_vars)
857
+
858
+ # 提取常见的 Bedrock 相关变量
859
+ for key, value in global_vars.items():
860
+ key_upper = key.upper()
861
+ if key_upper == 'PSM' or key_upper.endswith('_PSM'):
862
+ result['psm'] = value
863
+ elif key_upper == 'BEDROCK_PROJECT' or key_upper == 'PROJECT':
864
+ result['bedrock_project'] = value
865
+ elif key_upper == 'BEDROCK_APP' or key_upper == 'APP_NAME':
866
+ result['bedrock_app'] = value
867
+ elif 'IMAGE' in key_upper or 'REGISTRY' in key_upper:
868
+ if isinstance(value, str) and 'harbor' in value.lower():
869
+ result['image_repo'] = value
870
+
871
+ # 2. 检查各个 job 中的 variables
872
+ for job_name, job_config in ci_config.items():
873
+ if isinstance(job_config, dict):
874
+ job_vars = job_config.get('variables', {})
875
+ if job_vars:
876
+ for key, value in job_vars.items():
877
+ key_upper = key.upper()
878
+ if key_upper == 'PSM' and not result['psm']:
879
+ result['psm'] = value
880
+ elif key_upper == 'BEDROCK_PROJECT' and not result['bedrock_project']:
881
+ result['bedrock_project'] = value
882
+ elif key_upper == 'BEDROCK_APP' and not result['bedrock_app']:
883
+ result['bedrock_app'] = value
884
+
885
+ # 检查是否有 deploy 相关的 stage
886
+ stage = job_config.get('stage', '')
887
+ script = job_config.get('script', [])
888
+ if isinstance(stage, str) and 'deploy' in stage.lower():
889
+ result['deploy_stages'].append({
890
+ 'job_name': job_name,
891
+ 'stage': stage
892
+ })
893
+ # 检查 script 中是否有 bedrock 相关命令
894
+ if isinstance(script, list):
895
+ for cmd in script:
896
+ if isinstance(cmd, str) and 'bedrock' in cmd.lower():
897
+ if job_name not in [s.get('job_name') for s in result['deploy_stages']]:
898
+ result['deploy_stages'].append({
899
+ 'job_name': job_name,
900
+ 'stage': stage or 'unknown'
901
+ })
902
+ break
903
+
904
+ except yaml.YAMLError as e:
905
+ logger.warning(f"YAML 解析失败,尝试正则提取: {e}")
906
+ # 降级:使用正则表达式提取
907
+
908
+ # 提取 PSM
909
+ psm_match = re.search(r'PSM[:\s]*["\']?([a-zA-Z0-9_.]+)["\']?', ci_content, re.IGNORECASE)
910
+ if psm_match:
911
+ result['psm'] = psm_match.group(1)
912
+
913
+ # 提取 BEDROCK_PROJECT
914
+ project_match = re.search(r'BEDROCK_PROJECT[:\s]*["\']?([a-zA-Z0-9_-]+)["\']?', ci_content, re.IGNORECASE)
915
+ if project_match:
916
+ result['bedrock_project'] = project_match.group(1)
917
+
918
+ # 提取镜像仓库
919
+ image_match = re.search(r'(txharbor\.xaminim\.com/[a-zA-Z0-9_/-]+)', ci_content)
920
+ if image_match:
921
+ result['image_repo'] = image_match.group(1)
922
+
923
+ return result
924
+
925
+ def parse_makefile(self, makefile_content: str) -> Dict:
926
+ """
927
+ 解析 Makefile 内容,提取 Bedrock 相关配置
928
+
929
+ Args:
930
+ makefile_content: Makefile 文件内容
931
+
932
+ Returns:
933
+ Bedrock 配置信息
934
+ """
935
+ import re
936
+
937
+ result = {
938
+ 'psm': None,
939
+ 'bedrock_app': None,
940
+ 'image_repo': None
941
+ }
942
+
943
+ # 提取 PSM
944
+ psm_match = re.search(r'PSM\s*[:?]?=\s*([a-zA-Z0-9_.]+)', makefile_content)
945
+ if psm_match:
946
+ result['psm'] = psm_match.group(1)
947
+
948
+ # 提取 APP_NAME
949
+ app_match = re.search(r'APP_NAME\s*[:?]?=\s*([a-zA-Z0-9_-]+)', makefile_content)
950
+ if app_match:
951
+ result['bedrock_app'] = app_match.group(1)
952
+
953
+ # 提取镜像仓库
954
+ image_match = re.search(r'(txharbor\.xaminim\.com/[a-zA-Z0-9_/-]+)', makefile_content)
955
+ if image_match:
956
+ result['image_repo'] = image_match.group(1)
957
+
958
+ return result
959
+
960
+ def infer_from_project_name(self, project_name: str, project_path: str) -> Dict:
961
+ """
962
+ 根据项目名称推断 Bedrock 配置
963
+
964
+ Args:
965
+ project_name: 项目名称
966
+ project_path: 项目路径 (如 qa/apicase_generate_tool)
967
+
968
+ Returns:
969
+ 推断的 Bedrock 配置
970
+ """
971
+ import re
972
+
973
+ result = {
974
+ 'psm_guess': None,
975
+ 'app_guess': None,
976
+ 'confidence': 'low'
977
+ }
978
+
979
+ # 从路径中提取 group 和 project
980
+ path_parts = project_path.split('/')
981
+ if len(path_parts) >= 2:
982
+ group = path_parts[-2]
983
+ project = path_parts[-1]
984
+
985
+ # 转换项目名为 PSM 格式 (下划线转点号)
986
+ # 例如: qa/apicase_generate_tool -> qa.apicase.generate_tool
987
+ psm_project = project.replace('-', '_').lower()
988
+ result['psm_guess'] = f"{group.lower()}.{psm_project}"
989
+
990
+ # 应用名通常是项目名的 kebab-case
991
+ result['app_guess'] = project.replace('_', '-').lower()
992
+
993
+ result['confidence'] = 'medium'
994
+
995
+ return result
996
+
997
+ def get_bedrock_info(self, project_id: int, branch: str = None) -> Dict:
998
+ """
999
+ 获取仓库对应的 Bedrock 部署信息
1000
+
1001
+ Args:
1002
+ project_id: GitLab 项目 ID
1003
+ branch: 分支名,默认使用默认分支
1004
+
1005
+ Returns:
1006
+ {
1007
+ "found": True/False,
1008
+ "source": "gitlab-ci" | "makefile" | "inferred",
1009
+ "psm": "xxx.yyy.zzz",
1010
+ "bedrock_project": "project-name",
1011
+ "bedrock_app": "app-name",
1012
+ "image_repo": "txharbor.xaminim.com/...",
1013
+ "deploy_stages": [...],
1014
+ "confidence": "high" | "medium" | "low",
1015
+ "project_info": {...}
1016
+ }
1017
+ """
1018
+ logger.info(f"获取项目 {project_id} 的 Bedrock 部署信息")
1019
+
1020
+ result = {
1021
+ 'found': False,
1022
+ 'source': None,
1023
+ 'psm': None,
1024
+ 'bedrock_project': None,
1025
+ 'bedrock_app': None,
1026
+ 'image_repo': None,
1027
+ 'deploy_stages': [],
1028
+ 'confidence': 'none',
1029
+ 'project_info': None
1030
+ }
1031
+
1032
+ # 1. 获取项目基本信息
1033
+ project_info = self.get_project_info(project_id)
1034
+ if project_info:
1035
+ result['project_info'] = {
1036
+ 'name': project_info.get('name'),
1037
+ 'path': project_info.get('path_with_namespace'),
1038
+ 'web_url': project_info.get('web_url'),
1039
+ 'default_branch': project_info.get('default_branch', 'main')
1040
+ }
1041
+
1042
+ if not branch:
1043
+ branch = project_info.get('default_branch', 'main')
1044
+
1045
+ # 2. 尝试读取 .gitlab-ci.yml
1046
+ ci_content = self.get_file_content(project_id, '.gitlab-ci.yml', branch)
1047
+ if ci_content:
1048
+ ci_info = self.parse_gitlab_ci(ci_content)
1049
+
1050
+ if ci_info.get('psm') or ci_info.get('bedrock_project') or ci_info.get('bedrock_app'):
1051
+ result['found'] = True
1052
+ result['source'] = 'gitlab-ci'
1053
+ result['psm'] = ci_info.get('psm')
1054
+ result['bedrock_project'] = ci_info.get('bedrock_project')
1055
+ result['bedrock_app'] = ci_info.get('bedrock_app')
1056
+ result['image_repo'] = ci_info.get('image_repo')
1057
+ result['deploy_stages'] = ci_info.get('deploy_stages', [])
1058
+ result['confidence'] = 'high'
1059
+
1060
+ logger.info(f"从 .gitlab-ci.yml 解析到 Bedrock 配置: {result}")
1061
+ return result
1062
+
1063
+ # 3. 尝试读取 Makefile
1064
+ makefile_content = self.get_file_content(project_id, 'Makefile', branch)
1065
+ if makefile_content:
1066
+ makefile_info = self.parse_makefile(makefile_content)
1067
+
1068
+ if makefile_info.get('psm') or makefile_info.get('bedrock_app'):
1069
+ result['found'] = True
1070
+ result['source'] = 'makefile'
1071
+ result['psm'] = makefile_info.get('psm')
1072
+ result['bedrock_app'] = makefile_info.get('bedrock_app')
1073
+ result['image_repo'] = makefile_info.get('image_repo')
1074
+ result['confidence'] = 'medium'
1075
+
1076
+ logger.info(f"从 Makefile 解析到 Bedrock 配置: {result}")
1077
+ return result
1078
+
1079
+ # 4. 尝试读取 deploy.yaml 或 bedrock.yaml
1080
+ for config_file in ['deploy.yaml', 'bedrock.yaml', '.bedrock.yaml', 'deploy/bedrock.yaml']:
1081
+ config_content = self.get_file_content(project_id, config_file, branch)
1082
+ if config_content:
1083
+ try:
1084
+ import yaml
1085
+ config = yaml.safe_load(config_content)
1086
+ if config:
1087
+ result['found'] = True
1088
+ result['source'] = config_file
1089
+ result['psm'] = config.get('psm') or config.get('PSM')
1090
+ result['bedrock_project'] = config.get('project') or config.get('bedrock_project')
1091
+ result['bedrock_app'] = config.get('app') or config.get('app_name')
1092
+ result['confidence'] = 'high'
1093
+
1094
+ logger.info(f"从 {config_file} 解析到 Bedrock 配置: {result}")
1095
+ return result
1096
+ except Exception:
1097
+ continue
1098
+
1099
+ # 5. 降级:根据项目名推断
1100
+ if project_info:
1101
+ inferred = self.infer_from_project_name(
1102
+ project_info.get('name', ''),
1103
+ project_info.get('path_with_namespace', '')
1104
+ )
1105
+
1106
+ result['found'] = True
1107
+ result['source'] = 'inferred'
1108
+ result['psm'] = inferred.get('psm_guess')
1109
+ result['bedrock_app'] = inferred.get('app_guess')
1110
+ result['confidence'] = 'low'
1111
+
1112
+ logger.info(f"通过项目名推断 Bedrock 配置: {result}")
1113
+
1114
+ return result
1115
+
1116
+
1117
+ def get_bedrock_info_by_repo(project_id: int, branch: str = None) -> Dict:
1118
+ """
1119
+ 获取 Git 仓库对应的 Bedrock 部署信息(供外部调用的便捷函数)
1120
+
1121
+ 通过解析仓库的 .gitlab-ci.yml、Makefile 等配置文件,
1122
+ 提取 Bedrock 部署相关信息,包括 PSM、项目名、应用名等。
1123
+
1124
+ Args:
1125
+ project_id: GitLab 项目 ID
1126
+ branch: 分支名,默认使用默认分支
1127
+
1128
+ Returns:
1129
+ {
1130
+ "found": True/False,
1131
+ "source": "gitlab-ci" | "makefile" | "inferred",
1132
+ "psm": "xxx.yyy.zzz",
1133
+ "bedrock_project": "project-name",
1134
+ "bedrock_app": "app-name",
1135
+ "image_repo": "txharbor.xaminim.com/...",
1136
+ "deploy_stages": [...],
1137
+ "confidence": "high" | "medium" | "low",
1138
+ "project_info": {...}
1139
+ }
1140
+ """
1141
+ resolver = GitlabBedrockResolver()
1142
+ return resolver.get_bedrock_info(project_id, branch)
1143
+
1144
+
1145
+ if __name__ == '__main__':
1146
+ # 测试代码
1147
+ result = get_recent_branches_by_product_line("qa_group", 3)
1148
+ print(f"找到 {len(result)} 个新分支:")
1149
+ for branch in result:
1150
+ print(f" - {branch['project_name']}/{branch['branch_name']} ({branch['created_at']})")