@clawhub-wangzn-a6d20f3f46
生成 GitLab 团队周报,支持按产品功能分类 MR、按成员和仓库汇总贡献、输出 Markdown/HTML、生成图表和历史周报首页,并可选上传到飞书文档。用于用户提到“GitLab 周报”“团队周报”“统计本周 MR/commit”“按功能归类开发工作”“生成 HTML 周报”“上传周报到飞书”等场景。 Ge...
---
name: gitlab-weekly-report
description: |
生成 GitLab 团队周报,支持按产品功能分类 MR、按成员和仓库汇总贡献、输出 Markdown/HTML、生成图表和历史周报首页,并可选上传到飞书文档。用于用户提到“GitLab 周报”“团队周报”“统计本周 MR/commit”“按功能归类开发工作”“生成 HTML 周报”“上传周报到飞书”等场景。 Generate GitLab weekly team reports with MR categorization, contributor and repository summaries, Markdown/HTML output, charts, historical report index pages, and optional Feishu publishing. Use when users ask for “GitLab weekly report”, “team engineering summary”, “summarize merge requests and commits”, “group work by product area”, “generate an HTML report”, or “publish the report to Feishu”.
---
# GitLab Weekly Report Generator
生成适合团队复盘、周会同步和对外汇报的 GitLab 周报。
## 执行流程
1. 读取 `config/config.json`;如果不存在,先从 `config/config.example.json` 复制一份再填写。
2. 运行 `scripts/generate-report.sh` 生成周报主文件。
3. 如需图表,运行 `scripts/generate-charts.py`;如果环境缺少 `matplotlib`,接受 Mermaid 回退方案。
4. 如需发布到飞书,使用 `scripts/upload-to-feishu.sh` 或 `scripts/upload-to-feishu.js`。
5. 优先修改配置和分类规则,不要直接改业务脚本,除非需求本身变了。
## 主要能力
- 按 **一级分类 → 二级分类 → MR** 组织产品功能周报
- 按 **人 → repo** 汇总 MR、commit、贡献摘要
- 输出 `weekly_report.md` 与 `weekly_report.html`
- 生成 `stats.json`、图表和 `reports/index.html`
- 保持 Markdown 尽量兼容飞书文档
- 为 MR 和成员附上 GitLab 链接
- 支持“规则优先 + 启发式补全”的分类方式
## 关键文件
- `scripts/generate-report.sh`:命令入口
- `scripts/generate-report.py`:主逻辑
- `scripts/generate-charts.py`:图表生成
- `scripts/upload-to-feishu.sh` / `scripts/upload-to-feishu.js`:飞书上传
- `config/config.example.json`:配置示例
- `config/classification.rules.example.json`:分类规则示例
- `templates/report.template.md`:报告模板
## 配置方式
优先使用以下文件:
- `config/config.json`
- `config/classification.rules.json`
如果规则文件不存在,就从对应的 `*.example.json` 复制后再修改。
优先调整顺序:
1. `repo_rules`:适合仓库名、路径、项目归属明显的场景
2. `keyword_rules`:适合 title / label / branch 关键词补充判断
3. `default_category`:兜底分类
## 基本用法
```bash
cd /path/to/gitlab-weekly-report
cp config/config.example.json config/config.json
cp config/classification.rules.example.json config/classification.rules.json
./scripts/generate-report.sh \
-c config/config.json \
-s 2026-03-14 \
-e 2026-03-19
```
可选参数:
| 参数 | 说明 |
|---|---|
| `-c, --config` | 配置文件 |
| `-s, --start-date` | 开始日期 |
| `-e, --end-date` | 结束日期 |
| `-o, --output` | 输出目录 |
| `--no-charts` | 跳过图表生成 |
## 典型输出
```text
reports/
├── index.html
├── latest -> 2026-03-14_to_2026-03-19/
└── 2026-03-14_to_2026-03-19/
├── weekly_report.md
├── weekly_report.html
├── stats.json
└── charts/
```
## 依赖
必需:
- `python3`
- `jq`
推荐:
- `matplotlib`
- `pandas`
- `requests`
安装:
```bash
pip3 install -r requirements.txt
```
## 注意事项
- 保持 `SKILL.md` 聚焦流程和决策,不要把大段样例配置塞进来。
- 优先通过配置和规则文件调整分类结果。
- 接受图表回退到 Mermaid 的情况,不要因为缺少 `matplotlib` 阻塞周报生成。
- 飞书上传依赖本地配置和权限;发布 skill 时不要分发真实 token 或私有配置文件。
FILE:config/classification.rules.example.json
{
"priority": ["label", "title", "branch", "repo", "classification", "default"],
"default_category": {
"category": "产品与体验",
"subcategory": "性能与稳定性"
},
"repo_rules": [
{
"match": ["chat", "assistant", "gateway", "api"],
"category": "核心平台能力",
"subcategory": "自动化与工具集成",
"notes": "适合对话主链路、工具调用、网关或 API 相关仓库"
},
{
"match": ["billing", "subscription", "pay", "checkout"],
"category": "业务与运营",
"subcategory": "商业化与支付"
},
{
"match": ["account", "user-center", "profile", "auth"],
"category": "业务与运营",
"subcategory": "账号与身份"
},
{
"match": ["frontend", "mobile", "ios", "android", "webapp"],
"category": "产品与体验",
"subcategory": "客户端与界面体验"
}
],
"keyword_rules": [
{
"field": "title",
"match": ["chat", "message", "conversation", "会话", "消息", "render"],
"category": "核心平台能力",
"subcategory": "会话与交互流程"
},
{
"field": "title",
"match": ["memory", "context", "history", "recall", "summary", "记忆", "上下文"],
"category": "核心平台能力",
"subcategory": "上下文与记忆"
},
{
"field": "title",
"match": ["billing", "rate limit", "subscription", "计费", "套餐", "支付"],
"category": "业务与运营",
"subcategory": "商业化与支付"
},
{
"field": "label",
"match": ["agent", "tool", "plugin", "mcp"],
"category": "核心平台能力",
"subcategory": "自动化与工具集成"
},
{
"field": "branch",
"match": ["feat/chat", "feature/chat", "chat-", "billing-"],
"category": "核心平台能力",
"subcategory": "会话与交互流程"
}
]
}
FILE:config/config.example.json
{
"gitlab": {
"url": "https://gitlab.example.com",
"token": "glpat-your-token-here"
},
"users": [
{"username": "user_a", "id": 101, "name": "成员A"},
{"username": "user_b", "id": 102, "name": "成员B"},
{"username": "user_c", "id": 103, "name": "成员C"}
],
"feishu": {
"enabled": false,
"doc_url": "",
"parent_wiki_url": "https://your-domain.feishu.cn/wiki/parent-node",
"user_token_file": "~/path/to/feishu_token.json",
"app_id": "cli_xxxxxxxx",
"app_secret": "xxxxxxxx"
},
"visualization": {
"enabled": true,
"charts": ["commits_by_user", "mr_status", "daily_activity", "project_distribution"],
"output_format": "png"
},
"report": {
"output_dir": "./reports",
"template": "default",
"include_charts": true,
"generate_html": true,
"generate_index": true,
"classification_rules_file": "./config/classification.rules.json",
"title_prefix": "Engineering Weekly Report",
"exclude_repos": [
"example-org/sandbox-repo"
]
},
"classification": {
"产品与体验": [
{
"name": "客户端与界面体验",
"repo_keywords": ["client", "app", "frontend", "web", "ios", "android"],
"mr_keywords": ["ui", "ux", "交互", "体验", "页面", "客户端", "前端"],
"label_keywords": ["frontend", "ui", "ux"]
},
{
"name": "性能与稳定性",
"repo_keywords": ["runtime", "core", "performance", "stability"],
"mr_keywords": ["性能", "优化", "稳定", "crash", "latency", "memory"],
"label_keywords": ["performance", "stability", "bugfix"]
},
{
"name": "数据与可观测性",
"repo_keywords": ["data", "analytics", "monitor", "metrics", "logging"],
"mr_keywords": ["监控", "日志", "埋点", "analytics", "metrics", "observability"],
"label_keywords": ["monitoring", "analytics", "data"]
},
{
"name": "安全与配置管理",
"repo_keywords": ["auth", "security", "config", "policy"],
"mr_keywords": ["权限", "认证", "安全", "配置", "token", "policy"],
"label_keywords": ["security", "auth", "config"]
}
],
"业务与运营": [
{
"name": "账号与身份",
"repo_keywords": ["auth", "account", "user", "profile"],
"mr_keywords": ["登录", "注册", "账号", "账户", "profile", "身份"],
"label_keywords": ["auth", "account", "user"]
},
{
"name": "商业化与支付",
"repo_keywords": ["billing", "payment", "subscription", "checkout"],
"mr_keywords": ["支付", "订阅", "套餐", "billing", "invoice", "checkout"],
"label_keywords": ["billing", "payment", "subscription"]
},
{
"name": "增长与用户运营",
"repo_keywords": ["growth", "marketing", "engagement", "retention", "notification"],
"mr_keywords": ["增长", "转化", "留存", "运营", "campaign", "push", "通知"],
"label_keywords": ["growth", "marketing", "conversion", "retention", "engagement"]
},
{
"name": "后台与运营工具",
"repo_keywords": ["admin", "console", "ops", "dashboard"],
"mr_keywords": ["后台", "管理台", "配置台", "审批", "console", "dashboard"],
"label_keywords": ["admin", "ops", "console"]
}
],
"核心平台能力": [
{
"name": "会话与交互流程",
"repo_keywords": ["chat", "conversation", "session", "message"],
"mr_keywords": ["会话", "消息", "发送", "接收", "渲染", "conversation", "session"],
"label_keywords": ["chat", "conversation", "message"]
},
{
"name": "上下文与记忆",
"repo_keywords": ["memory", "context", "history", "search"],
"mr_keywords": ["记忆", "上下文", "history", "summary", "embedding", "检索"],
"label_keywords": ["memory", "context"]
},
{
"name": "自动化与工具集成",
"repo_keywords": ["agent", "tool", "plugin", "mcp", "workflow", "gateway", "api"],
"mr_keywords": ["agent", "tool", "插件", "技能", "workflow", "automation", "api"],
"label_keywords": ["agent", "tools", "plugin", "automation"]
},
{
"name": "模型与推理服务",
"repo_keywords": ["model", "llm", "router", "inference"],
"mr_keywords": ["模型", "推理", "路由", "provider", "reasoning", "prompt", "token"],
"label_keywords": ["model", "llm", "inference"]
}
]
}
}
FILE:requirements.txt
matplotlib>=3.5.0
pandas>=1.3.0
numpy>=1.21.0
requests>=2.26.0
python-dateutil>=2.8.0
FILE:scripts/generate-charts.py
#!/usr/bin/env python3
"""
GitLab Weekly Report - Chart Generator
优先生成 PNG 图表;若 matplotlib 不可用,则回退为 Mermaid 图表 Markdown。
"""
import json
import sys
import os
from pathlib import Path
try:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
HAS_MPL = True
plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
except Exception:
HAS_MPL = False
def load_stats(stats_file):
with open(stats_file, 'r', encoding='utf-8') as f:
return json.load(f)
def generate_png(stats, output_dir):
users = list(stats['commits_by_user'].keys())
counts = list(stats['commits_by_user'].values())
fig, ax = plt.subplots(figsize=(10, 6))
bars = ax.bar(users, counts, color='#4CAF50')
ax.set_xlabel('用户')
ax.set_ylabel('提交数')
ax.set_title('各用户提交数量统计')
ax.tick_params(axis='x', rotation=45)
for bar in bars:
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2., height, f'{int(height)}', ha='center', va='bottom')
plt.tight_layout()
plt.savefig(f'{output_dir}/commits_by_user.png', dpi=150, bbox_inches='tight')
plt.close()
mr_stats = stats['mr_stats']
labels = ['已合并', '已关闭', '进行中']
sizes = [mr_stats.get('merged', 0), mr_stats.get('closed', 0), mr_stats.get('opened', 0)]
colors = ['#4CAF50', '#f44336', '#2196F3']
fig, ax = plt.subplots(figsize=(8, 8))
ax.pie(sizes, labels=labels, colors=colors, autopct='%1.1f%%', startangle=90)
ax.set_title('MR 状态分布')
plt.savefig(f'{output_dir}/mr_status.png', dpi=150, bbox_inches='tight')
plt.close()
daily = stats['daily_activity']
dates = sorted(daily.keys())
vals = [daily[d] for d in dates]
fig, ax = plt.subplots(figsize=(12, 6))
ax.plot(dates, vals, marker='o', linewidth=2, markersize=8, color='#2196F3')
ax.set_xlabel('日期')
ax.set_ylabel('活动数')
ax.set_title('每日活动趋势')
ax.tick_params(axis='x', rotation=45)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(f'{output_dir}/daily_activity.png', dpi=150, bbox_inches='tight')
plt.close()
projects = stats['projects']
names = list(projects.keys())
pcounts = list(projects.values())
fig, ax = plt.subplots(figsize=(10, 8))
ax.pie(pcounts, labels=names, autopct='%1.1f%%', startangle=90, textprops={'fontsize': 10})
ax.set_title('项目贡献分布')
plt.savefig(f'{output_dir}/project_distribution.png', dpi=150, bbox_inches='tight')
plt.close()
return 'png'
def mermaid_xybar(title, x_labels, values, y_label='值'):
xs = ', '.join(f'"{x}"' for x in x_labels)
ys = ', '.join(str(v) for v in values)
maxv = max(values) if values else 1
return f'''```mermaid
xychart-beta
title "{title}"
x-axis [{xs}]
y-axis "{y_label}" 0 --> {maxv + max(1, int(maxv*0.2))}
bar [{ys}]
```
'''
def mermaid_pie(title, data):
body = '\n'.join(f' "{k}" : {v}' for k, v in data.items())
return f'''```mermaid
pie showData
title {title}
{body}
```
'''
def generate_mermaid(stats, output_dir):
users = list(stats['commits_by_user'].keys())
counts = list(stats['commits_by_user'].values())
daily = stats['daily_activity']
projects = stats['projects']
mr_stats = {
'已合并': stats['mr_stats'].get('merged', 0),
'已关闭': stats['mr_stats'].get('closed', 0),
'进行中': stats['mr_stats'].get('opened', 0),
}
charts_md = []
charts_md.append('# 图表版周报')
charts_md.append('')
charts_md.append('## 1. 提交数分布')
charts_md.append(mermaid_xybar('各用户提交数量', users, counts, '提交数'))
charts_md.append('## 2. MR 状态分布')
charts_md.append(mermaid_pie('MR 状态分布', mr_stats))
charts_md.append('## 3. 每日活动趋势')
charts_md.append(mermaid_xybar('每日活动趋势', list(daily.keys()), list(daily.values()), '活动数'))
charts_md.append('## 4. 项目分布')
charts_md.append(mermaid_pie('项目贡献分布', projects))
out = Path(output_dir) / 'charts.md'
out.write_text('\n'.join(charts_md), encoding='utf-8')
print(f'✅ 生成: {out}')
return 'mermaid'
def main():
if len(sys.argv) < 3:
print('用法: python3 generate-charts.py <stats.json> <output_dir>')
sys.exit(1)
stats_file = sys.argv[1]
output_dir = sys.argv[2]
os.makedirs(output_dir, exist_ok=True)
stats = load_stats(stats_file)
mode = generate_png(stats, output_dir) if HAS_MPL else generate_mermaid(stats, output_dir)
print(f'✅ 图表生成完成,模式: {mode}')
if __name__ == '__main__':
main()
FILE:scripts/generate-report-simple.sh
#!/bin/bash
#
# GitLab Weekly Report Generator - Fixed Version
# 自动生成指定时间范围内团队成员的 GitLab 周报
#
set -e
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# 默认配置
CONFIG_FILE=""
START_DATE=""
END_DATE=""
OUTPUT_DIR=""
FEISHU_UPLOAD=false
GENERATE_CHARTS=true
show_help() {
cat << EOF
GitLab Weekly Report Generator
Usage:
$0 [options]
Options:
-c, --config FILE Config file path
-s, --start-date DATE Start date (YYYY-MM-DD)
-e, --end-date DATE End date (YYYY-MM-DD)
-o, --output DIR Output directory
-f, --feishu Upload to Feishu
--no-charts Skip chart generation
-h, --help Show help
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
-c|--config) CONFIG_FILE="$2"; shift 2 ;;
-s|--start-date) START_DATE="$2"; shift 2 ;;
-e|--end-date) END_DATE="$2"; shift 2 ;;
-o|--output) OUTPUT_DIR="$2"; shift 2 ;;
-f|--feishu) FEISHU_UPLOAD=true; shift ;;
--no-charts) GENERATE_CHARTS=false; shift ;;
-h|--help) show_help; exit 0 ;;
*) echo -e "REDUnknown option: $1NC"; show_help; exit 1 ;;
esac
done
}
load_config() {
if [[ -f "$CONFIG_FILE" ]]; then
echo -e "BLUELoading config: $CONFIG_FILENC"
GITLAB_URL=$(jq -r '.gitlab.url // "https://gitlab.example.com"' "$CONFIG_FILE")
GITLAB_TOKEN=$(jq -r '.gitlab.token // ""' "$CONFIG_FILE")
USERS_JSON=$(jq -c '.users // []' "$CONFIG_FILE")
FEISHU_ENABLED=$(jq -r '.feishu.enabled // false' "$CONFIG_FILE")
FEISHU_DOC_URL=$(jq -r '.feishu.doc_url // ""' "$CONFIG_FILE")
FEISHU_APP_ID=$(jq -r '.feishu.app_id // ""' "$CONFIG_FILE")
FEISHU_APP_SECRET=$(jq -r '.feishu.app_secret // ""' "$CONFIG_FILE")
if [[ "$GENERATE_CHARTS" == true ]]; then
GENERATE_CHARTS=$(jq -r '.visualization.enabled // true' "$CONFIG_FILE")
fi
if [[ -z "$OUTPUT_DIR" ]]; then
OUTPUT_DIR=$(jq -r '.report.output_dir // "./reports"' "$CONFIG_FILE")
fi
else
echo -e "REDConfig file not found: $CONFIG_FILENC"
exit 1
fi
}
validate() {
[[ -z "$GITLAB_TOKEN" ]] && { echo -e "REDGitLab Token requiredNC"; exit 1; }
[[ "$USERS_JSON" == "[]" ]] && { echo -e "REDNo users configuredNC"; exit 1; }
if [[ -z "$START_DATE" ]]; then
START_DATE=$(date -v-7d +%Y-%m-%d 2>/dev/null || date -d "7 days ago" +%Y-%m-%d)
fi
if [[ -z "$END_DATE" ]]; then
END_DATE=$(date +%Y-%m-%d)
fi
REPORT_DIR="OUTPUT_DIR/START_DATE_to_END_DATE"
mkdir -p "$REPORT_DIR"
mkdir -p "$REPORT_DIR/charts"
echo -e "BLUEOutput: $REPORT_DIRNC"
}
get_user_events() {
local user_id=$1
curl -s "GITLAB_URL/api/v4/users/user_id/events?per_page=100" \
-H "PRIVATE-TOKEN: GITLAB_TOKEN" 2>/dev/null
}
generate_weekly_report() {
echo -e "BLUEGenerating report...NC"
echo -e "BLUEPeriod: START_DATE to END_DATENC"
echo ""
local report_file="REPORT_DIR/weekly_report.md"
# Header
cat > "$report_file" << EOF
# Agent Dev Weekly Report
Period: \`START_DATE\` to \`END_DATE\`
Team members:
EOF
# User list
local user_count=$(echo "$USERS_JSON" | jq 'length')
for ((i=0; i<user_count; i++)); do
local name=$(echo "$USERS_JSON" | jq -r ".[$i].name")
local username=$(echo "$USERS_JSON" | jq -r ".[$i].username")
echo "- $name (\`$username\`)" >> "$report_file"
done
echo "" >> "$report_file"
echo "---" >> "$report_file"
echo "" >> "$report_file"
# User details
for ((i=0; i<user_count; i++)); do
local username=$(echo "$USERS_JSON" | jq -r ".[$i].username")
local user_id=$(echo "$USERS_JSON" | jq -r ".[$i].id")
local user_name=$(echo "$USERS_JSON" | jq -r ".[$i].name")
echo -e "YELLOWProcessing: $user_nameNC"
echo "### $user_name ($username)" >> "$report_file"
echo "" >> "$report_file"
local events=$(get_user_events "$user_id")
local filtered=$(echo "$events" | jq --arg s "$START_DATE" --arg e "$END_DATE" '
map(select(.created_at >= ($s + "T00:00:00Z") and .created_at <= ($e + "T23:59:59Z")))
')
local count=$(echo "$filtered" | jq 'length')
if [[ "$count" -eq 0 ]]; then
echo "No activity in this period." >> "$report_file"
echo "" >> "$report_file"
continue
fi
echo "**Activity count**: $count" >> "$report_file"
echo "" >> "$report_file"
# MRs
local mrs=$(echo "$filtered" | jq '[map(select(.target_type == "merge_request")) | group_by(.target_iid) | map(.[0])]')
local mr_count=$(echo "$mrs" | jq 'length')
if [[ "$mr_count" -gt 0 ]]; then
echo "#### Merge Requests ($mr_count)" >> "$report_file"
echo "" >> "$report_file"
echo "$mrs" | jq -r '.[] | "- **!\(.target_iid)** \(.target_title)\n - Action: \(.action_name) | Date: \(.created_at[:10])"' >> "$report_file"
echo "" >> "$report_file"
fi
# Commits
local pushes=$(echo "$filtered" | jq '[map(select(.action_name | contains("pushed"))) | .[:10]]')
local push_count=$(echo "$pushes" | jq 'length')
if [[ "$push_count" -gt 0 ]]; then
echo "#### Commits ($push_count)" >> "$report_file"
echo "" >> "$report_file"
echo "$pushes" | jq -r '.[] | select(.push_data) | "- `\(.push_data.commit_title[:60])`\n - Branch: \(.push_data.ref) | Date: \(.created_at[:10])"' >> "$report_file"
echo "" >> "$report_file"
fi
done
# Summary
cat >> "$report_file" << EOF
## Summary
Period: START_DATE to END_DATE
### Overview
| User | Activities |
|------|------------|
EOF
for ((i=0; i<user_count; i++)); do
local name=$(echo "$USERS_JSON" | jq -r ".[$i].name")
local uid=$(echo "$USERS_JSON" | jq -r ".[$i].id")
local events=$(get_user_events "$uid")
local filtered=$(echo "$events" | jq --arg s "$START_DATE" --arg e "$END_DATE" 'map(select(.created_at >= ($s + "T00:00:00Z") and .created_at <= ($e + "T23:59:59Z"))) | length')
echo "| $name | $filtered |" >> "$report_file"
done
echo "" >> "$report_file"
echo "---" >> "$report_file"
echo "" >> "$report_file"
echo "*Generated: $(date '+%Y-%m-%d %H:%M:%S')*" >> "$report_file"
echo ""
echo -e "GREENReport generated: $report_fileNC"
# Create symlink
ln -sfn "$REPORT_DIR" "OUTPUT_DIR/latest"
echo -e "GREENDone!NC"
}
main() {
parse_args "$@"
[[ -z "$CONFIG_FILE" ]] && { echo -e "REDConfig file required (-c)NC"; exit 1; }
load_config
validate
generate_weekly_report
}
main "$@"
FILE:scripts/generate-report.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import collections
import datetime as dt
import html
import json
import re
import subprocess
import sys
import urllib.request
from pathlib import Path
from typing import Any
UTC = dt.timezone.utc
TZ8 = dt.timezone(dt.timedelta(hours=8))
MAX_MRS_PER_SUBCATEGORY = 5
DEFAULT_CLASSIFICATION = {
"产品与体验": [
{"name": "平台与基础能力", "repo_keywords": ["platform", "workspace", "infra"], "mr_keywords": ["backup", "restore", "infra", "deploy", "environment"], "label_keywords": []},
{"name": "CDN/网络", "repo_keywords": ["cdn"], "mr_keywords": ["cdn", "网络", "域名"], "label_keywords": []},
{"name": "稳定性与监控", "repo_keywords": [], "mr_keywords": ["ssh", "探活", "卡死", "稳定性", "监控", "metrics", "指标", "日志", "log"], "label_keywords": []},
{"name": "安全与配置", "repo_keywords": ["auth", "config", "secret"], "mr_keywords": ["权限", "配置", "download", "proxy", "rewrite"], "label_keywords": []},
{"name": "客户端体验", "repo_keywords": ["client", "mobile", "ios", "android", "frontend"], "mr_keywords": ["ui", "ux", "交互", "体验"], "label_keywords": []},
],
"业务与运营": [
{"name": "账号与登录", "repo_keywords": ["account", "auth", "passport"], "mr_keywords": ["账号", "邮箱", "登录", "注册", "验证", "identity"], "label_keywords": []},
{"name": "商业化与计费", "repo_keywords": ["billing", "payment", "subscription"], "mr_keywords": ["billing", "计费", "订阅", "套餐", "支付", "rate limit", "按量"], "label_keywords": []},
{"name": "后台与管理工具", "repo_keywords": ["admin", "console"], "mr_keywords": ["admin", "后台", "管理台", "审批", "dashboard", "tooling"], "label_keywords": []},
{"name": "用户运营", "repo_keywords": ["growth", "retention"], "mr_keywords": ["增长", "转化", "留存", "运营", "裂变"], "label_keywords": []},
],
"核心平台能力": [
{"name": "会话管理", "repo_keywords": ["chat", "conversation", "session"], "mr_keywords": ["会话", "session", "conversation", "入口", "新建"], "label_keywords": []},
{"name": "消息收发", "repo_keywords": ["message", "render"], "mr_keywords": ["chat", "message", "消息", "发送", "渲染", "保存", "save msg"], "label_keywords": []},
{"name": "自动化与工具集成", "repo_keywords": ["agent", "plugin", "mcp"], "mr_keywords": ["agent", "plugin", "插件", "tool", "工具", "integration", "技能", "workflow"], "label_keywords": []},
{"name": "上下文与记忆", "repo_keywords": ["memory", "context"], "mr_keywords": ["memory", "记忆", "上下文", "context", "recall"], "label_keywords": []},
{"name": "模型与推理", "repo_keywords": ["model", "llm", "router"], "mr_keywords": ["model", "模型", "推理", "router", "provider"], "label_keywords": []},
],
}
REPO_CATEGORY_OVERRIDES = {
"billing": ("业务与运营", "商业化与计费"),
"payment": ("业务与运营", "商业化与计费"),
"growth": ("业务与运营", "用户运营"),
"marketing": ("业务与运营", "用户运营"),
"retention": ("业务与运营", "用户运营"),
"account": ("业务与运营", "账号与登录"),
"auth": ("业务与运营", "账号与登录"),
"admin": ("业务与运营", "后台与管理工具"),
"console": ("业务与运营", "后台与管理工具"),
"chat": ("核心平台能力", "会话管理"),
"conversation": ("核心平台能力", "会话管理"),
"message": ("核心平台能力", "消息收发"),
"memory": ("核心平台能力", "上下文与记忆"),
"agent": ("核心平台能力", "自动化与工具集成"),
"mcp": ("核心平台能力", "自动化与工具集成"),
"llm": ("核心平台能力", "模型与推理"),
"model": ("核心平台能力", "模型与推理"),
"infra": ("产品与体验", "平台与基础能力"),
"ci": ("产品与体验", "平台与基础能力"),
"monitor": ("产品与体验", "稳定性与监控"),
"otel": ("产品与体验", "稳定性与监控"),
"security": ("产品与体验", "安全与配置"),
"config": ("产品与体验", "安全与配置"),
"frontend": ("产品与体验", "客户端体验"),
"mobile": ("产品与体验", "客户端体验"),
"cdn": ("产品与体验", "CDN/网络"),
}
HIGH_SIGNAL_PATTERNS = [
r"feat", r"feature", r"support", r"add", r"新增", r"接入", r"上线", r"优化", r"重构", r"refactor", r"improve", r"ability", r"workflow"
]
LOW_SIGNAL_PATTERNS = [
r"chore", r"deps?", r"bump", r"lint", r"format", r"typo", r"docs?", r"test", r"ci", r"merge branch"
]
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser()
p.add_argument("-c", "--config", required=True)
p.add_argument("-s", "--start-date")
p.add_argument("-e", "--end-date")
p.add_argument("-o", "--output")
p.add_argument("--no-charts", action="store_true")
return p.parse_args()
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def request_json(url: str, headers: dict[str, str]) -> Any:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.load(resp)
def paginate(url: str, headers: dict[str, str], max_pages: int = 8) -> list[Any]:
items: list[Any] = []
for page in range(1, max_pages + 1):
sep = '&' if '?' in url else '?'
data = request_json(f"{url}{sep}page={page}&per_page=100", headers)
if not data:
break
if isinstance(data, list):
items.extend(data)
if len(data) < 100:
break
else:
return data
return items
def parse_dt(s: str) -> dt.datetime:
if s.endswith('Z'):
s = s.replace('Z', '+00:00')
return dt.datetime.fromisoformat(s)
def daterange_default() -> tuple[str, str]:
today = dt.date.today()
start = today - dt.timedelta(days=7)
return start.isoformat(), today.isoformat()
def markdown_link(label: str, url: str) -> str:
return f"[{label}]({url})"
def load_rule_config(root_dir: Path) -> dict[str, Any]:
rules_path = root_dir / "config" / "classification.rules.json"
if rules_path.exists():
return load_json(rules_path)
return {
"priority": ["repo", "label", "title", "branch", "classification", "default"],
"default_category": {"category": "产品与体验", "subcategory": "平台与基础能力"},
"repo_rules": [],
"keyword_rules": [],
}
def compact_text(text: str, limit: int = 90) -> str:
s = re.sub(r"\s+", " ", (text or "").strip())
return s[:limit] + ("…" if len(s) > limit else "")
def normalize_text(*parts: str) -> str:
return " ".join([p for p in parts if p]).lower()
def score_text(text: str, patterns: list[str], weight: int) -> int:
total = 0
for pat in patterns:
if re.search(pat, text, re.I):
total += weight
return total
def classification_score_from_matrix(mr: dict[str, Any], classification: dict[str, list[dict[str, Any]]]) -> tuple[str, str, str, int]:
repo = (mr.get("repo_name") or "").lower()
labels = " ".join(mr.get("labels") or []).lower()
text = normalize_text(mr.get("title", ""), mr.get("description", ""), labels, mr.get("source_branch", ""), mr.get("target_branch", ""))
for key, (primary, secondary) in REPO_CATEGORY_OVERRIDES.items():
if key in repo:
return primary, secondary, "repo_override", 100
best: tuple[int, str, str] | None = None
for primary, subcats in classification.items():
for sub in subcats[:5]:
score = 0
for kw in sub.get("repo_keywords", []):
if kw.lower() in repo:
score += 10
for kw in sub.get("label_keywords", []):
if kw.lower() in labels:
score += 7
for kw in sub.get("mr_keywords", []):
if kw.lower() in text:
score += 5
if best is None or score > best[0]:
best = (score, primary, sub.get("name", "未分类"))
if not best or best[0] <= 0:
return "产品与体验", "平台与基础能力", "fallback", 0
mode = "rule" if best[0] >= 10 else "fallback"
return best[1], best[2], mode, best[0]
def match_any(text: str, patterns: list[str]) -> bool:
text = (text or "").lower()
return any(p.lower() in text for p in patterns)
def classify_mr(mr: dict[str, Any], classification: dict[str, list[dict[str, Any]]], rule_config: dict[str, Any]) -> tuple[str, str, str, int]:
repo = (mr.get("repo_name") or "")
labels = " ".join(mr.get("labels") or [])
title = mr.get("title") or ""
branch = normalize_text(mr.get("source_branch", ""), mr.get("target_branch", ""))
fallback = classification_score_from_matrix(mr, classification)
default_category = rule_config.get("default_category", {"category": fallback[0], "subcategory": fallback[1]})
field_values = {
"repo": repo,
"label": labels,
"title": title,
"branch": branch,
}
priority = rule_config.get("priority") or ["repo", "label", "title", "branch", "classification", "default"]
for layer in priority:
if layer == "repo":
for rule in rule_config.get("repo_rules", []):
if match_any(repo, rule.get("match", [])):
return rule["category"], rule["subcategory"], "config_repo", 1000
elif layer in {"label", "title", "branch"}:
for rule in rule_config.get("keyword_rules", []):
if rule.get("field") == layer and match_any(field_values[layer], rule.get("match", [])):
return rule["category"], rule["subcategory"], f"config_{layer}", 900
elif layer == "classification":
return fallback
elif layer == "default":
return default_category["category"], default_category["subcategory"], "config_default", 1
return fallback
def mr_priority(mr: dict[str, Any]) -> int:
state_score = {"merged": 30, "opened": 18, "closed": 8}.get(mr.get("state"), 0)
text = normalize_text(mr.get("title", ""), mr.get("description", ""), " ".join(mr.get("labels") or []))
keyword_score = score_text(text, HIGH_SIGNAL_PATTERNS, 4) - score_text(text, LOW_SIGNAL_PATTERNS, 3)
change_count = mr.get("changes_count_num", 0)
recency = 0
updated = mr.get("updated_at") or ""
if updated:
recency = int(re.sub(r"\D", "", updated[:19]) or "0") % 1000000
return state_score + keyword_score + min(change_count, 20) + min(recency // 10000, 20)
def summarize_overflow(mrs: list[dict[str, Any]]) -> str:
if not mrs:
return ""
merged = sum(1 for x in mrs if x["state"] == "merged")
opened = sum(1 for x in mrs if x["state"] == "opened")
closed = sum(1 for x in mrs if x["state"] == "closed")
repos = [x["repo_name"].split("/")[-1] for x in mrs]
top_repos = [name for name, _ in collections.Counter(repos).most_common(3)]
repo_text = " / ".join(top_repos) if top_repos else "多个仓库"
return f"其余 {len(mrs)} 个 MR({merged} merged / {opened} opened / {closed} closed),主要涉及 {repo_text}。"
def render_markdown(report: dict[str, Any]) -> str:
lines: list[str] = []
lines += [f"# {report['title']}", "", f"时间范围:`{report['start']}` 至 `{report['end']}`", ""]
lines += ["## 参与成员", ""]
for user in report["users"]:
lines.append(f"- {markdown_link(user['name'], user['web_url'])} (`{user['username']}`)")
lines += ["", "## 一、产品功能", ""]
for primary in report["product_sections"]:
lines += [f"### {primary['name']}", ""]
for sub in primary["subsections"]:
lines += [f"#### {sub['name']}({sub['mr_total']} 个 MR)", ""]
if sub["mr_total"] == 0:
lines += ["- 本周暂无归入该分类的 MR", ""]
continue
for mr in sub["top_mrs"]:
lines.append(f"- {markdown_link(f'!{mr['iid']} {mr['title']}', mr['web_url'])} | 提交人:{markdown_link(mr['author_name'], mr['author_url'])} | 仓库:`{mr['repo_name']}` | 状态:**{mr['state_cn']}**")
if sub.get("overflow_summary"):
lines.append(f"- {sub['overflow_summary']}")
lines.append("")
lines += ["## 二、人员统计", ""]
for person in report["people_sections"]:
lines += [f"### {markdown_link(person['name'], person['web_url'])}", ""]
lines.append(f"- 主要贡献仓库数:**{person['repo_count']}**")
lines.append(f"- MR 总数:**{person['mr_total']}**(已合并 {person['mr_merged']} / 进行中 {person['mr_opened']} / 已关闭 {person['mr_closed']})")
lines.append(f"- Commit 总数:**{person['commit_total']}**")
top_repos = [r for r in person["repos"] if r["mr_total"] > 0 or r["commit_total"] > 0][:4]
if top_repos:
lines.append("- 重点仓库:")
for repo in top_repos:
lines.append(f" - `{repo['repo_name']}`:MR {repo['mr_total']}(merged {repo['mr_merged']} / opened {repo['mr_opened']} / closed {repo['mr_closed']}),Commit {repo['commit_total']};{repo['summary']}")
lines.append("")
stats = report["stats"]
lines += ["## 三、团队整体产出统计", ""]
lines.append(f"- 总 MR:**{stats['mr_total']}**")
lines.append(f"- 总 Commit:**{stats['commit_total']}**")
lines.append(f"- 活跃仓库数:**{stats['repo_total']}**")
lines.append(f"- 活跃成员数:**{stats['user_total']}**")
lines += ["", "### 统计摘要", ""]
lines.append(f"- MR 状态分布:**Merged {stats['mr_merged']} / Opened {stats['mr_opened']} / Closed {stats['mr_closed']}**")
lines.append(f"- 每日最高活动:**{stats['busiest_day']}**")
lines.append(f"- 最高产出仓库:`{stats['top_repo']}`")
if report.get("top_contributors"):
lines.append("- 成员产出排行:")
for item in report["top_contributors"]:
lines.append(f" - {item['name']}:MR {item['mr_total']},Commit {item['commit_total']}")
if report.get("top_categories"):
lines.append("- 产品方向分布:")
for item in report["top_categories"]:
lines.append(f" - {item['name']}:{item['mr_total']} 个 MR")
lines += ["", f"_报告生成时间:{report['generated_at']}_", ""]
return "\n".join(lines)
def json_for_js(value: Any) -> str:
return json.dumps(value, ensure_ascii=False)
def render_html(report: dict[str, Any], stats_payload: dict[str, Any]) -> str:
dashboard_cards = f"""
<div class='metric'><span>总 MR</span><strong>{report['stats']['mr_total']}</strong></div>
<div class='metric'><span>总 Commit</span><strong>{report['stats']['commit_total']}</strong></div>
<div class='metric'><span>活跃仓库</span><strong>{report['stats']['repo_total']}</strong></div>
<div class='metric'><span>活跃成员</span><strong>{report['stats']['user_total']}</strong></div>
"""
product_sections_html = []
for primary in report["product_sections"]:
subs = []
for sub in primary["subsections"]:
items = []
for mr in sub["top_mrs"]:
items.append(f"<li><a href='{html.escape(mr['web_url'])}' target='_blank'>!{mr['iid']} {html.escape(mr['title'])}</a><span>{html.escape(mr['repo_name'])} · {html.escape(mr['author_name'])} · {html.escape(mr['state_cn'])}</span></li>")
overflow = f"<p class='overflow'>{html.escape(sub['overflow_summary'])}</p>" if sub.get("overflow_summary") else ""
empty = "<p class='empty'>本周暂无归入该分类的 MR</p>" if sub["mr_total"] == 0 else ""
subs.append(f"<section class='subcat'><h4>{html.escape(sub['name'])}<em>{sub['mr_total']} 个 MR</em></h4>{empty}<ul>{''.join(items)}</ul>{overflow}</section>")
product_sections_html.append(f"<section class='primary'><h3>{html.escape(primary['name'])}</h3>{''.join(subs)}</section>")
people_html = []
for person in report["people_sections"]:
repo_blocks = []
for repo in person["repos"][:6]:
top_mrs = "".join([f"<li><a href='{html.escape(m['web_url'])}' target='_blank'>!{m['iid']} {html.escape(m['title'])}</a></li>" for m in repo['top_mrs'][:3]])
repo_blocks.append(f"<div class='repo-card'><h4>{html.escape(repo['repo_name'])}</h4><p>MR {repo['mr_total']} · Commit {repo['commit_total']}</p><p>{html.escape(repo['summary'])}</p><ul>{top_mrs}</ul></div>")
people_html.append(f"<section class='person'><h3><a href='{html.escape(person['web_url'])}' target='_blank'>{html.escape(person['name'])}</a></h3><p>MR {person['mr_total']}(merged {person['mr_merged']} / opened {person['mr_opened']} / closed {person['mr_closed']}) · Commit {person['commit_total']}</p><div class='repo-grid'>{''.join(repo_blocks)}</div></section>")
return f"""<!doctype html>
<html lang='zh-CN'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>{html.escape(report['title'])}</title>
<script src='https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js'></script>
<style>
body{{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Arial,PingFang SC,Microsoft YaHei,sans-serif;background:#0b1020;color:#e5e7eb;margin:0;padding:0}}
.wrapper{{max-width:1280px;margin:0 auto;padding:24px}}
.hero{{display:flex;justify-content:space-between;gap:24px;align-items:flex-end;margin-bottom:24px}}
.hero h1{{margin:0;font-size:34px}} .hero p{{color:#94a3b8}}
.metrics{{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:20px 0 28px}}
.metric,.panel,.primary,.person{{background:#111827;border:1px solid #1f2937;border-radius:16px;padding:18px;box-shadow:0 8px 24px rgba(0,0,0,.2)}}
.metric span{{display:block;color:#94a3b8;font-size:13px;margin-bottom:10px}} .metric strong{{font-size:30px}}
.charts{{display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-bottom:28px}} .chart{{height:340px}}
.section-title{{margin:28px 0 14px;font-size:22px}}
.primary{{margin-bottom:18px}} .subcat{{padding:14px 0;border-top:1px solid #1f2937}} .subcat:first-of-type{{border-top:none;padding-top:0}}
.subcat h4{{margin:0 0 10px;display:flex;justify-content:space-between;align-items:center}} .subcat em{{font-style:normal;color:#94a3b8;font-size:12px}}
ul{{margin:0;padding-left:18px}} li{{margin:6px 0}} li span{{color:#94a3b8;font-size:13px;display:block;margin-top:2px}}
a{{color:#60a5fa;text-decoration:none}} a:hover{{text-decoration:underline}}
.overflow,.empty{{color:#94a3b8;margin-top:10px}}
.repo-grid{{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}} .repo-card{{background:#0f172a;border:1px solid #1f2937;border-radius:12px;padding:14px}}
@media (max-width: 960px){{.metrics,.charts,.repo-grid{{grid-template-columns:1fr}} .hero{{display:block}}}}
</style>
</head>
<body>
<div class='wrapper'>
<div class='hero'>
<div>
<h1>{html.escape(report['title'])}</h1>
<p>时间范围:{html.escape(report['start'])} 至 {html.escape(report['end'])}</p>
</div>
<div><p>生成时间:{html.escape(report['generated_at'])}</p></div>
</div>
<div class='metrics'>{dashboard_cards}</div>
<h2 class='section-title'>团队整体产出统计</h2>
<div class='charts'>
<div class='panel'><div id='chart-commits' class='chart'></div></div>
<div class='panel'><div id='chart-mr' class='chart'></div></div>
<div class='panel'><div id='chart-daily' class='chart'></div></div>
<div class='panel'><div id='chart-projects' class='chart'></div></div>
</div>
<h2 class='section-title'>产品功能</h2>
{''.join(product_sections_html)}
<h2 class='section-title'>人员统计</h2>
{''.join(people_html)}
</div>
<script>
const stats = {json_for_js(stats_payload)};
const commitsChart = echarts.init(document.getElementById('chart-commits'));
commitsChart.setOption({{backgroundColor:'transparent',title:{{text:'提交数分布',textStyle:{{color:'#e5e7eb'}}}},tooltip:{{trigger:'axis'}},xAxis:{{type:'category',data:Object.keys(stats.commits_by_user),axisLabel:{{color:'#cbd5e1',rotate:20}}}},yAxis:{{type:'value',axisLabel:{{color:'#cbd5e1'}}}},series:[{{type:'bar',data:Object.values(stats.commits_by_user),itemStyle:{{color:'#34d399'}},barMaxWidth:42}}]}});
const mrChart = echarts.init(document.getElementById('chart-mr'));
mrChart.setOption({{backgroundColor:'transparent',title:{{text:'MR 状态分布',textStyle:{{color:'#e5e7eb'}}}},tooltip:{{trigger:'item'}},series:[{{type:'pie',radius:['45%','70%'],data:[{{name:'Merged',value:stats.mr_stats.merged}},{{name:'Opened',value:stats.mr_stats.opened}},{{name:'Closed',value:stats.mr_stats.closed}}]}}]}});
const dailyChart = echarts.init(document.getElementById('chart-daily'));
dailyChart.setOption({{backgroundColor:'transparent',title:{{text:'每日活动趋势',textStyle:{{color:'#e5e7eb'}}}},tooltip:{{trigger:'axis'}},xAxis:{{type:'category',data:Object.keys(stats.daily_activity),axisLabel:{{color:'#cbd5e1',rotate:20}}}},yAxis:{{type:'value',axisLabel:{{color:'#cbd5e1'}}}},series:[{{type:'line',smooth:true,data:Object.values(stats.daily_activity),lineStyle:{{color:'#60a5fa'}},itemStyle:{{color:'#60a5fa'}}}}]}});
const projectEntries = Object.entries(stats.projects).sort((a,b)=>b[1]-a[1]).slice(0,10);
const projectChart = echarts.init(document.getElementById('chart-projects'));
projectChart.setOption({{backgroundColor:'transparent',title:{{text:'项目分布',textStyle:{{color:'#e5e7eb'}}}},tooltip:{{trigger:'axis'}},grid:{{left:140,right:30,top:50,bottom:20}},xAxis:{{type:'value',axisLabel:{{color:'#cbd5e1'}}}},yAxis:{{type:'category',data:projectEntries.map(x=>x[0]),axisLabel:{{color:'#cbd5e1'}}}},series:[{{type:'bar',data:projectEntries.map(x=>x[1]),itemStyle:{{color:'#f59e0b'}}}}]}});
window.addEventListener('resize', ()=>[commitsChart,mrChart,dailyChart,projectChart].forEach(c=>c.resize()));
</script>
</body></html>"""
def generate_index_html(output_dir: Path, title_prefix: str = "GitLab Weekly Report") -> None:
report_dirs = sorted([p for p in output_dir.iterdir() if p.is_dir() and re.match(r"\d{4}-\d{2}-\d{2}_to_\d{4}-\d{2}-\d{2}", p.name)], reverse=True)
cards = []
for rd in report_dirs:
stats_file = rd / "stats.json"
meta = load_json(stats_file) if stats_file.exists() else {}
summary = meta.get("dashboard", {})
cards.append(f"<div class='card'><h2><a href='./{rd.name}/weekly_report.html'>{rd.name}</a></h2><p>MR {summary.get('mr_total','-')} · Commit {summary.get('commit_total','-')} · Repo {summary.get('repo_total','-')}</p><p><a href='./{rd.name}/weekly_report.md'>Markdown</a> · <a href='./{rd.name}/weekly_report.html'>HTML</a></p></div>")
html_doc = f"""<!doctype html><html lang='zh-CN'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'><title>{html.escape(title_prefix)} 仪表盘</title><style>body{{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Arial,PingFang SC,Microsoft YaHei,sans-serif;max-width:1100px;margin:40px auto;padding:0 24px;background:#fafafa;color:#111827}}.hero{{background:#111827;color:#fff;padding:24px;border-radius:16px}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-top:24px}}.card{{background:white;border:1px solid #e5e7eb;border-radius:16px;padding:18px}}a{{color:#2563eb;text-decoration:none}}</style></head><body><div class='hero'><h1>{html.escape(title_prefix)} 仪表盘</h1><p>历史周报入口与关键统计</p></div><div class='grid'>{''.join(cards) or '<p>暂无周报</p>'}</div></body></html>"""
(output_dir / "index.html").write_text(html_doc, encoding="utf-8")
def main() -> int:
args = parse_args()
config_path = Path(args.config).expanduser().resolve()
cfg = load_json(config_path)
start, end = args.start_date, args.end_date
if not start or not end:
start, end = daterange_default()
root_dir = config_path.parent.parent
output_dir = Path(args.output or cfg.get("report", {}).get("output_dir") or (root_dir / "reports"))
if not output_dir.is_absolute():
output_dir = (root_dir / output_dir).resolve()
report_dir = output_dir / f"{start}_to_{end}"
charts_dir = report_dir / "charts"
report_dir.mkdir(parents=True, exist_ok=True)
charts_dir.mkdir(parents=True, exist_ok=True)
headers = {"PRIVATE-TOKEN": cfg["gitlab"]["token"]}
base = cfg["gitlab"]["url"].rstrip("/")
classification = cfg.get("classification", DEFAULT_CLASSIFICATION)
rule_config = load_rule_config(root_dir)
users_cfg = cfg["users"]
excluded_repos = set(cfg.get("report", {}).get("exclude_repos", []))
def is_excluded_repo(repo_name: str) -> bool:
repo_name = (repo_name or "").strip()
if not repo_name:
return False
return any(repo_name == item or repo_name.endswith(item) or item in repo_name for item in excluded_repos)
start_utc = parse_dt(f"{start}T00:00:00+08:00").astimezone(UTC)
end_utc = parse_dt(f"{end}T23:59:59+08:00").astimezone(UTC)
users_out, all_mrs, all_commits = [], [], []
daily_activity, repo_activity, commits_by_user, mr_state = collections.Counter(), collections.Counter(), collections.Counter(), collections.Counter()
project_cache: dict[int, dict[str, Any]] = {}
def get_project(project_id: int | None) -> dict[str, Any]:
if not project_id:
return {}
if project_id not in project_cache:
project_cache[project_id] = request_json(f"{base}/api/v4/projects/{project_id}", headers)
return project_cache[project_id]
for user in users_cfg:
user_detail = request_json(f"{base}/api/v4/users/{user['id']}", headers)
user_web = user_detail.get("web_url") or f"{base}/{user['username']}"
users_out.append({"id": user["id"], "username": user["username"], "name": user["name"], "web_url": user_web})
events_before = (parse_dt(f"{end}T00:00:00+08:00") + dt.timedelta(days=1)).strftime("%Y-%m-%d")
events = paginate(f"{base}/api/v4/users/{user['id']}/events?action=pushed&after={start}&before={events_before}", headers)
for event in events:
created = event.get("created_at")
if not created:
continue
created_dt = parse_dt(created)
if not (start_utc <= created_dt <= end_utc):
continue
push = event.get("push_data") or {}
commit_count = max(1, int(push.get("commit_count") or 1))
title = push.get("commit_title") or push.get("commit_from") or "Commit"
project = get_project(event.get("project_id"))
repo_name = project.get("path_with_namespace") or str(event.get("project_id") or "unknown")
if is_excluded_repo(repo_name):
continue
repo_activity[repo_name] += commit_count
commits_by_user[user["name"]] += commit_count
daily_activity[created_dt.astimezone(dt.timezone(dt.timedelta(hours=8))).strftime("%Y-%m-%d")] += commit_count
all_commits.append({"author_id": user["id"], "author_name": user["name"], "author_url": user_web, "repo_name": repo_name, "title": title, "created_at": created, "short_id": (push.get("commit_from") or "")[:8] or re.sub(r"[^a-zA-Z0-9]", "", title)[:8], "count": commit_count})
created_mrs = paginate(f"{base}/api/v4/merge_requests?author_id={user['id']}&created_after={start}T00:00:00Z&created_before={end}T23:59:59Z&scope=all", headers)
updated_mrs = paginate(f"{base}/api/v4/merge_requests?author_id={user['id']}&updated_after={start}T00:00:00Z&updated_before={end}T23:59:59Z&scope=all", headers)
merged = {mr["id"]: mr for mr in created_mrs + updated_mrs if mr.get("id")}
for mr in merged.values():
project = get_project(mr.get("project_id"))
repo_name = project.get("path_with_namespace") or mr.get("references", {}).get("full", "").split("!")[0] or str(mr.get("project_id") or "unknown")
if is_excluded_repo(repo_name):
continue
changes_raw = str(mr.get("changes_count", "0")).replace("+", "")
changes_num = int(changes_raw) if changes_raw.isdigit() else 0
primary, sub, mode, class_score = classify_mr({**mr, "repo_name": repo_name}, classification, rule_config)
item = {
"id": mr["id"], "iid": mr.get("iid"), "title": mr.get("title", ""), "description": mr.get("description", ""),
"web_url": mr.get("web_url"), "repo_name": repo_name, "state": mr.get("state", "opened"),
"state_cn": {"merged": "已合并", "opened": "进行中", "closed": "已关闭"}.get(mr.get("state", "opened"), mr.get("state", "opened")),
"labels": mr.get("labels", []), "author_id": user["id"], "author_name": user["name"], "author_url": user_web,
"classification_primary": primary, "classification_secondary": sub, "classification_mode": mode, "classification_score": class_score,
"updated_at": mr.get("updated_at"), "created_at": mr.get("created_at"), "changes_count_num": changes_num,
}
item["priority_score"] = mr_priority(item)
all_mrs.append(item)
mr_state[item["state"]] += 1
repo_activity[repo_name] += 1
raw_day = item.get("updated_at") or item.get("created_at") or ""
day = parse_dt(raw_day).astimezone(dt.timezone(dt.timedelta(hours=8))).strftime("%Y-%m-%d") if raw_day else ""
if day:
daily_activity[day] += 1
product_sections = []
for primary, subcats in classification.items():
primary_block = {"name": primary, "subsections": []}
for sub in subcats[:5]:
items = [m for m in all_mrs if m["classification_primary"] == primary and m["classification_secondary"] == sub["name"]]
items.sort(key=lambda x: (x["priority_score"], x.get("updated_at") or ""), reverse=True)
top_mrs = items[:MAX_MRS_PER_SUBCATEGORY]
overflow = items[MAX_MRS_PER_SUBCATEGORY:]
primary_block["subsections"].append({
"name": sub["name"], "mr_total": len(items), "top_mrs": top_mrs,
"overflow_summary": summarize_overflow(overflow),
})
product_sections.append(primary_block)
commits_by_person_repo: dict[tuple[int, str], list[dict[str, Any]]] = collections.defaultdict(list)
mrs_by_person_repo: dict[tuple[int, str], list[dict[str, Any]]] = collections.defaultdict(list)
for c in all_commits:
commits_by_person_repo[(c["author_id"], c["repo_name"])].append(c)
for mr in all_mrs:
mrs_by_person_repo[(mr["author_id"], mr["repo_name"])].append(mr)
people_sections = []
for user in users_out:
repos = []
repo_names = sorted({r for (uid, r) in set(list(commits_by_person_repo.keys()) + list(mrs_by_person_repo.keys())) if uid == user["id"]})
for repo_name in repo_names:
commits = commits_by_person_repo.get((user["id"], repo_name), [])
mrs = mrs_by_person_repo.get((user["id"], repo_name), [])
mrs.sort(key=lambda x: x["priority_score"], reverse=True)
mr_merged = sum(1 for x in mrs if x["state"] == "merged")
mr_opened = sum(1 for x in mrs if x["state"] == "opened")
mr_closed = sum(1 for x in mrs if x["state"] == "closed")
commit_total = sum(c["count"] for c in commits)
score = commit_total + mr_merged * 8 + mr_opened * 5 + mr_closed * 3 + sum(m["priority_score"] for m in mrs[:3])
focus = [m["classification_secondary"] for m in mrs[:3] if m.get("classification_secondary")]
summary = f"本周在 `{repo_name}` 主要推进了 {len(mrs)} 个 MR、{commit_total} 次提交,重点集中在 {'、'.join(dict.fromkeys(focus)) if focus else '日常迭代与问题修复'}"
repos.append({"repo_name": repo_name, "mr_total": len(mrs), "mr_merged": mr_merged, "mr_opened": mr_opened, "mr_closed": mr_closed, "commit_total": commit_total, "score": score, "top_mrs": mrs[:5], "summary": summary})
repos.sort(key=lambda x: x["score"], reverse=True)
user_mrs = [m for m in all_mrs if m["author_id"] == user["id"]]
people_sections.append({"name": user["name"], "web_url": user["web_url"], "repo_count": len(repos), "repos": repos, "mr_total": len(user_mrs), "mr_merged": sum(1 for m in user_mrs if m["state"] == "merged"), "mr_opened": sum(1 for m in user_mrs if m["state"] == "opened"), "mr_closed": sum(1 for m in user_mrs if m["state"] == "closed"), "commit_total": sum(c["count"] for c in all_commits if c["author_id"] == user["id"])})
stats = {"mr_total": len(all_mrs), "commit_total": sum(c["count"] for c in all_commits), "repo_total": len(set([m["repo_name"] for m in all_mrs] + [c["repo_name"] for c in all_commits])), "user_total": len(users_out), "mr_merged": mr_state["merged"], "mr_opened": mr_state["opened"], "mr_closed": mr_state["closed"], "busiest_day": max(daily_activity.items(), key=lambda x: x[1])[0] if daily_activity else "-", "top_repo": max(repo_activity.items(), key=lambda x: x[1])[0] if repo_activity else "-"}
top_contributors = sorted([
{"name": p["name"], "mr_total": p["mr_total"], "commit_total": p["commit_total"]}
for p in people_sections
], key=lambda x: (x["mr_total"], x["commit_total"]), reverse=True)
top_categories = []
for primary in product_sections:
top_categories.append({
"name": primary["name"],
"mr_total": sum(sub["mr_total"] for sub in primary["subsections"]),
})
top_categories.sort(key=lambda x: x["mr_total"], reverse=True)
stats_payload = {"period": {"start": start, "end": end}, "commits_by_user": dict(commits_by_user), "mr_stats": {"merged": mr_state["merged"], "opened": mr_state["opened"], "closed": mr_state["closed"]}, "daily_activity": dict(sorted(daily_activity.items())), "projects": dict(repo_activity), "dashboard": {"start": start, "end": end, **stats}, "top_contributors": top_contributors, "top_categories": top_categories}
stats_file = report_dir / "stats.json"
stats_file.write_text(json.dumps(stats_payload, ensure_ascii=False, indent=2), encoding="utf-8")
if not args.no_charts:
try:
subprocess.run([sys.executable, str((Path(__file__).parent / "generate-charts.py").resolve()), str(stats_file), str(charts_dir)], check=True)
except Exception:
pass
report_title_prefix = cfg.get("report", {}).get("title_prefix", "GitLab Weekly Report")
report = {"title": f"{report_title_prefix} {start} ~ {end}", "start": start, "end": end, "users": users_out, "product_sections": product_sections, "people_sections": people_sections, "stats": stats, "top_contributors": top_contributors[:5], "top_categories": top_categories[:5], "generated_at": dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
md = render_markdown(report)
(report_dir / "weekly_report.md").write_text(md, encoding="utf-8")
(report_dir / "weekly_report.html").write_text(render_html(report, stats_payload), encoding="utf-8")
latest = output_dir / "latest"
if latest.exists() or latest.is_symlink():
latest.unlink()
latest.symlink_to(report_dir, target_is_directory=True)
generate_index_html(output_dir, report_title_prefix)
print(f"Report: {report_dir / 'weekly_report.md'}")
print(f"HTML: {report_dir / 'weekly_report.html'}")
print(f"Stats: {stats_file}")
print(f"Index: {output_dir / 'index.html'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/generate-report.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
exec python3 "$SCRIPT_DIR/generate-report.py" "$@"
FILE:scripts/lib/feishu-api.sh
#!/bin/bash
#
# 飞书 API 工具函数
#
FEISHU_API_BASE="https://open.feishu.cn/open-apis"
# 获取 tenant access token
get_tenant_token() {
local app_id=$1
local app_secret=$2
curl -s -X POST "FEISHU_API_BASE/auth/v3/tenant_access_token/internal" \
-H "Content-Type: application/json" \
-d "{\"app_id\":\"$app_id\",\"app_secret\":\"$app_secret\"}" | \
jq -r '.tenant_access_token'
}
# 从 URL 提取文档 token
extract_doc_token() {
local url=$1
# 提取 wiki/ 或 docx/ 后的 token
echo "$url" | sed -E 's/.*\/(wiki|docx)\/([^/?]+).*/\2/'
}
# 获取文档内容
get_doc_content() {
local token=$1
local doc_token=$2
curl -s "FEISHU_API_BASE/docx/v1/documents/doc_token" \
-H "Authorization: Bearer $token" | \
jq -r '.data.document.title'
}
# 更新文档(示例函数)
update_document() {
local token=$1
local doc_token=$2
local content=$3
# 实际实现需要调用飞书文档更新 API
echo "更新文档: $doc_token"
}
FILE:scripts/package.sh
#!/bin/bash
# Package the skill into a .skill file (zip with .skill extension)
set -euo pipefail
SKILL_NAME="gitlab-weekly-report"
OUTPUT_DIR="-."
cd "$(dirname "$0")/.."
mkdir -p "$OUTPUT_DIR"
PACKAGE_PATH="OUTPUT_DIR/SKILL_NAME.skill"
rm -f "$PACKAGE_PATH"
# Package only distributable files. Exclude local config, generated outputs,
# caches, and other machine-specific artifacts.
zip -r "$PACKAGE_PATH" \
SKILL.md \
scripts \
config/config.example.json \
config/classification.rules.example.json \
templates \
requirements.txt \
LICENSE \
VERSION \
.gitignore \
-x "*.DS_Store" \
-x "*/.git/*" \
-x "*/__pycache__/*" \
-x "*.pyc" \
-x "*.bak" \
-x "reports/*" \
-x "config/config.json" \
-x "config/classification.rules.json"
echo "✅ Packaged: $PACKAGE_PATH"
FILE:scripts/setup-cron.sh
#!/bin/bash
#
# 设置定时自动生成周报
#
set -e
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" \u0026\u0026 pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
CRON_COMMENT="# GitLab Weekly Report Generator"
DEFAULT_CRON="0 18 * * 5" # 每周五 18:00
show_help() {
cat \u003c\u003c EOF
设置定时自动生成周报
用法:
\$0 -c <config_file> [选项]
选项:
-c, --config FILE 配置文件路径(必需)
--cron CRON Cron 表达式(默认: \"$DEFAULT_CRON\")
--list 列出已设置的定时任务
--remove 移除定时任务
-h, --help 显示帮助
示例:
# 设置每周五 18:00 自动生成
\$0 -c config/config.json
# 设置每周一 09:00 自动生成
\$0 -c config/config.json --cron "0 9 * * 1"
# 查看已设置的任务
\$0 --list
# 移除定时任务
\$0 --remove
Cron 格式说明:
分 时 日 月 周
0 18 * * 5 # 每周五 18:00
0 9 * * 1 # 每周一 09:00
0 10 * * 0 # 每周日 10:00
EOF
}
parse_args() {
while [[ \$# -gt 0 ]]; do
case \$1 in
-c|--config) CONFIG_FILE="\$2"; shift 2 ;;
--cron) CRON_EXPR="\$2"; shift 2 ;;
--list) LIST_MODE=true; shift ;;
--remove) REMOVE_MODE=true; shift ;;
-h|--help) show_help; exit 0 ;;
*) echo "未知选项: \$1"; show_help; exit 1 ;;
esac
done
}
list_cron() {
echo "📋 当前定时任务:"
crontab -l 2>/dev/null | grep -A2 -B2 "$CRON_COMMENT" || echo " 未找到 GitLab 周报定时任务"
}
remove_cron() {
echo "🗑️ 正在移除定时任务..."
(crontab -l 2>/dev/null | grep -v "$CRON_COMMENT") | crontab -
echo "✅ 定时任务已移除"
}
setup_cron() {
local config_file="\$1"
local cron_expr="\-$DEFAULT_CRON"
[[ ! -f "\$config_file" ]] \u0026\u0026 { echo "❌ 配置文件不存在: \$config_file"; exit 1; }
# 获取绝对路径
CONFIG_ABS_PATH="$(cd "$(dirname "\$config_file")" \u0026\u0026 pwd)/$(basename "\$config_file")"
# 构建命令
CMD="\$cron_expr cd \"$SKILL_DIR\" \u0026\u0026 ./scripts/generate-report.sh -c \"\$CONFIG_ABS_PATH\" \u0026\u0026 ./scripts/upload-to-feishu.sh -d \"\$SKILL_DIR/reports/latest\" \u003e\u003e \"\$SKILL_DIR/logs/cron.log\" 2\u003e\u00261 # $CRON_COMMENT"
echo "⏰ 设置定时任务:"
echo " 时间: \$cron_expr"
echo " 命令: 生成周报并上传到飞书"
echo ""
# 移除旧的同类任务
(crontab -l 2>/dev/null | grep -v "$CRON_COMMENT") | crontab -
# 添加新任务
(crontab -l 2>/dev/null; echo "$CMD") | crontab -
echo "✅ 定时任务设置成功!"
echo ""
echo "📋 当前 crontab:"
crontab -l | grep -A1 -B1 "$CRON_COMMENT"
}
main() {
parse_args "$@"
[[ "$LIST_MODE" == true ]] \u0026\u0026 { list_cron; exit 0; }
[[ "$REMOVE_MODE" == true ]] \u0026\u0026 { remove_cron; exit 0; }
[[ -z "$CONFIG_FILE" ]] \u0026\u0026 { echo "❌ 请指定配置文件 (-c)"; show_help; exit 1; }
setup_cron "$CONFIG_FILE" "-$DEFAULT_CRON"
}
main "$@"
FILE:scripts/upload-to-feishu.js
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const require = createRequire('/opt/homebrew/lib/node_modules/openclaw/package.json');
const Lark = require('@larksuiteoapi/node-sdk');
function parseArgs(argv) {
const out = {};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
const b = argv[i + 1];
if (a === '-d' || a === '--dir') { out.reportDir = b; i++; continue; }
if (a === '-u' || a === '--url') { out.docUrl = b; i++; continue; }
if (a === '-t' || a === '--title') { out.title = b; i++; continue; }
if (a === '-h' || a === '--help') { out.help = true; }
}
return out;
}
function usage() {
console.log(`上传周报到飞书文档
用法:
node upload-to-feishu.js -d <report_dir> [-u <doc_url>] [-t <title>]
说明:
- 提供 -u: 覆盖写入已有文档
- 不提供 -u: 若 config.json 配了 feishu.parent_wiki_url,则默认在该父 wiki 下新建子文档;否则新建独立文档
环境变量:
FEISHU_APP_ID
FEISHU_APP_SECRET`);
}
function extractToken(url) {
const m = String(url).match(/\/(wiki|docx)\/([^/?#]+)/);
if (!m) throw new Error(`无法从 URL 提取 token: url`);
return { kind: m[1], token: m[2] };
}
function getFeishuOrigin(url) {
try {
const u = new URL(url);
return `u.protocol//u.host`;
} catch {
return 'https://feishu.cn';
}
}
function expandHome(p) {
if (!p) return p;
return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
}
function loadConfig() {
const configPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../config/config.json');
if (!fs.existsSync(configPath)) return {};
try {
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch {
return {};
}
}
function loadUserAccessToken(cfg) {
const envToken = process.env.FEISHU_USER_ACCESS_TOKEN;
if (envToken) return envToken;
const tokenFile = expandHome(cfg?.feishu?.user_token_file || '~/path/to/feishu_token.json');
if (!tokenFile || !fs.existsSync(tokenFile)) return '';
try {
const data = JSON.parse(fs.readFileSync(tokenFile, 'utf8'));
return data?.access_token || '';
} catch {
return '';
}
}
function normalizeMarkdownForFeishu(markdown) {
return String(markdown)
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/^\s{2,}-\s+/gm, '- ')
.replace(/^\s{4,}-\s+/gm, '- ')
.replace(/^\|(.+)\|$/gm, (_m, inner) => `- String(inner).replace(/\|/g, '|').trim()`)
.trim() + '\n';
}
function splitMarkdownByHeadings(markdown) {
const lines = markdown.split('\n');
const chunks = [];
let current = [];
let inFence = false;
for (const line of lines) {
if (/^(`{3,}|~{3,})/.test(line)) inFence = !inFence;
if (!inFence && /^#{1,2}\s/.test(line) && current.length > 0) {
chunks.push(current.join('\n'));
current = [];
}
current.push(line);
}
if (current.length) chunks.push(current.join('\n'));
return chunks.filter(Boolean);
}
function splitMarkdownForAppend(markdown, maxChars = 6000) {
const sections = [];
const lines = markdown.split('\n');
let current = [];
for (const line of lines) {
if (line.startsWith('## ') && current.length > 0) {
sections.push(current.join('\n').trim() + '\n');
current = [line];
} else {
current.push(line);
}
}
if (current.length) sections.push(current.join('\n').trim() + '\n');
const out = [];
for (const section of sections) {
if (section.length <= maxChars) {
out.push(section);
continue;
}
const subparts = splitMarkdownByHeadings(section);
let buf = '';
for (const part of subparts) {
const candidate = buf ? `buf\npart` : part;
if (candidate.length <= maxChars) {
buf = candidate;
} else {
if (buf) out.push(buf.trim() + '\n');
if (part.length <= maxChars) {
buf = part;
} else {
const paras = part.split(/\n\n+/);
let pbuf = '';
for (const para of paras) {
const cand = pbuf ? `pbuf\n\npara` : para;
if (cand.length <= maxChars) pbuf = cand;
else {
if (pbuf) out.push(pbuf.trim() + '\n');
pbuf = para;
}
}
buf = pbuf;
}
}
}
if (buf) out.push(buf.trim() + '\n');
}
return out.filter(Boolean);
}
function cleanBlocksForInsert(blocks) {
const skipped = [];
const cleaned = (blocks || []).filter(Boolean).map((block) => {
const clone = JSON.parse(JSON.stringify(block));
delete clone.block_id;
delete clone.parent_id;
delete clone.children;
delete clone.children_ids;
delete clone.revision_id;
delete clone.deleted;
delete clone.create_time;
delete clone.update_time;
return clone;
}).filter((block) => {
if (block.block_type === 31 || block.block_type === 32) {
skipped.push(`skip structured block type=block.block_type`);
return false;
}
return true;
});
return { cleaned, skipped };
}
async function clearDocumentContent(client, docToken, options = undefined) {
const existing = await client.docx.documentBlock.list({ path: { document_id: docToken } }, options);
if (existing.code !== 0) throw new Error(existing.msg);
const childIds = (existing.data?.items || [])
.filter((b) => b.parent_id === docToken && b.block_type !== 1)
.map((b) => b.block_id);
if (childIds.length > 0) {
const res = await client.docx.documentBlockChildren.batchDelete({
path: { document_id: docToken, block_id: docToken },
data: { start_index: 0, end_index: childIds.length },
}, options);
if (res.code !== 0) throw new Error(res.msg);
}
return childIds.length;
}
async function updateWikiNodeTitle(client, spaceId, nodeToken, title, options = undefined) {
if (!spaceId || !nodeToken || !title) return;
const res = await client.wiki.spaceNode.updateTitle({
path: { space_id: spaceId, node_token: nodeToken },
data: { title },
}, options);
if (res.code !== 0) throw new Error(`更新 wiki 节点标题失败: res.msg`);
}
async function convertMarkdown(client, markdown, options = undefined) {
const res = await client.docx.document.convert({
data: { content_type: 'markdown', content: markdown },
}, options);
if (res.code !== 0) throw new Error(res.msg);
return { blocks: res.data?.blocks || [], firstLevelBlockIds: res.data?.first_level_block_ids || [] };
}
function sortBlocksByFirstLevel(blocks, firstLevelIds) {
if (!firstLevelIds || !firstLevelIds.length) return blocks;
const sorted = firstLevelIds.map((id) => blocks.find((b) => b.block_id === id)).filter(Boolean);
const sortedIds = new Set(firstLevelIds);
return [...sorted, ...blocks.filter((b) => !sortedIds.has(b.block_id))];
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function insertBlocks(client, docToken, blocks, options = undefined) {
const { cleaned, skipped } = cleanBlocksForInsert(blocks);
const allInserted = [];
for (const block of cleaned) {
let lastErr;
for (let attempt = 1; attempt <= 6; attempt++) {
try {
const res = await client.docx.documentBlockChildren.create({
path: { document_id: docToken, block_id: docToken },
data: { children: [block] },
}, options);
if (res.code !== 0) throw new Error(res.msg);
allInserted.push(...(res.data?.children || []));
await sleep(180);
lastErr = undefined;
break;
} catch (err) {
lastErr = err;
const msg = String(err?.message || err || '');
if (!msg.includes('429') || attempt === 6) {
throw err;
}
await sleep(500 * attempt);
}
}
if (lastErr) throw lastErr;
}
return { inserted: allInserted.length, skipped };
}
async function resolveDocToken(client, url) {
const { kind, token } = extractToken(url);
if (kind === 'docx') return token;
const res = await client.wiki.space.getNode({ params: { token, obj_type: 'wiki' } });
if (res.code !== 0) throw new Error(`解析 wiki 节点失败: res.msg`);
const objToken = res.data?.node?.obj_token;
if (!objToken) throw new Error('wiki 节点没有返回 obj_token');
return objToken;
}
async function createDoc(client, title, options = undefined) {
const res = await client.docx.document.create({ data: { title } }, options);
if (res.code !== 0) throw new Error(`创建文档失败: res.msg`);
const docToken = res.data?.document?.document_id;
if (!docToken) throw new Error('创建文档成功但没有返回 document_id');
return { docToken, docUrl: `https://feishu.cn/docx/docToken` };
}
async function createDocUnderWiki(client, parentWikiUrl, title, options) {
const { token } = extractToken(parentWikiUrl);
const nodeRes = await client.wiki.space.getNode({ params: { token, obj_type: 'wiki' } }, options);
if (nodeRes.code !== 0) throw new Error(`解析父 wiki 节点失败: nodeRes.msg`);
const spaceId = nodeRes.data?.node?.space_id;
const parentNodeToken = nodeRes.data?.node?.node_token;
if (!spaceId || !parentNodeToken) throw new Error('父 wiki 节点缺少 space_id 或 node_token');
const createRes = await client.wiki.spaceNode.create({
path: { space_id: spaceId },
data: { obj_type: 'docx', parent_node_token: parentNodeToken, node_type: 'origin', title },
}, options);
if (createRes.code !== 0) throw new Error(`在 wiki 下创建子文档失败: createRes.msg`);
const docToken = createRes.data?.node?.obj_token;
const nodeToken = createRes.data?.node?.node_token;
if (!docToken) throw new Error('wiki 子文档创建成功但没有返回 obj_token');
const origin = getFeishuOrigin(parentWikiUrl);
return {
docToken,
nodeToken,
docUrl: `origin/docx/docToken`,
wikiNodeUrl: `origin/wiki/nodeToken`,
};
}
async function maybeUpdateTitle(client, docToken, title) {
if (!title) return;
if (typeof client.docx?.document?.patch !== 'function') return;
const res = await client.docx.document.patch({ path: { document_id: docToken }, data: { title } });
if (res.code !== 0) console.warn(`⚠️ 更新标题失败: res.msg`);
}
async function appendMarkdownChunk(client, docToken, markdown, options = undefined) {
const converted = await convertMarkdown(client, markdown, options);
const sorted = sortBlocksByFirstLevel(converted.blocks, converted.firstLevelBlockIds);
return insertBlocks(client, docToken, sorted, options);
}
async function main() {
const args = parseArgs(process.argv);
if (args.help || !args.reportDir) {
usage();
process.exit(args.help ? 0 : 1);
}
const appId = process.env.FEISHU_APP_ID;
const appSecret = process.env.FEISHU_APP_SECRET;
if (!appId || !appSecret) throw new Error('缺少 FEISHU_APP_ID / FEISHU_APP_SECRET');
const mdFile = path.join(path.resolve(args.reportDir), 'weekly_report.md');
if (!fs.existsSync(mdFile)) throw new Error(`周报文件不存在: mdFile`);
const markdown = normalizeMarkdownForFeishu(fs.readFileSync(mdFile, 'utf8'));
const client = new Lark.Client({
appId,
appSecret,
appType: Lark.AppType.SelfBuild,
domain: Lark.Domain.Feishu,
});
const cfg = loadConfig();
const configTitlePrefix = cfg?.report?.title_prefix || 'GitLab Weekly Report';
const desiredTitle = args.title || `configTitlePrefix path.basename(path.resolve(args.reportDir)).replace('_to_', ' ~ ')`;
const userAccessToken = loadUserAccessToken(cfg);
const userOpts = userAccessToken ? Lark.withUserAccessToken(userAccessToken) : undefined;
let docUrl = args.docUrl || cfg?.feishu?.doc_url || '';
const parentWikiUrl = cfg?.feishu?.parent_wiki_url || '';
let docToken;
let created = false;
let createdUnderWiki = false;
let wikiNodeUrl = '';
let wikiSpaceId = '';
let wikiNodeToken = '';
if (docUrl) {
docToken = await resolveDocToken(client, docUrl);
} else if (parentWikiUrl && userOpts) {
const createdDoc = await createDocUnderWiki(client, parentWikiUrl, desiredTitle, userOpts);
docToken = createdDoc.docToken;
docUrl = createdDoc.docUrl;
wikiNodeUrl = createdDoc.wikiNodeUrl;
wikiNodeToken = createdDoc.nodeToken;
created = true;
createdUnderWiki = true;
const parentNode = await client.wiki.space.getNode({ params: { token: extractToken(parentWikiUrl).token, obj_type: 'wiki' } }, userOpts);
wikiSpaceId = parentNode.data?.node?.space_id || '';
} else {
const createdDoc = await createDoc(client, desiredTitle, userOpts);
docToken = createdDoc.docToken;
docUrl = createdDoc.docUrl;
created = true;
}
const editOpts = userOpts;
const deleted = await clearDocumentContent(client, docToken, editOpts);
const chunks = splitMarkdownForAppend(markdown, 6000);
let inserted = 0;
const skipped = [];
for (let i = 0; i < chunks.length; i++) {
const res = await appendMarkdownChunk(client, docToken, chunks[i], editOpts);
inserted += res.inserted;
skipped.push(...res.skipped);
await sleep(1200);
}
if (createdUnderWiki) {
await updateWikiNodeTitle(client, wikiSpaceId, wikiNodeToken, desiredTitle, userOpts);
} else {
await maybeUpdateTitle(client, docToken, desiredTitle);
}
const result = {
success: true,
created,
created_under_wiki: createdUnderWiki,
parent_wiki_url: parentWikiUrl || undefined,
wiki_node_url: wikiNodeUrl || undefined,
doc_url: docUrl,
doc_token: docToken,
markdown_file: mdFile,
blocks_deleted: deleted,
blocks_inserted: inserted,
skipped,
};
console.log(JSON.stringify(result, null, 2));
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});
FILE:scripts/upload-to-feishu.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
if [[ -f "$SCRIPT_DIR/../config/config.json" ]]; then
export FEISHU_APP_ID="-$(jq -r '.feishu.app_id // empty' "$SCRIPT_DIR/../config/config.json")"
export FEISHU_APP_SECRET="-$(jq -r '.feishu.app_secret // empty' "$SCRIPT_DIR/../config/config.json")"
fi
exec node "$SCRIPT_DIR/upload-to-feishu.js" "$@"
FILE:templates/report.template.md
# {{TITLE}}
时间范围:`{{START_DATE}}` 至 `{{END_DATE}}`
本文汇总以下同学本周在 GitLab 内的工作:
{{USER_LIST}}
---
{{USER_DETAILS}}
## 总结
本周 ({{START_DATE}} 至 {{END_DATE}}) 工作汇总:
### 统计概览
{{STATS_TABLE}}
### 图表
{{CHARTS}}
---
*报告生成时间: {{GENERATED_AT}}*