Files
gochat/scripts/generate_gap_report.py
T
2026-06-04 15:44:48 +08:00

379 lines
14 KiB
Python

#!/usr/bin/env python3
"""Generate comprehensive gap analysis report comparing GoChat vs Chatwoot.
Focus on actionable metrics, not raw counts."""
import re
from collections import defaultdict
import subprocess
import json
import os
from datetime import datetime
now_str = datetime.now().strftime("%Y%m%d_%H%M")
def normalize(path):
"""Normalize param names for comparison. Replace all :xxx_id with generic :id,
but keep sub-resource segments (avatar, config, etc.) intact."""
# Replace :xxx_id patterns
path = re.sub(r':([a-zA-Z_]+)_id', ':id', path)
# Replace other named params (website_token, slug, etc.)
path = re.sub(r':(website_token|slug|identifier|channel_type|fb_id|locale|category_slug|article_slug|portal_id|user_id)', ':id', path)
# Remove trailing slashes
path = path.rstrip('/')
return path
def normalize_gochat(path):
"""Normalize GoChat params — same logic but GoChat uses specific param names."""
path = re.sub(r':([a-zA-Z_]+)_id', ':id', path)
path = re.sub(r':(website_token|slug|identifier|channel_type|fb_id|locale|category_slug|article_slug|portal_id|user_id|thread_id)', ':id', path)
path = path.rstrip('/')
return path
def load_routes(filepath, normalize_func):
routes = {}
with open(filepath) as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split(' ', 1)
if len(parts) == 2:
method, path = parts
norm = normalize_func(path)
routes[(method, norm)] = line.strip()
return routes
def extract_resource_name(path):
"""Extract the top-level resource name from a normalized path."""
# E.g. /api/v1/accounts/:id/agents/:id → accounts
m = re.match(r'/api/v\d/(accounts|platform|public)?/[^/]+', path)
if m:
return m.group(0)
# E.g. /api/v1/agent_bots/:id → agent_bots
m = re.match(r'/api/v\d/([^/]+)', path)
if m:
return m.group(0)
return path.split('/:id')[0].split('/')[-1]
def get_gochat_stats():
"""Get GoChat handler/model/service file counts."""
stats = {}
# Handler files
handler_files = subprocess.run(
"find /home/yanghao05/Workspace/gochat/internal/handler -name '*.go' | wc -l",
shell=True, capture_output=True
).stdout.decode().decode().strip()
stats['handlers'] = int(handler_files) if handler_files.isdigit() else 0
# Model files
model_files = subprocess.run(
"find /home/yanghao05/Workspace/gochat/internal/model -name '*.go' | wc -l",
shell=True, capture_output=True
).stdout.decode().decode().strip()
stats['models'] = int(model_files) if model_files.isdigit() else 0
# Service files
service_files = subprocess.run(
"find /home/yanghao05/Workspace/gochat/internal/service -name '*.go' | wc -l",
shell=True, capture_output=True
).stdout.decode().decode().strip()
stats['services'] = int(service_files) if service_files.isdigit() else 0
# Test files
test_files = subprocess.run(
"find /home/yanghao05/Workspace/gochat -path '*_test.go' -name '*.go' | wc -l",
shell=True, capture_output=True
).stdout.decode().decode().strip()
stats['tests'] = int(test_files) if test_files.isdigit() else 0
# Count handler implementations (stub vs real)
stub_count = 0
real_count = 0
for f in subprocess.run(
"find /home/yanghao05/Workspace/gochat/internal/handler -name '*.go'",
shell=True, capture_output=True
).stdout.decode().decode().strip().split('\n'):
if f.strip():
with open(f.strip()) as fh:
content = fh.read()
if 'TODO' in content or 'placeholder' in content or 'stub' in content.lower():
stub_count += 1
else:
real_count += 1
stats['stub_handlers'] = stub_count
stats['real_handlers'] = real_count
return stats
def get_chatwoot_stats():
"""Get Chatwoot controller/model/service counts."""
stats = {}
# Controller files
ctrl_files = subprocess.run(
"find /home/yanghao05/Workspace/chatwoot-reference/app/controllers -name '*.rb' | wc -l",
shell=True, capture_output=True
).stdout.decode().decode().strip()
stats['controllers'] = int(ctrl_files) if ctrl_files.isdigit() else 0
# Model files
model_files = subprocess.run(
"find /home/yanghao05/Workspace/chatwoot-reference/app/models -name '*.rb' | wc -l",
shell=True, capture_output=True
).stdout.decode().decode().strip()
stats['models'] = int(model_files) if model_files.isdigit() else 0
# Service files
service_files = subprocess.run(
"find /home/yanghao05/Workspace/chatwoot-reference/app/services -name '*.rb' | wc -l",
shell=True, capture_output=True
).stdout.decode().decode().strip()
stats['services'] = int(service_files) if service_files.isdigit() else 0
return stats
def get_chatwoot_resources():
"""Extract all unique resource names from Chatwoot routes."""
resources = set()
result = subprocess.run(
"cd /home/yanghao05/Workspace/gochat && python3 scripts/parse_chatwoot_routes_v3.py 2>&1 | grep -E '^(GET|POST|PUT|PATCH|DELETE)' | sort",
shell=True, capture_output=True
)
for line in result.stdout.decode().decode().strip().split('\n'):
m = re.match(r'(GET|POST|PUT|PATCH|DELETE)\s+/api/v\d/(?:accounts/\:id/)?([^/]+)', line)
if m:
resources.add(m.group(2))
return sorted(resources)
def get_gochat_resources():
"""Extract all unique resource names from GoChat routes."""
resources = set()
result = subprocess.run(
"cd /home/yanghao05/Workspace/gochat && ./dump_routes 2>&1 | grep -v 'CONNECT' | sort",
shell=True, capture_output=True
)
for line in result.stdout.decode().decode().strip().split('\n'):
m = re.match(r'(GET|POST|PUT|PATCH|DELETE)\s+/api/v\d/accounts/\:account_id/([^/]+)', line)
if m:
resources.add(m.group(1))
m = re.match(r'(GET|POST|PUT|PATCH|DELETE)\s+/api/v\d/([^/]+)', line)
if m:
resources.add(m.group(1))
m = re.match(r'(GET|POST|PUT|PATCH|DELETE)\s+/platform/api/v\d/([^/]+)', line)
if m:
resources.add(m.group(1))
m = re.match(r'(GET|POST|PUT|PATCH|DELETE)\s+/public/api/v\d/([^/]+)', line)
if m:
resources.add(m.group(1))
return sorted(resources)
def compare_resource_coverage():
"""Compare which resources exist in Chatwoot but not in GoChat."""
chatwoot_res = get_chatwoot_resources()
gochat_res = get_gochat_resources()
missing = chatwoot_res - gochat_res
extra = gochat_res - chatwoot_res
return chatwoot_res, gochat_res, missing, extra
def get_chatwoot_route_details():
"""Get detailed Chatwoot route list with action names."""
result = subprocess.run(
"cd /home/yanghao05/Workspace/gochat && python3 scripts/parse_chatwoot_routes_v3.py 2>&1 | grep -E '^(GET|POST|PUT|PATCH|DELETE)' | sort",
shell=True, capture_output=True
)
routes = {}
for line in result.stdout.decode().decode().strip().split('\n'):
parts = line.strip().split(' ', 2)
if len(parts) == 2:
method, path = parts
# Normalize for comparison
norm = normalize(path)
routes[(method, norm)] = path
return routes
def get_gochat_route_details():
"""Get GoChat route details."""
result = subprocess.run(
"cd /home/yanghao05/Workspace/gochat && ./dump_routes 2>&1 | grep -v 'CONNECT' | sort",
shell=True, capture_output=True
)
routes = {}
for line in result.stdout.decode().decode().strip().split('\n'):
parts = line.strip().split(' ', 1)
if len(parts) == 2:
method, path = parts
norm = normalize_gochat(path)
routes[(method, norm)] = path
return routes
def generate_report():
chatwoot = load_routes('/tmp/chatwoot_routes_all.txt', normalize)
gochat = load_routes('/tmp/gochat_routes_all.txt', normalize_gochat)
chatwoot_keys = set(chatwoot.keys())
gochat_keys = set(gochat.keys())
matched = chatwoot_keys & gochat_keys
missing_in_gochat = chatwoot_keys - gochat_keys
extra_in_gochat = gochat_keys - chatwoot_keys
# Get stats
gochat_stats = get_gochat_stats()
chatwoot_stats = get_chatwoot_stats()
chatwoot_res, gochat_res, missing_res, extra_res = compare_resource_coverage()
# Group missing routes by resource
missing_by_resource = defaultdict(list)
for key in sorted(missing_in_gochat):
method, norm_path = key
orig_path = chatwoot[key]
# Extract resource name
parts = norm_path.split('/')
if len(parts) >= 4:
# /api/v1/accounts/:id/agents/:id → agents
resource = '/'.join(parts[:4])
elif len(parts) >= 3:
resource = '/'.join(parts[:3])
else:
resource = norm_path
missing_by_resource[resource].append((method, orig_path))
# Group extra routes by resource
extra_by_resource = defaultdict(list)
for key in sorted(extra_in_gochat):
method, norm_path = key
orig_path = gochat[key]
parts = norm_path.split('/')
if len(parts) >= 4:
resource = '/'.join(parts[:4])
elif len(parts) >= 3:
resource = '/'.join(parts[:3])
else:
resource = norm_path
extra_by_resource[resource].append((method, orig_path))
report_name = f"gochat_vs_chatwoot_gap_report_{now_str}.md"
with open(report_name, 'w') as f:
f.write(f"""# GoChat vs Chatwoot 实现差距报告
**生成时间**: {now_str}
## 一、总览
| | GoChat | Chatwoot |
| --- | --- | --- |
| 路由总数 | {len(gochat_keys)} | {len(chatwoot_keys)} |
| 匹配路由 | {len(matched)} | {len(matched)} |
| GoChat缺失 | {len(missing_in_gochat)} | — |
| GoChat多余 | {len(extra_in_gochat)} | — |
| 覆盖率 | {len(matched)/len(chatwoot_keys)*100:.1f}% | — |
| Handler文件 | {gochat_stats.get('handlers', 0)} | {chatwoot_stats.get('controllers', 0)} |
| Model文件 | {gochat_stats.get('models', 0)} | {chatwoot_stats.get('models', 0)} |
| Service文件 | {gochat_stats.get('services', 0)} | {chatwoot_stats.get('services', 0)} |
| Test文件 | {gochat_stats.get('tests', 0)} | — |
| Stub Handler | {gochat_stats.get('stub_handlers', 0)} | — |
| 完整Handler | {gochat_stats.get('real_handlers', 0)} | — |
| Chatwoot资源数 | {len(chatwoot_res)} | — |
| GoChat资源数 | {len(gochat_res)} | — |
| 缺失资源 | {len(missing_res)} | — |
| 多余资源 | {len(extra_res)} | — |
## 二、缺失资源清单
以下{len(missing_res)}个资源在Chatwoot中存在但在GoChat中缺失:
""")
for res in sorted(missing_res):
f.write(f"- {res}\n")
f.write(f"""
## 三、缺失路由详情
以下{len(missing_in_gochat)}条路由在Chatwoot中存在但在GoChat中缺失(按资源分组):
""")
for resource in sorted(missing_by_resource.keys()):
routes = missing_by_resource[resource]
f.write(f"\n### {resource} — 缺失{len(routes)}条路由\n")
for method, orig_path in sorted(routes):
f.write(f"- {method} {orig_path}\n")
f.write(f"""
## 四、GoChat多余路由
以下{len(extra_in_gochat)}条路由在GoChat中存在但在Chatwoot中不存在(按资源分组):
""")
for resource in sorted(extra_by_resource.keys()):
routes = extra_by_resource[resource]
f.write(f"\n### {resource} — 多余{len(routes)}条路由\n")
for method, orig_path in sorted(routes):
f.write(f"- {method} {orig_path}\n")
f.write(f"""
## 五、优化计划
基于差距分析,建议按以下优先级推进实现:
### Phase 1: 路由补全 (P0 — 最高优先级)
当前覆盖率仅 {len(matched)/len(chatwoot_keys)*100:.1f}%,需补齐以下缺失路由:
""")
# Top 10 missing resources by route count
missing_res_count = defaultdict(int)
for key in missing_in_gochat:
method, norm_path = key
parts = norm_path.split('/')
if len(parts) >= 4:
resource = '/'.join(parts[:4])
else:
resource = norm_path
missing_res_count[resource] += 1
top_missing = sorted(missing_res_count.items(), key=lambda x: -x[1])[:15]
for resource, count in top_missing:
f.write(f"1. **{resource}** — 缺失{count}条路由\n")
f.write("""
### Phase 2: Service层补全
当前Service文件仅{}个(Chatwoot {}个),需补齐核心业务逻辑:
""".format(gochat_stats.get('services', 0), chatwoot_stats.get('services', 0)))
f.write("""
### Phase 3: Handler逻辑1:1对齐
当前Handler实现质量参差不齐({}个stub/{}个real),需逐模块验证:
- 错误码/响应结构必须匹配Chatwoot
- 副作用(事件触发、自动分配)必须一致
- 授权scope/过滤器/排序必须等效
""".format(gochat_stats.get('stub_handlers', 0), gochat_stats.get('real_handlers', 0)))
f.write("""
### Phase 4: 测试覆盖
当前测试文件{}个,需提升到834+覆盖所有核心场景:
- 每个CRUD操作的正常+异常路径
- 每个filter/sort/pagination组合
- 授权边界(角色/scope/权限)
""".format(gochat_stats.get('tests', 0)))
f.write("""
---
*报告基于 {now_str} 的代码状态生成,随着实现推进数据将动态更新。*
""")
print(f"Report generated: {report_name}")
return report_name
if __name__ == '__main__':
report = generate_report()
print(f"\n=== SUMMARY ===")
print(f"Report saved to: {report}")