issue/PR 分配变动 → 飞书群¶
把 GitHub/GitCode 中“分配给团队成员的 issue/PR 变动”自动推到飞书群(当前为 预报系统)。
能力边界¶
| 仓库 | 负责 |
|---|---|
git-contribution-stats |
采集、当前状态、issue_events、MCP get_issue_events(数据/事件能力) |
myopenclaw |
人员映射、飞书卡片、群投递、游标/去重/重试(渠道能力) |
GitHub/GitCode ─→ issue_watcher(stats,10 min)
│ issue_events + immutable snapshot
▼
MCP get_issue_events(repo-scanner-mcp:8001)
│ 60s 游标轮询
▼
issue-notifier(myopenclaw)
│ hermes-coder 应用(CODER_FEISHU_*)
▼
预报系统 飞书群
事件与卡片¶
- 事件:
assigned/unassigned/review_requested/review_request_removed/opened/closed/reopened/merged。 - 卡片:平台 + 仓库 +
#编号+ 事件类型 + 标题 + 人员 + 「打开」按钮。 - 分配事件按人拆;状态事件使用事件时的 assignee 快照。
- 通知路径不经过 LLM,模板直出。
人员映射¶
真实映射在宿主机:
platform=github比github字段;platform=gitcode比gitcode字段。- 来源:
~/.myagentdata/repo-scanner/repos.sqlite的author_names名册(排除离职/机器人), GitHub 别名按同名/同登录查询补充;新成员直接加一段。 - 仓库内只提交
config/issue-notifier.example.toml(占位符),真实名册不入公开仓库。 - 可选
allowed_repos = ["platform/owner/repo", ...]控制哪些仓库允许进群(空 = 全部; 默认全部)。这是群通知的粗粒度白名单,不替代仓库 ACL。
环境变量¶
# .env
ISSUE_NOTIFIER_FEISHU_MODE=app # app | webhook
ISSUE_NOTIFIER_FEISHU_CHAT_ID=oc_xxx # 预报系统群;app 模式必填
# ISSUE_NOTIFIER_FEISHU_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/xxx
CODER_FEISHU_APP_ID=cli_xxx # 爱码士应用(已在群内)
CODER_FEISHU_APP_SECRET=xxx
ISSUE_NOTIFIER_POLL_SECONDS=60
ISSUE_WATCHER_POLL_SECONDS=600
REPO_SCANNER_WATCH_OWNS_ISSUES=1
# MCP 认证(scripts/ensure_issue_watcher_auth.py 幂等生成/合并;勿手抄)
HERMES_REPO_SCANNER_KEY=xxx # Hermes 默认 profile 使用
TIANYI_BOT_REPO_SCANNER_KEY=xxx # 天一使用;.env.tianyi-bot 必须同值
ISSUE_NOTIFIER_MCP_TOKEN=xxx # notifier 轮询使用
GIT_STATS_API_KEYS={"<key>":"hermes",...} # 服务端 key→用户名映射
app 模式只注入 hermes-coder 应用(CODER_FEISHU_*);compose 刻意不转发
主飞书应用密钥(notifier 只需要对一个群发消息;专用 bot 可进一步收窄权限)。
webhook 模式只填 ISSUE_NOTIFIER_FEISHU_WEBHOOK,消息显示为“自定义机器人”;
只支持未签名 webhook;签名 webhook(timestamp/sign)需要额外实现——当前会重试
最多 24 小时后移入 dead,可用 --requeue-dead 恢复。
feishu.py 同时接受 {"code":0} 与自定义机器人历史返回的 {"StatusCode":0}。
数据边界:卡片包含私有仓库的 owner/repo、issue 标题、链接和 assignee。 群成员应与仓库 ACL 对齐;
state.sqlite的 outbox 也会随 data 备份上云。 若某些私有仓库不应进群,应在 stats 侧configs/repos.toml移除或后续加仓库白名单。
运行与状态¶
首次部署请用 ./scripts/start.sh:它会创建 ~/.myagentdata/issue-notifier/ 并把
config/issue-notifier.example.toml 播种为 config.toml(否则 notifier 会以
"no people configured" 退出 2,重启循环)。编辑 config.toml 后需要
docker compose restart issue-notifier(配置只在启动时读取)。
# 启动/重启(会同时重建 repo-scanner 合并服务)
./scripts/start.sh
docker compose up -d repo-scanner-mcp issue-notifier
# 日志
docker compose logs -f issue-notifier
docker compose logs -f repo-scanner-mcp
# 手动跑一次(基线/排障;daemon 运行时避免并发写 state,先停或优先 --dry-run)
docker compose exec issue-notifier python /app/main.py --once
docker compose exec issue-notifier python /app/main.py --dry-run
docker compose exec issue-notifier python /app/main.py --requeue-dead # 恢复 dead 卡片
docker compose exec issue-notifier python /app/main.py --purge-dead # 清理 7 天前的 dead
# 状态
docker compose exec issue-notifier cat /data/status.json
status.json 字段:last_poll_epoch / last_success_epoch / events_seen /
events_queued / events_filtered_repo / events_unmatched / events_sent /
send_failed / cursor / pending / oldest_pending_age / dead /
placeholder_config / poll_seconds / last_send_error / watcher / last_error。
容器 healthcheck 要求 last_success_epoch 在 max(300, 3×ISSUE_NOTIFIER_POLL_SECONDS)
内、send_failed 为 0;dead 不再让容器永久 unhealthy(由 AgentOps 单独告警),
死信超过 7 天由 daemon 自动清理。
watcher.error_repos 只记录/告警,不判 unhealthy(私有仓库 401/404 是预期噪音)。
--dry-run 在首次运行(无 cursor)时只建基线、不打印事件;要看事件先跑一次
--once 建立 cursor 后再 --dry-run。
游标、去重与重试¶
- 本地
~/.myagentdata/issue-notifier/state.sqlite(rollback journal,随 data 备份)。 kv.cursor:最后扫描的event_id;首次运行跳到latest_event_id(不补发历史)。latest_event_id缺失时直接报错(不静默从 0 开始灌历史)。- cursor > latest_event_id(stats DB 被恢复/重建)→ 记 ERROR 并重置到 latest, 避免永远空轮询、通知静默停摆。
sent(event_key)+outbox(event_id)+dead(event_id):同一事件不会重复发送; 发送失败留在 outbox,下一轮(60s)重试;持续失败 24 小时才移入dead(短时故障/机器人被移出群再拉回不会永久丢通知)。- 恢复:
docker compose exec issue-notifier python /app/main.py --requeue-dead把 dead 卡片移回 outbox(attempts 归零);dead 保留 7 天,daemon 每天自动 清理更早的卡片,也可用--purge-dead手动清理(card_json 含私有标题; state 随 data 备份,stats DB 本身也含这些标题,属既定备份边界)。 - 每次 poll 的发送有 20s 预算,Feishu 黑洞不会把轮询拖死;单张失败不阻塞同批其他卡片。
pending/dead计数来自COUNT(*)。- Feishu/MCP 异常文本在落日志/
last_error前会脱敏(access_token=、Bearer、 webhook 路径、oc_/ou_id);Feishu HTTP body 不再回显。 - MCP 调用有 30s 超时;服务重启/网络抖动不会把 notifier 卡死。
httpx/mcp客户端日志级别降到 WARNING(避免每 60s 6 行噪音)。
MCP 认证:
repo-scanner-mcp启用 per-user Bearer(GIT_STATS_API_KEYS,key → 用户名)。三个消费者各持一把 key:Hermes 默认 profile 的mcp_servers.repo-scanner.headers、天一模板的headers、notifier 的ISSUE_NOTIFIER_MCP_TOKEN。scripts/ensure_issue_watcher_auth.py幂等生成/合并 根.env里的三把 key,scripts/ensure_hermes_repo_scanner_auth.py幂等补 Hermes header;./scripts/start.sh启动前自动运行两者。/health不鉴权 (Docker/Uptime Kuma 探针),其余路径无 key 一律 401。轮换:删除/修改根
.env里的 key 后重跑./scripts/start.sh。helper 会 删除 key map 里同角色的旧 key(旧 key 立即失效),并把新值同步到.env.tianyi-bot;start.sh 会重建 repo-scanner/notifier/Hermes 容器,检测到 天一 key 变化且openclaw-tianyi在运行时也会重建天一端(容器 env 在创建时 冻结,只改文件不重建会继续用旧 key,表现为工具调用 401)。残余风险:MCP 与 notifier 都在
myopenclaw-net上走明文 HTTP,token 与事件 数据可被同网容器嗅探。当前接受(团队只读数据 + 内网);要消除需给 notifier 单独网络或让 MCP 走 TLS 终止。
故障排查¶
| 症状 | 检查 |
|---|---|
internal_error / 无事件 |
repo-scanner-mcp 是否 healthy;/data/repos.readonly.sqlite 是否存在(快照) |
| 401/403 | 群/应用权限;CODER_FEISHU_* 是否在群内;token 是否过期 |
| 卡片没到群 | ISSUE_NOTIFIER_FEISHU_CHAT_ID 是否正确;status.send_failed / dead;notifier 日志 |
| 一直不触发 | config.toml 映射是否匹配平台用户名;事件是否属于该仓库/人;cursor > latest_event_id 是否被重置 |
| 事件为空 | stats 侧 baseline 是否完成;repo_watch_state.baseline_done=1;watcher 是否 401/403/404(last_error) |
| 私有仓库标题出现在群里 | 检查群成员是否与仓库 ACL 一致;必要时从 configs/repos.toml 移除该仓库 |
过渡与退役¶
watcher 接管 issue/PR 当前状态后,宿主的每日 git-contribution-stats launchd
任务应设置 REPO_SCANNER_WATCH_OWNS_ISSUES=1(只采 commits),避免两个写者。
合并服务稳定一周后,可卸载宿主 launchd(合并服务已在每日 07:45 跑全量采集)。