Merge PR #346: # feat(feishu): 新增分层飞书协作导出能力

# Conflicts:
#	.github/workflows/test.yml
#	.gitignore
#	README.zh-CN.md
#	docs/pr-draft.md
#	shortcuts/register.go
#	shortcuts/register_test.go
#	shortcuts/workflow/api_types.go
#	shortcuts/workflow/api_types_test.go
#	shortcuts/workflow/health_fetch.go
#	shortcuts/workflow/repo_report.go
#	shortcuts/workflow/repo_report_fetch.go
#	shortcuts/workflow/triage_fetch.go
#	shortcuts/workflow/triage_fetch_test.go
This commit is contained in:
wbtiger 2026-07-14 22:50:19 +08:00
commit 63f9f196dd
67 changed files with 14860 additions and 359 deletions

View File

@ -26,8 +26,14 @@ jobs:
- name: Scan i18n key references
run: go run ./internal/i18n/cmd/check --scan-code
- name: Validate skill metadata
run: go run ./internal/skillmeta/cmd/check
- name: Test Feishu shortcuts
run: go test ./shortcuts/feishu
- name: Test workflow shortcuts
run: go test ./shortcuts/workflow
- name: Run Go tests
run: go test ./...
- name: Vet
run: go vet ./...

16
.gitignore vendored
View File

@ -1,7 +1,9 @@
docs/
doc/
# Python & demo artifacts
__pycache__/
*.pyc
demo/bin/
data/
gitlink-cli.exe
/gitlink-cli
.local/*
!.local/
!.local/feishu-gitlink.env.example.ps1
*.local.ps1
*.secret.*
reports/feishu-real-smoke-terminal.log

View File

@ -0,0 +1,31 @@
# Stable Feishu webhook
$env:FEISHU_WEBHOOK_URL=""
$env:FEISHU_WEBHOOK_SECRET=""
# Feishu Open Platform
$env:FEISHU_APP_ID=""
$env:FEISHU_APP_SECRET=""
# Feishu DocX / Wiki
$env:FEISHU_WIKI_URL=""
$env:FEISHU_WIKI_NODE_TOKEN=""
$env:FEISHU_FOLDER_TOKEN=""
$env:FEISHU_DOCUMENT_ID=""
# Feishu Base / Bitable
$env:FEISHU_BASE_APP_TOKEN=""
$env:FEISHU_REPORT_TABLE_ID=""
$env:FEISHU_ISSUE_TABLE_ID=""
$env:FEISHU_PR_TABLE_ID=""
$env:FEISHU_CONTRIBUTOR_TABLE_ID=""
$env:FEISHU_TASK_TABLE_ID=""
# Feishu Task
$env:FEISHU_TASK_PROJECT_ID=""
$env:FEISHU_TASK_SECTION_ID=""
# GitLink real test input
$env:GITLINK_OWNER=""
$env:GITLINK_REPO=""
$env:GITLINK_TEST_PR_IDS=""
$env:GITLINK_TOKEN=""

View File

@ -578,6 +578,14 @@ gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.j
# Repository workflow report by read-only GitLink fetch
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
# Optional full PR review attribution. This deep-fetches formal reviews and
# PR-associated Issue journals for analyzed PRs, so keep it explicit.
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --include-pr-review-audit --format json > report.review-audit.json
# Limit analysis only when an intentional sample is needed.
# By default, repo-report paginates through all open issues and pull requests.
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --issue-limit 20 --pr-limit 50 --format markdown
# Repository workflow report from a local JSON file
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
```
@ -595,6 +603,60 @@ Safety:
- They do not depend on LLM APIs.
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
- `workflow +repo-report --include-pr-review-audit` remains read-only. It treats formal review objects as authoritative review evidence and keeps submitter, reviewer, participant, and system journal activity separate.
### Feishu Collaboration Export
`feishu` turns `workflow +repo-report` JSON into Feishu collaboration outputs.
`workflow +repo-report` paginates through all open issues and pull requests by
default. Feishu cards label these values as analyzed counts. Passing
`--issue-limit` or `--pr-limit` intentionally limits the analysis and the
resulting values must not be interpreted as repository totals.
Stable usage:
```bash
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --format json > report.json
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --include-pr-review-audit --format json > report.review-audit.json
gitlink-cli feishu +notify --from-workflow-json report.json --format json
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
gitlink-cli feishu +owner-digest --from-workflow-json report.review-audit.json --format table
gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown
```
Experimental Open Platform usage:
```bash
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "$FEISHU_WIKI_URL" --send --format table
gitlink-cli feishu +bitable-sync --from-workflow-json report.json --tables reports,issues,prs,tasks --send --format table
gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table
```
GitLink write operations are not implemented in this branch. Feishu card buttons are navigation-only. Open Platform commands require explicit `--send` and a self-built app with resource permissions. Whether these experimental capabilities should be enabled in official deployments is left to GitLink maintainers and deployment administrators.
Details:
- [Feishu integration](./docs/feishu-integration.md)
- [Feishu capability layers](./docs/FEISHU_CAPABILITY_LAYERS.md)
- [Feishu environment variables](./docs/FEISHU_ENVIRONMENT.md)
- [Feishu permission matrix](./reports/FEISHU_PERMISSION_MATRIX.md)
Local setup and smoke testing:
```powershell
.\scripts\feishu-gitlink-setup.ps1
.\scripts\feishu-gitlink-env-check.ps1 -Layer stable
.\scripts\feishu-gitlink-smoke.ps1 -Mode preview
```
The setup script stores real values only in `.local/feishu-gitlink.env.ps1`, which is ignored.
### Wiki

View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/README.md)。
**[English](./README.md)**
@ -78,11 +78,19 @@
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/luwanzhou" title="luwanzhou"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/165_135_246/120.png" width="40" height="40" alt="luwanzhou" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/luwanzhou">luwanzhou</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/whale_hihihi" title="whale_hihihi"><img src="https://www.gitlink.org.cn/images/avatars/User/137722?t=1778575729" width="40" height="40" alt="whale_hihihi" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/whale_hihihi">whale_hihihi</a></sub>
</div>
</div>
## 为什么选择 gitlink-cli
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/README.md),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
@ -96,8 +104,7 @@
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息、洞察数据和互动状态 |
| ⭐ 互动 | 关注、取消关注、点赞、取消点赞,查看关注者和点赞者 |
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue |
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
@ -106,9 +113,11 @@
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
| 📖 Wiki | 列出、查看、创建、更新、删除、导出 Wiki 页面 |
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔍 搜索 | 搜索仓库、用户 |
| 📊 数据集 | 按项目查询科研数据集 |
| 👤 用户 | 查看用户资料和信息 |
| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
@ -259,24 +268,6 @@ gitlink-cli repo +create -n my-project -d "项目描述"
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### 仓库互动
```bash
# 列出仓库关注者
gitlink-cli reaction +watchers --owner Gitlink --repo forgeplus
# 列出仓库点赞者
gitlink-cli reaction +stargazers --owner Gitlink --repo forgeplus
# 关注或取消关注仓库
gitlink-cli reaction +follow --owner Gitlink --repo forgeplus
gitlink-cli reaction +unfollow --owner Gitlink --repo forgeplus
# 点赞或取消点赞仓库
gitlink-cli reaction +like --owner Gitlink --repo forgeplus
gitlink-cli reaction +unlike --owner Gitlink --repo forgeplus
```
### Webhook 管理
```bash
@ -294,6 +285,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Wiki 管理
```bash
# 列出 Wiki 页面(目录结构)
gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
# 查看 Wiki 页面
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
# 创建 Wiki 页面
gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
-n getting-started -t "快速开始" -c "# 快速开始指南"
# 更新 Wiki 页面标题和/或内容
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" -c "新内容"
# 删除 Wiki 页面
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
```
### 成员管理
```bash
@ -343,6 +356,14 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
# 从 CSV 文件批量关闭 Issue
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
# 按 API issue id 预览批量更新元数据
# 注意:--ids 是 API issue id不是网页 URL 中的 Issue 编号。
gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run
# 危险批量删除必须先 dry-run真实执行还要显式 --yes
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes
# 添加评论
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
@ -465,6 +486,16 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
### 忽略文件模板
```bash
# 列出所有可用的 .gitignore 模板
gitlink-cli ignore +list
# 按名称筛选模板
gitlink-cli ignore +list --name Go
```
### 搜索
```bash
@ -475,31 +506,143 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### Wiki 管理
### 用户画像
`wiki` 管理仓库的 Wiki 页面。GitLink 项目 ID 会自动从 `--owner/--repo` 解析,也可用 `--project-id` 指定。
`profile` 暴露 GitLink 原生的用户画像统计(开发能力、角色定位、专业定位、近期活动、贡献热力图)。
省略 `--user` 时默认使用当前认证用户。
```bash
# 列出并查看 Wiki 页面
gitlink-cli wiki +list --owner Gitlink --repo gitlink-cli
gitlink-cli wiki +view --owner Gitlink --repo gitlink-cli --page Home
# 开发能力评分 + 语言分布
gitlink-cli profile +ability --user zhangsan
# 创建页面(内容自动 base64 编码)
gitlink-cli wiki +create --owner Gitlink --repo gitlink-cli --page Home --title Home --content "# 欢迎"
# 角色定位 / 专业(学科)定位
gitlink-cli profile +role --user zhangsan
gitlink-cli profile +major --user zhangsan
# 从文件创建
gitlink-cli wiki +create --owner Gitlink --repo gitlink-cli --page Guide --content-file guide.md
# 指定时间范围的开发能力Unix 时间戳)
gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600
# 更新(内容可选),并用 --dry-run 预览
gitlink-cli wiki +update --owner Gitlink --repo gitlink-cli --page Home --title "首页" --dry-run
# 当前用户的近期活动(每日 疑修 / 合并请求 / 提交)
gitlink-cli profile +activity
# 删除页面
gitlink-cli wiki +delete --owner Gitlink --repo gitlink-cli --page Home
# 导出 Wikimarkdown、pdf 或 html
gitlink-cli wiki +export --owner Gitlink --repo gitlink-cli --type markdown
# 指定年份的贡献热力图
gitlink-cli profile +contribution --user zhangsan --year 2025
```
### 数据集
`dataset` 管理并查询 GitLink 科研数据集(标题、描述、论文内容、许可证、所属项目)。
```bash
# 按数字项目 ID 列出一个或多个项目的数据集
gitlink-cli dataset +list --ids 5988
# 查看仓库的数据集及其附件
gitlink-cli dataset +view --owner Gitlink --repo forgeplus
# 创建 / 更新仓库数据集(先用 --dry-run 预览)
gitlink-cli dataset +create --owner me --repo proj -t "我的数据集" -d "..." --license-id 359 --dry-run
gitlink-cli dataset +update --owner me --repo proj -t "我的数据集" -d "更新"
# 删除数据集附件(破坏性:先预览,再用 --yes 确认)
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --dry-run
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --yes
```
> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404待平台上线后即可生效。
### 飞书协作导出
`feishu``workflow +repo-report` JSON 转成飞书协作内容。
`workflow +repo-report` 默认分页读取并分析全部开放 Issue 和 PR。飞书卡片会把这些值明确标为“已分析数量”。只有显式传 `--issue-limit``--pr-limit` 时才会采样,此时结果不能解释为仓库总量。
#### 稳定层:自定义机器人通知
稳定层只依赖飞书群自定义机器人。它适合把 GitLink 项目状态、周报、Owner 摘要和贡献者摘要推送到群里。默认只预览,真实发送必须显式传 `--send`
```bash
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --format json > report.json
# 仅在明确需要采样时设置上限
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --issue-limit 20 --pr-limit 50 --format json > report.sample.json
gitlink-cli feishu +notify --from-workflow-json report.json --format json
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown
```
中文输出建议同时给 workflow 和 feishu 命令传 `--lang zh-CN`
```bash
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --lang zh-CN --format json > report.zh-CN.json
gitlink-cli feishu +notify --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table
gitlink-cli feishu +owner-digest --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table
gitlink-cli feishu +contributor-digest --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table
```
#### 配置诊断层:先检查再写入
诊断命令用于降低飞书开放平台配置成本。默认只检查本地变量和目标配置;传 `--remote` 后会调用飞书只读/检查接口,例如获取 `tenant_access_token`、解析 Wiki node、搜索 Bitable sentinel `unique_key`。这些命令不会创建文档、不会写入多维表格、不会创建任务,也不会修改 GitLink。
```bash
gitlink-cli feishu +app-check --format table
gitlink-cli feishu +doc-check --remote --format table
gitlink-cli feishu +bitable-check --tables reports,issues,prs,tasks --remote --format table
gitlink-cli feishu +task-check --remote --format table
```
#### 实验层:飞书开放平台写入
实验层使用飞书开放平台自建应用。当前已在测试企业中验证 DocX 追加、多维表格写入和飞书任务创建,但这部分不是零配置稳定能力。真实写入仍然必须显式传 `--send`,并且要求自建应用有对应 API scope 和目标资源权限。
```bash
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "$FEISHU_WIKI_URL" --send --format table
gitlink-cli feishu +bitable-sync --from-workflow-json report.json --tables reports,issues,prs,tasks --send --format table
gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table
```
为了完成端到端验证,测试企业里的自建应用授予了较宽的权限。正式部署时不建议照搬测试权限,应由维护者或管理员按命令实际需要开最小权限。
多维表格已完成两类真实验证:
- 单表多视图验证:把 `reports/issues/prs/contributors/tasks` 写入同一张测试表,证明字段和写入链路可用。
- 独立表验证:拆成 `gitlink_reports`、`gitlink_issues`、`gitlink_prs`、`gitlink_contributors`、`gitlink_tasks` 五张表,分别写入 `1/5/2/1/7` 条记录,证明每类记录都能写入独立表。
#### 当前边界
本分支不实现 GitLink 写操作。飞书卡片按钮仅用于跳转。开放平台能力必须显式传 `--send`,并要求自建应用具备对应资源权限。是否在正式部署中启用这些实验能力,由 GitLink 维护者和部署管理员决定。
下一阶段再考虑:
- 飞书任务项目/分组归属。
- 飞书任务执行者/关注人。
- 飞书侧任务去重或搜索。
- 多维表格自动建 Base、建表、建字段、建视图。
- 飞书卡片回调和 GitLink 低风险写动作。
详细文档:
- [飞书集成](./docs/feishu-integration.md)
- [飞书能力分层](./docs/FEISHU_CAPABILITY_LAYERS.md)
- [飞书环境变量](./docs/FEISHU_ENVIRONMENT.md)
- [飞书权限矩阵](./reports/FEISHU_PERMISSION_MATRIX.md)
本地配置和 smoke 测试:
```powershell
.\scripts\feishu-gitlink-setup.ps1
.\scripts\feishu-gitlink-env-check.ps1 -Layer stable
.\scripts\feishu-gitlink-smoke.ps1 -Mode preview
```
真实值只会写入被忽略的 `.local/feishu-gitlink.env.ps1`,不要提交。
### Raw API
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:
@ -556,14 +699,13 @@ git push gitlink
`skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
详见 [skills/README.md](skills/README.md)
详见 [skills/README.md](./skills/README.md)
| Skill | 说明 |
|-------|------|
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
| `gitlink-repo` | 仓库操作创建、查看、删除、Fork、洞察数据等 |
| `gitlink-reaction` | 仓库互动(关注、点赞、关注者、点赞者) |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、批量更新/删除、评论等) |
| `gitlink-pr` | Pull Request 操作创建、合并、Review 等) |
| `gitlink-member` | 仓库成员与邀请链接管理 |
| `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) |
@ -625,7 +767,7 @@ gitlink-cli/
## 文档
- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明
- [Skills 使用指南](./skills/README.md) — AI Agent Skills 详细说明
- [设计文档](doc/design.md) — 架构设计和开发计划
## 常见问题
@ -688,7 +830,7 @@ gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Creden
### Q: 如何查看完整的 API 参考?
查看 [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md)
查看 [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md)
## 许可证

View File

@ -0,0 +1,302 @@
# Feishu Capability Layers
Date: 2026-06-26
This document defines the implemented and planned Feishu integration layers for `gitlink-cli`.
## Layer 1: Stable Webhook Export
Status: stable surface.
Purpose:
```text
Export GitLink workflow report summaries to Feishu without modifying GitLink or Feishu resources.
```
Required Feishu permission:
```text
Feishu custom bot webhook in a target chat.
```
Required environment variables:
```text
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET optional
```
Implemented commands:
```text
gitlink-cli feishu +bot-test
gitlink-cli feishu +notify
gitlink-cli feishu +weekly-report
gitlink-cli feishu +owner-digest
gitlink-cli feishu +contributor-digest
gitlink-cli feishu +bitable-schema
gitlink-cli feishu +bitable-records
gitlink-cli feishu +task-preview
```
What it can do:
```text
Send Feishu custom bot cards when --send is explicit.
Render weekly reports as markdown.
Generate owner-oriented digests.
Generate contributor-oriented digests.
Generate Bitable-ready local records.
Generate task candidates locally.
Add navigation-only buttons to GitLink or Feishu URLs.
```
What it cannot do:
```text
Write Feishu Docs.
Write Feishu Wiki.
Write Feishu Base / Bitable.
Create Feishu Tasks.
Receive card callbacks.
Modify GitLink issues.
Review GitLink pull requests.
Merge pull requests.
Close issues.
Modify members.
Modify GitLink webhooks.
```
Testing:
```text
Unit and mock tests are implemented.
Real custom bot sending can be tested when FEISHU_WEBHOOK_URL exists.
```
## Layer 1.5: Configuration Diagnostics
Status: stable read/check surface.
Purpose:
```text
Help users discover missing env vars, invalid tokens, missing table IDs, missing
unique_key fields, and next-stage Task limitations before running --send writes.
```
Implemented commands:
```text
gitlink-cli feishu +app-check
gitlink-cli feishu +doc-check
gitlink-cli feishu +bitable-check
gitlink-cli feishu +task-check
```
Default behavior:
```text
Local checks only. No Feishu OpenAPI call is made unless --remote is explicit.
```
Remote behavior:
```text
--remote acquires tenant_access_token.
+doc-check --remote may resolve a Wiki node.
+bitable-check --remote searches a sentinel unique_key to verify table access
and the unique_key field.
+task-check --remote verifies app credentials only; it does not create tasks.
```
What it cannot do:
```text
Create Feishu resources.
Modify Feishu resources.
Modify GitLink resources.
Guarantee DocX edit permission without an actual append.
Guarantee Feishu Task project/section placement.
```
## Layer 2: Experimental Open Platform Validation
Status: experimental validation surface.
Purpose:
```text
Validate Feishu self-built app integration for Docs, Wiki, Base, and Tasks.
```
Required Feishu permission:
```text
Self-built app with approved scopes and resource-level access.
```
Test-enterprise note:
```text
The local validation enterprise used a self-built app with broad permissions so
that DocX, Base, and Task APIs could be tested end to end. This is only a
validation setup. Production deployments should use least-privilege scopes and
resource-level access selected by maintainers or administrators.
```
Required environment variables:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
FEISHU_WIKI_URL or FEISHU_WIKI_NODE_TOKEN optional for doc-export
FEISHU_FOLDER_TOKEN optional for doc-export
FEISHU_BASE_APP_TOKEN for bitable-sync
FEISHU_REPORT_TABLE_ID for reports table
FEISHU_ISSUE_TABLE_ID for issues table
FEISHU_PR_TABLE_ID for pull request table
FEISHU_CONTRIBUTOR_TABLE_ID optional
FEISHU_TASK_TABLE_ID optional
FEISHU_TASK_PROJECT_ID optional
FEISHU_TASK_SECTION_ID optional
```
Implemented commands:
```text
gitlink-cli feishu +app-check
gitlink-cli feishu +doc-check
gitlink-cli feishu +bitable-check
gitlink-cli feishu +task-check
gitlink-cli feishu +doc-export
gitlink-cli feishu +bitable-sync
gitlink-cli feishu +task-create
```
What it can do:
```text
Acquire tenant_access_token.
Resolve Wiki node tokens.
Attempt DocX / Wiki append or document creation with --send.
Preview or write Bitable records with --send.
Search Bitable records by unique_key before update.
Fall back to create-only if Bitable search fails.
Preview or create Feishu Tasks with --send.
Print diagnostic errors for permission, scope, ID, and resource-access failures.
```
What it cannot do:
```text
Create Base apps, tables, fields, or views.
Modify Feishu document permissions.
Guarantee Task deduplication against existing Feishu tasks.
Place created Feishu tasks into a specific Task project or section.
Assign task executors or followers.
Guarantee Bitable upsert if unique_key is missing from the target table.
Treat Open Platform writes as stable zero-config behavior.
```
Next-stage Open Platform boundary:
```text
Task project placement, section placement, executors, followers, and Feishu-side
dedupe/search should be implemented in a later stage after the exact Task API
request fields and tenant behavior are confirmed. They are not part of the
current stable or experimental surface.
```
Testing:
```text
Mock HTTP tests cover DocX/Wiki, Bitable sync, and Task create paths.
Real Open Platform calls require a configured test enterprise.
Failures should be preserved in smoke reports rather than converted into fake passes.
```
## Layer 3: GitLink Management Planning
Status: future work only.
Purpose:
```text
Plan a permissioned path where Feishu can become an entry point for selected GitLink actions.
```
Implemented commands:
```text
none
```
Planned requirements:
```text
Feishu callback verification.
Repo binding.
Feishu open_id / union_id to GitLink identity mapping.
GitLink permission checks.
Dry-run action preview.
Explicit confirmation.
Audit logs.
Maintainer-controlled policy.
```
Not implemented in this code path:
```text
issue comment
PR comment
PR review
PR approve
PR request changes
issue close
PR merge
member add/remove
webhook create/update
branch delete
release delete
callback server
action apply
```
Authorization policy:
```text
GitLink write permissions must be defined by GitLink official maintainers, project owners, and deployers.
This module must not hard-code a write-action authorization policy.
```
## Next-Stage Read-Only PR Activity Layer
Before any GitLink action gateway, a read-only PR activity layer should support:
```text
Complete open/merged/closed inventory.
Formal review status.
Review and journal attribution by submitter/reviewer/participant/system.
Previous-snapshot comparison.
Maintainer-role enrichment when authenticated member data is available.
Review-content fingerprints and change detection.
```
The generic actor, review, fallback, and snapshot rules are defined in
`docs/FEISHU_PR_ACTIVITY_STRATEGY.md`. Member lookup is optional enrichment:
when GitLink authentication or permission is unavailable, the CLI must not
guess that a participant is a maintainer.
Current implementation status:
```text
implemented: complete open/merged/closed PR inventory
implemented: optional read-only formal review and journal actor attribution
implemented: conservative reviewed/unreviewed classification
implemented: needs_re_review when submitter activity or PR updates happen after reviewer feedback
not implemented: previous-snapshot diff
not implemented: maintainer-role enrichment
not implemented: review-content fingerprint persistence
```

151
docs/FEISHU_ENVIRONMENT.md Normal file
View File

@ -0,0 +1,151 @@
# Feishu Environment Variables
Date: 2026-06-26
Do not commit real values. Use a local shell profile, CI secret store, or test terminal session.
Recommended local workflow:
```powershell
.\scripts\feishu-gitlink-setup.ps1
.\scripts\feishu-gitlink-env-check.ps1 -Layer stable
.\scripts\feishu-gitlink-smoke.ps1 -Mode preview
```
`feishu-gitlink-setup.ps1` opens the relevant Feishu / GitLink pages, lets the user paste values locally, and writes only to:
```text
.local/feishu-gitlink.env.ps1
```
The real local env file is ignored. The tracked example is:
```text
.local/feishu-gitlink.env.example.ps1
```
## Stable Custom Bot Variables
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
| --- | --- | --- | --- | --- | --- |
| `FEISHU_WEBHOOK_URL` | Feishu custom bot webhook URL | Required for `--send` bot delivery | `+bot-test`, `+notify`, `+weekly-report`, `+owner-digest`, `+contributor-digest` | Yes | Feishu group custom bot settings |
| `FEISHU_WEBHOOK_SECRET` | Optional custom bot signing secret | Optional | same as above | Yes | Feishu group custom bot security settings |
Example:
```powershell
$env:FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/REDACTED"
$env:FEISHU_WEBHOOK_SECRET="REDACTED"
```
## Open Platform App Variables
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
| --- | --- | --- | --- | --- | --- |
| `FEISHU_APP_ID` | Self-built app ID | Required for Open Platform `--send` or diagnostic `--remote` | `+app-check`, `+doc-check`, `+bitable-check`, `+task-check`, `+doc-export`, `+bitable-sync`, `+task-create` | Yes | Feishu Open Platform app page |
| `FEISHU_APP_SECRET` | Self-built app secret | Required for Open Platform `--send` or diagnostic `--remote` | same as above | Yes | Feishu Open Platform app credentials |
Example:
```powershell
$env:FEISHU_APP_ID="cli_REDACTED"
$env:FEISHU_APP_SECRET="REDACTED"
```
## DocX / Wiki Variables
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
| --- | --- | --- | --- | --- | --- |
| `FEISHU_WIKI_URL` | Existing Wiki page URL | Optional target | `+doc-check`, `+doc-export` | Can expose workspace/resource ID | Copy from Feishu Wiki |
| `FEISHU_WIKI_NODE_TOKEN` | Existing Wiki node token | Optional target | `+doc-check`, `+doc-export` | Yes | Parsed from Wiki URL or API |
| `FEISHU_FOLDER_TOKEN` | Folder token for creating a new DocX | Optional target | `+doc-check`, `+doc-export` | Yes | Feishu Drive folder URL / Open Platform docs |
| `FEISHU_DOCUMENT_ID` | Existing DocX document ID for append | Optional target | `+doc-check`, `+doc-export` | Yes | Existing Feishu DocX URL or Open Platform docs |
Legacy compatibility:
```text
FEISHU_DOC_FOLDER_TOKEN is still accepted after FEISHU_FOLDER_TOKEN.
```
Example:
```powershell
$env:FEISHU_WIKI_URL="https://example.feishu.cn/wiki/REDACTED"
$env:FEISHU_FOLDER_TOKEN="REDACTED"
$env:FEISHU_DOCUMENT_ID="REDACTED"
```
For localized output, generate the source workflow report and the Feishu output
with the same language flag:
```powershell
go run . workflow +repo-report --owner "$env:GITLINK_OWNER" --repo "$env:GITLINK_REPO" --lang zh-CN --format json > .local\report.zh-CN.json
go run . feishu +notify --from-workflow-json .local\report.zh-CN.json --lang zh-CN --format table
```
## Base / Bitable Variables
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
| --- | --- | --- | --- | --- | --- |
| `FEISHU_BASE_APP_TOKEN` | Base app token | Required for `+bitable-check` and `+bitable-sync --send` | `+bitable-check`, `+bitable-sync` | Yes | Feishu Base URL / Open Platform docs |
| `FEISHU_REPORT_TABLE_ID` | Reports table ID | Required when checking or syncing `reports` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_ISSUE_TABLE_ID` | Issues table ID | Required when checking or syncing `issues` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_PR_TABLE_ID` | Pull request table ID | Required when checking or syncing `prs` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_CONTRIBUTOR_TABLE_ID` | Contributors table ID | Optional unless selected | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_TASK_TABLE_ID` | Task-candidate table ID | Optional unless selected | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
Example:
```powershell
$env:FEISHU_BASE_APP_TOKEN="REDACTED"
$env:FEISHU_REPORT_TABLE_ID="REDACTED"
$env:FEISHU_ISSUE_TABLE_ID="REDACTED"
$env:FEISHU_PR_TABLE_ID="REDACTED"
$env:FEISHU_CONTRIBUTOR_TABLE_ID="REDACTED"
$env:FEISHU_TASK_TABLE_ID="REDACTED"
```
## Feishu Task Variables
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
| --- | --- | --- | --- | --- | --- |
| `FEISHU_TASK_PROJECT_ID` | Optional task project target | Optional | `+task-check`, `+task-create` | Yes | Feishu Task project settings / API |
| `FEISHU_TASK_SECTION_ID` | Optional task section target | Optional | `+task-check`, `+task-create` | Yes | Feishu Task section settings / API |
Current limitation:
```text
The experimental task create command creates task candidates through the Task API.
Task project and section IDs are currently collected and redacted in output,
but they are not yet mapped into the create-task request body.
Project/section placement should be wired only after the official request fields
and test-enterprise behavior are confirmed.
```
## GitLink Test Variables
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
| --- | --- | --- | --- | --- | --- |
| `GITLINK_OWNER` | Test repository owner | Optional for local smoke | workflow report generation | No | GitLink repository URL |
| `GITLINK_REPO` | Test repository name | Optional for local smoke | workflow report generation | No | GitLink repository URL |
| `GITLINK_TEST_PR_IDS` | Comma-separated PR IDs for smoke reference | Optional | smoke report only unless workflow supports filtering | No | GitLink PR URLs |
| `GITLINK_TOKEN` | GitLink API token | Optional if already logged in | workflow read operations | Yes | GitLink account settings |
Example:
```powershell
$env:GITLINK_OWNER="OWNER"
$env:GITLINK_REPO="REPO"
$env:GITLINK_TEST_PR_IDS="1,2,3"
$env:GITLINK_TOKEN="REDACTED"
```
## Safety Warnings
```text
Never paste real secrets into committed docs.
Never paste real secrets into ChatGPT.
Never print raw webhook URLs or app secrets in smoke reports.
Do not commit tenant_access_token or user_access_token.
Do not enable --send in shared scripts unless the target test enterprise is intentional.
```

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,642 @@
# Feishu OpenAPI Inventory for GitLink CLI
Date: 2026-06-26
This document maps the Feishu / Lark Open Platform APIs collected for the
`gitlink-cli feishu` integration to the current command surface and the next
implementation gaps.
The inventory is intentionally split into three surfaces:
```text
Layer 1: Stable custom bot export
Layer 2: Experimental Open Platform validation
Layer 3: Future callback-based GitLink action gateway
```
No implemented command in this branch performs GitLink write operations.
## GitLink Read Sources Used by Feishu Exports
The Feishu commands consume `workflow +repo-report` JSON. The workflow report
uses these GitLink read-only sources when the corresponding flags are enabled:
| GitLink read source | Used by | Status | Notes |
| --- | --- | --- | --- |
| `GET /v1/{owner}/{repo}/issues?category=opened` | Issue analysis | Implemented | Paginates all open issues by default |
| `GET /v1/{owner}/{repo}/pulls?status=0` | Open PR analysis | Implemented | Paginates all open PRs by default |
| `GET /v1/{owner}/{repo}/pulls?status=1` | PR lifecycle totals | Implemented | Merged total only |
| `GET /v1/{owner}/{repo}/pulls?status=2` | PR lifecycle totals | Implemented | Closed/rejected total only |
| `GET /v1/{owner}/{repo}/pulls/{number}/reviews` | Optional review audit | Implemented read-only | Formal review objects are authoritative review evidence |
| `GET /v1/{owner}/{repo}/issues/{issue_id}/journals` | Optional review audit | Implemented read-only | Counts submitter/reviewer/participant/system activity without storing raw comment text |
| Repository member lookup | Optional role enrichment | Not implemented in this branch | Maintainer identity is not guessed when auth is unavailable |
## Source Index
Official Feishu / Lark references used for this inventory:
```text
Custom bot:
https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
https://open.feishu.cn/document/feishu-cards/quick-start/send-message-cards-with-custom-bot?lang=zh-CN
App authentication:
https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN
IM app bot:
https://open.feishu.cn/document/server-docs/im-v1/message/create?lang=zh-CN
DocX / Wiki:
https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/create
https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document-block/create?lang=zh-CN
https://open.feishu.cn/document/server-docs/docs/wiki-v2/space/get_node
Base / Bitable:
https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/search
https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/create?lang=zh-CN
https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/update
Task:
https://open.feishu.cn/document/task-v2/task/create?lang=zh-CN
lark-cli:
https://github.com/larksuite/cli
https://open.larksuite.com/document/mcp_open_tools/feishu-cli-let-ai-actually-do-your-work-in-feishu
https://www.feishu.cn/feishu-cli
```
## Current API Usage Summary
| Area | Endpoint / API family | Current command | Status | Write target | Notes |
| --- | --- | --- | --- | --- | --- |
| Custom bot webhook | `POST /open-apis/bot/v2/hook/{token}` | `+bot-test`, `+notify`, `+weekly-report`, `+owner-digest`, `+contributor-digest` | Implemented stable | Feishu chat message | Requires `--send`; preview by default |
| Custom bot signature | timestamp + HMAC-SHA256 signing secret | same as above | Implemented stable | Request signature only | `FEISHU_WEBHOOK_SECRET` optional |
| Tenant token | `POST /auth/v3/tenant_access_token/internal` | `+app-check --remote`, `+doc-check --remote`, `+bitable-check --remote`, `+task-check --remote`, `+doc-export`, `+bitable-sync`, `+task-create` | Implemented | Tenant token | No token cache yet |
| Wiki node resolution | `GET /wiki/v2/spaces/get_node?token=...` | `+doc-check --remote`, `+doc-export` | Implemented | Wiki metadata read | Used to resolve Wiki node to DocX object token |
| DocX create | `POST /docx/v1/documents` | `+doc-export` | Implemented experimental | New DocX document | Requires folder/resource permission |
| DocX append blocks | `POST /docx/v1/documents/{document_id}/blocks/{block_id}/children` | `+doc-export` | Implemented experimental | DocX block tree | Real write can fail on scope or document permission |
| Bitable search | `POST /bitable/v1/apps/{app_token}/tables/{table_id}/records/search` | `+bitable-check --remote`, `+bitable-sync` | Implemented | Existing Base table | `+bitable-check` searches a sentinel key without writing |
| Bitable create record | `POST /bitable/v1/apps/{app_token}/tables/{table_id}/records` | `+bitable-sync` | Implemented experimental | Existing Base table | No table/field/view creation |
| Bitable update record | `PUT /bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}` | `+bitable-sync` | Implemented experimental | Existing Base table | Never deletes records |
| Task create | `POST /task/v2/tasks` | `+task-create` | Implemented experimental | Feishu task | Project/section placement is not mapped into request body yet |
| IM app bot send | `POST /im/v1/messages?receive_id_type=...` | none | Planned | App-bot message | Needed for direct/group app bot sends beyond custom bot |
| Card callbacks | Interactive card callback / event subscription | none | Future | Callback server | Required before Feishu-triggered GitLink actions |
| User identity | open_id / union_id / user lookup | none | Future | Identity mapping | Required before personalized contributor routing |
## Layer 1: Stable Custom Bot Export
### Implemented APIs
#### Custom Bot Webhook
Current commands:
```text
+bot-test
+notify
+weekly-report
+owner-digest
+contributor-digest
```
Inputs:
```text
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET optional
--send required for real delivery
--dry-run conflicts with --send
```
Current behavior:
```text
Builds Feishu interactive card payloads.
Signs webhook requests when a secret is configured.
Prints local previews by default.
Redacts webhook URLs and secrets from normal output.
Only includes navigation buttons.
```
Limits:
```text
No personalized routing.
No app-level chat_id.
No callback execution.
No Feishu resource write.
No GitLink resource write.
```
Next hardening:
```text
Add more card color/stage variants for PR review state.
Add compact owner card and detailed digest variants.
Keep image evidence deferred for this upload; use text smoke evidence instead.
```
## Layer 2: Experimental Open Platform Validation
### App Authentication
Endpoint:
```text
POST /auth/v3/tenant_access_token/internal
```
Current commands:
```text
+doc-export
+bitable-sync
+task-create
+app-check
+doc-check
+bitable-check
+task-check
```
Inputs:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
```
Current behavior:
```text
Fetches tenant_access_token before Open Platform writes.
Does not persist or cache tenant_access_token.
Does not print the raw token.
```
Next hardening:
```text
`+app-check` now exists. Add richer scope-name hints once official scope names
are mapped in the code.
Cache token in memory during one command execution only.
Add scope diagnostics where official scope names are confirmed.
```
### DocX / Wiki
Endpoints:
```text
GET /wiki/v2/spaces/get_node?token=...
POST /docx/v1/documents
POST /docx/v1/documents/{document_id}/blocks/{parent_block_id}/children
```
Current command:
```text
+doc-export
```
Inputs:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
FEISHU_WIKI_URL or FEISHU_WIKI_NODE_TOKEN
FEISHU_FOLDER_TOKEN optional
FEISHU_DOCUMENT_ID optional
--send required for real write
```
Current behavior:
```text
Preview renders workflow report content locally.
Wiki URL can be parsed into a node token.
Wiki node can be resolved to a DocX object token.
Existing DocX / Wiki target is appended when allowed.
Folder token can be used to create a new DocX when allowed.
Diagnostics preserve Feishu errors without leaking tokens.
```
Known blockers:
```text
The app must have approved document scopes.
The app must be able to edit the target Wiki / DocX page.
For folder creation, the app must be able to create files in the target folder.
The command does not modify document permissions.
```
Local UI observation:
```text
The Feishu desktop app currently shows a cloud-doc permission request flow.
This supports the current design decision that resource-level document access
must be handled by the owner/admin outside the CLI.
```
Next hardening:
```text
`+doc-check` now validates DocX/Wiki target configuration and can resolve Wiki
nodes with --remote.
Add clearer output for target type: wiki node, existing doc, folder creation.
Add optional markdown-only export for manual paste into Feishu Docs.
```
### Base / Bitable
Endpoints:
```text
POST /bitable/v1/apps/{app_token}/tables/{table_id}/records/search
POST /bitable/v1/apps/{app_token}/tables/{table_id}/records
PUT /bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}
```
Current commands:
```text
+bitable-schema
+bitable-records
+bitable-check
+bitable-sync
```
Inputs:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
FEISHU_BASE_APP_TOKEN
FEISHU_REPORT_TABLE_ID
FEISHU_ISSUE_TABLE_ID
FEISHU_PR_TABLE_ID
FEISHU_CONTRIBUTOR_TABLE_ID optional
FEISHU_TASK_TABLE_ID optional
--send required for real sync
```
Current tables:
```text
reports
issues
prs
contributors
tasks
```
Current behavior:
```text
+bitable-schema outputs a dry-run schema.
+bitable-records outputs summary-oriented local records.
+bitable-check validates configured table IDs and expected fields.
+bitable-check --remote searches a sentinel unique_key to verify table access
and the unique_key field without writing records.
+bitable-sync previews by default.
+bitable-sync --send searches by unique_key, updates if found, creates if missing.
If search fails, the command falls back to create-only for that record.
Slice values are flattened into newline-separated text before OpenAPI writes.
No records are deleted.
```
Local test-enterprise validation on 2026-06-26:
```text
The provided Base links resolved to one Base and one table with multiple views.
The table initially contained only default fields.
The missing fields were created manually through OpenAPI for validation.
+bitable-sync then successfully created and updated reports, issues, prs,
contributors, and task records in the test table.
```
Follow-up split-table validation on 2026-06-26:
```text
Five dedicated test tables were created or reused in the same Base:
gitlink_reports, gitlink_issues, gitlink_prs, gitlink_contributors, gitlink_tasks.
Each table received the fields required by its record group.
+bitable-sync --send then wrote every group to its own table:
reports=1, issues=5, prs=2, contributors=1, tasks=7.
```
The split-table run proves that the CLI can write each supported Bitable record
group to an independent table when the table IDs are configured separately.
Known blockers:
```text
The Base app must already exist.
The target tables must already exist.
The target tables must contain a compatible unique_key field.
Field types must be compatible with generated record values.
No Bitable view creation is implemented.
No table/field creation is implemented.
Current records are summary buckets, not full row-level PR/Issue/CI records.
```
Next hardening:
```text
`+bitable-check` now provides pre-write table ID and unique_key search
diagnostics. Field type validation still depends on richer Feishu field metadata.
Add row-level records for PRs, Issues, CI runs, milestones, releases, and audits.
Add optional Bitable view planning output for Kanban, Gantt, Calendar, Gallery, Form, and Dashboard.
Keep real view creation as a separate permissioned task.
```
### Task
Endpoint:
```text
POST /task/v2/tasks
```
Current commands:
```text
+task-preview
+task-check
+task-create
```
Inputs:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
FEISHU_TASK_PROJECT_ID optional
FEISHU_TASK_SECTION_ID optional
--send required for real creation
```
Current behavior:
```text
+task-preview generates local task candidates.
+task-check validates app credentials and documents current project/section and
dedupe limitations without creating tasks.
+task-create previews by default and creates tasks only with --send.
Task candidates are derived from workflow recommendations, high-risk issues,
missing-info issues, high-risk PRs, and review-focus items.
Local dedupe uses stable unique_key generation.
```
Local test-enterprise validation on 2026-06-26:
```text
+task-preview generated 7 task candidates from the Gitlink/gitlink-cli report.
+task-create --send created 7 Feishu tasks.
The task result table now shows per-task create status and redacted task IDs.
```
Known blockers:
```text
Feishu-side dedupe/search is not implemented.
Task project and section IDs are collected and redacted in output, but the
current OpenAPI request body only sends summary and description. Project/section
placement must be wired only after the official request fields and tenant
behavior are confirmed in the test enterprise.
```
This is a next-stage capability boundary, not a current implementation gap to
hide. The current branch proves basic Task API creation; project placement,
section placement, executors, followers, and Feishu-side task dedupe should be
added in a later implementation stage.
Next hardening:
```text
Confirm official Task project/section placement fields.
Add Feishu-side dedupe or external unique_key linking when a stable API path exists.
`+task-check --remote` validates app credentials without creating tasks. Add
scope diagnostics and project/section request mapping after the official fields
are confirmed.
```
## i18n Validation
Current Feishu commands can consume a Chinese workflow report and render
localized Feishu output:
```text
workflow +repo-report --lang zh-CN
feishu +notify --lang zh-CN
feishu +owner-digest --lang zh-CN
feishu +contributor-digest --lang zh-CN
feishu +doc-export --lang zh-CN
feishu +task-preview --lang zh-CN
feishu +task-create --lang zh-CN
```
Validated output surfaces:
```text
card field labels
owner/contributor digest headings
common workflow recommendations
DocX block headings
task candidate titles and descriptions
table/markdown preview labels
```
Repository-wide i18n check still reports that `internal/i18n/locales/en-US.json`
needs formatting. That is outside the Feishu module and was left untouched to
avoid unrelated locale-file churn.
## Layer 3: Future Callback-Based GitLink Action Gateway
No callback server or GitLink write action is implemented in this branch.
Planned Feishu API families:
```text
Card callback verification
Event subscription / long connection or callback endpoint
IM message update or follow-up message
User identity lookup: open_id / union_id / email
Chat membership or chat metadata where needed
```
Planned GitLink command families:
```text
Read:
workflow +repo-report
issue +list / +view
pr +list / +view / +files / +diff / +reviews
ci +builds / +logs
pipeline +list / +view / +runs / +results
Low-risk future writes:
issue +comment
pr +comment
pr +review
High-risk future writes disabled by default:
pr +merge
issue +close
member +add / +remove / +role
webhook +create / +update / +delete
branch or release deletion
```
Required gateway controls:
```text
Verify Feishu callback signature.
Resolve repo binding.
Map Feishu identity to GitLink identity.
Check GitLink permission.
Generate GitLink dry-run preview.
Require explicit confirmation.
Write audit logs.
Disable high-risk actions by default.
Never execute GitLink writes from a custom bot webhook.
```
## GitLink Data Source Inventory
Current Feishu commands primarily consume:
```text
workflow +repo-report --format json
```
Current source properties:
```text
Read-only.
Works with local JSON fixture or remote GitLink report generation.
Does not require the Feishu module to know a GitLink token.
Provides summary-level issue, PR, contributor, recommendation, and health fields.
```
`workflow +repo-report` paginates all open issues and pull requests by default.
`--issue-limit` and `--pr-limit` are explicit sampling controls. Counts in
Feishu output are labeled as analyzed counts and must not be interpreted as
repository totals after an explicit limit is applied.
Required future source expansion:
```text
PR row source:
pr +list
pr +view
pr +files
pr +diff
pr +versions
pr +reviews
Issue row source:
issue +list
issue +view
issue metadata commands
CI / pipeline row source:
ci +builds
ci +logs
pipeline +runs
pipeline +results
Milestone / release source:
milestone and release commands where available
Audit source:
future action gateway audit log
```
Next-stage PR activity comparison uses the read-only PR review and associated
Issue journal endpoints. Actor attribution and snapshot-diff rules are defined
in `docs/FEISHU_PR_ACTIVITY_STRATEGY.md`. This remains planned; current Feishu
commands do not crawl or copy PR conversations.
Reason:
```text
Summary buckets are enough for cards and weekly reports.
Kanban, Gantt, Calendar, Gallery, dashboard, and personal task panels require
row-level GitLink records.
```
## Manual Setup Required From User
Stable webhook validation:
```text
1. Add a custom bot to the target Feishu test group.
2. Copy the webhook URL into FEISHU_WEBHOOK_URL.
3. Copy the signing secret into FEISHU_WEBHOOK_SECRET if signing is enabled.
4. Run +bot-test or +notify with --send.
```
DocX / Wiki validation:
```text
1. Confirm FEISHU_APP_ID and FEISHU_APP_SECRET for the self-built app.
2. Approve required DocX / Drive scopes in Feishu Open Platform.
3. Grant the app edit access to the target Wiki / DocX page, or provide a
folder token where the app can create documents.
4. Run +doc-export first without --send, then with --send.
```
Bitable validation:
```text
1. Create or choose a Base manually.
2. Create reports, issues, prs, contributors, and tasks tables manually.
3. Add a unique_key field to every table.
4. Copy FEISHU_BASE_APP_TOKEN and each table ID into local env.
5. Grant the self-built app Base/Bitable access.
6. Run +bitable-sync without --send first, then with --send.
```
For a quick validation, multiple table env vars can point to the same test
table if that table has every required field. For a real project cockpit, prefer
separate tables or a row-level model that supports Kanban, Gantt, Calendar,
Gallery, Form, and Dashboard views without mixing incompatible record groups.
Task validation:
```text
1. Confirm Task API scopes for the self-built app.
2. Decide whether tasks should be created as plain tasks first.
3. Do not rely on project/section placement until request fields are verified.
4. Run +task-preview first, then +task-create --send.
```
GitLink real data validation:
```text
1. Set GITLINK_OWNER and GITLINK_REPO.
2. Set GITLINK_TEST_PR_IDS for smoke report reference.
3. Ensure gitlink-cli can generate workflow +repo-report JSON.
4. Do not commit GitLink tokens or account credentials.
```
## Acceptance Checklist For API Collection
```text
[x] Custom bot webhook API identified.
[x] Custom bot signing behavior mapped to current code.
[x] tenant_access_token API identified and implemented.
[x] Wiki node resolution API identified and implemented.
[x] DocX create and block append APIs identified and implemented.
[x] Bitable record search/create/update APIs identified and implemented.
[x] Task create API identified and implemented at minimal summary/description level.
[x] Read/check diagnostics implemented for app, DocX/Wiki, Bitable, and Task setup.
[x] IM app bot send API identified as planned, not implemented.
[x] Card callback/event subscription identified as future, not implemented.
[x] User identity APIs identified as future, not implemented.
[x] GitLink read data source boundary documented.
[x] GitLink write action boundary documented as not implemented.
[x] Required local env variables documented.
[x] Resource-level permission requirements documented.
[x] Remaining user/manual setup steps documented.
```

View File

@ -0,0 +1,246 @@
# PR Activity, Review Attribution, and Snapshot Strategy
Status: next-stage design. No GitLink write operation is implemented here.
## Goal
Provide repository owners with a general, cross-repository view of:
- all current open pull requests;
- merged and closed/rejected totals;
- state transitions since the previous assessment;
- whether a pull request received a formal review;
- whether conversation feedback came from the submitter, a reviewer, a
maintainer, another participant, a bot, or a system event;
- whether review/comment content changed since the previous snapshot.
The model must not depend on a specific repository, user login, PR number, or
organization role name.
## Verified Read Sources
The GitLink API surfaces needed by this design are read-only:
```text
GET /v1/{owner}/{repo}/pulls
GET /v1/{owner}/{repo}/pulls/{number}
GET /v1/{owner}/{repo}/pulls/{number}/reviews
GET /v1/{owner}/{repo}/issues/{issue_id}/journals
GET repository members when the current identity has permission
```
Local validation confirmed:
- list responses expose PR state, author, associated issue ID, and pagination
totals;
- formal reviews expose reviewer identity, status, content, and time;
- journals expose actor identity, comment text, state events, created time, and
updated time;
- repository-member lookup may return 401 for an unauthenticated read. The
implementation must degrade to `participant`, not guess maintainer status.
## Actor Classification
Normalize identities by stable user ID first and login second.
| Actor class | Evidence |
| --- | --- |
| `submitter` | Actor matches the PR author |
| `reviewer` | Actor owns a formal review or is in the assigned reviewer set |
| `maintainer` | Repository membership data proves a configured privileged role |
| `participant` | Authenticated human who is none of the above |
| `bot` | Explicit bot/application identity |
| `system` | State transition or generated event without human review content |
| `unknown` | Identity is incomplete |
Role precedence:
```text
system/bot -> submitter -> formal reviewer -> maintainer -> participant -> unknown
```
A user can be both maintainer and reviewer. Event attribution records the most
specific event relationship (`reviewer`) and may retain `is_maintainer=true` as
an additional property.
## Review Standard
Do not treat every comment as a review.
### Authoritative review
A formal review object with:
```text
status: approved | rejected | common
reviewer identity
created_at
```
is authoritative review evidence.
### Review-like journal feedback
A journal comment is review feedback only when:
1. it has non-empty human-authored content;
2. it is not a creation/status/system event;
3. the actor is a formal/assigned reviewer or a proven maintainer;
4. the actor is not the PR submitter, unless the UI explicitly marks a
self-review;
5. the normalized content is not only an acknowledgement such as `LGTM`,
`thanks`, or a generated status line, unless the product policy explicitly
enables acknowledgement reviews.
When member data is unavailable, a comment from an unassigned actor remains
`participant_feedback`, not `maintainer_review`.
## Risk Is Separate From Review
Current `workflow +repo-report` risk is rule-based. A list-metadata keyword hit
is a risk hint, not proof that a reviewer found a problem.
The next-stage output should keep separate fields:
```text
metadata_risk_hint
code_change_risk
formal_review_status
review_feedback_status
merge_readiness
```
Detailed code risk requires files and commits. Bulk list metadata alone must
not be presented as a formal review conclusion.
## Snapshot Model
Recommended local snapshot:
```json
{
"schema_version": 1,
"repository": "owner/repo",
"generated_at": "RFC3339",
"totals": {
"open": 0,
"merged": 0,
"closed": 0
},
"pull_requests": [
{
"number": 1,
"state": "open",
"author_id": "stable-id",
"updated_at": "RFC3339",
"head_revision": "optional",
"formal_review_status": "unreviewed",
"review_fingerprint": "sha256",
"conversation_fingerprint": "sha256",
"events": []
}
]
}
```
Raw access tokens and private profile fields must never enter snapshots.
## Content Fingerprints
Normalize review/comment content before hashing:
1. normalize line endings;
2. trim leading/trailing whitespace;
3. collapse repeated whitespace outside code blocks;
4. remove generated status-only markup;
5. preserve code and semantic text;
6. hash actor ID, event type, normalized content, and event time.
Store hashes and bounded summaries by default. Raw comment content should be
included only in an explicitly local evidence file.
## Snapshot Diff
Compare the current snapshot with `--previous-snapshot` and emit:
```text
new_open_prs
newly_merged_prs
newly_closed_prs
reopened_prs
new_formal_reviews
review_status_changes
new_reviewer_feedback
edited_reviewer_feedback
submitter_responses
participant_feedback
```
State transitions are determined by PR number plus previous/current state, not
by subtracting aggregate totals.
## Fetch Strategy
Default inventory:
1. paginate all PR list states;
2. record exact list totals and basic identity/state fields;
3. compare with the previous snapshot;
4. deep-fetch reviews and journals only for new or updated PRs.
Optional full audit:
```text
--full-review-audit
```
This explicitly deep-fetches every PR and may require hundreds of API calls.
Use bounded concurrency, retry/backoff, and a request summary. It must remain
read-only.
## Planned Commands
```text
gitlink-cli workflow +pr-activity-snapshot
gitlink-cli workflow +pr-activity-diff
gitlink-cli feishu +owner-activity-digest
```
The Feishu digest should show aggregate transitions and the most important
changed PRs. It must link to GitLink for full comments rather than copying an
unbounded conversation into a card.
## Current Boundary
Implemented in the current branch:
- correct GitLink Issue and PR list filters;
- complete pagination for `workflow +repo-report` by default;
- PR lifecycle totals for open, merged, and closed/rejected states;
- explicit analyzed-count labels and scope notes;
- optional read-only formal review and journal actor attribution through
`workflow +repo-report --include-pr-review-audit`.
The implemented audit follows the conservative review standard in this
document:
- a formal `/pulls/{number}/reviews` object marks the PR as reviewed;
- journal comments from the PR submitter are counted as submitter responses,
not reviews;
- journal comments from an actor who also has a formal review on the PR are
counted as reviewer feedback;
- other human comments are counted as participant feedback;
- status changes and empty/generated events are counted as system events;
- a reviewed PR is marked `needs_re_review` when a later submitter comment,
later commit, or later PR update timestamp is newer than the latest reviewer
feedback timestamp;
- maintainer classification is not guessed when repository-member data is not
available.
Not implemented in the current branch:
- snapshot persistence;
- member-role enrichment;
- review-content diffing;
- Feishu activity-diff cards;
- any GitLink write operation.

View File

@ -0,0 +1,637 @@
# GitLink CLI Capability Boundary
Date: 2026-06-26
## Purpose
This document defines the current `gitlink-cli` capability boundary before the Feishu integration grows from export-only reporting into a permissioned collaboration gateway.
The key rule is:
```text
Feishu can become an entry point, but gitlink-cli remains the authoritative GitLink data reader and action executor.
```
Any Feishu-triggered GitLink action must respect this boundary:
```text
Feishu callback -> validate Feishu identity -> validate repo binding -> map to gitlink-cli action -> dry-run -> confirm -> execute -> audit
```
## Capability Levels
Use these levels when deciding whether a GitLink command can be exposed through Feishu.
| Level | Name | Meaning | Feishu Gateway Policy |
| --- | --- | --- | --- |
| Level 0 | Read / Local Analysis | Reads GitLink data or analyzes local input | Safe for Stage 1 cards, docs, Bitable-ready records |
| Level 1 | Low-Risk Write | Adds reversible or additive collaboration data | Stage 3 only, requires identity mapping, dry-run, confirmation, audit |
| Level 2 | Medium-Risk Write | Changes project state but is usually recoverable | Planned only, disabled by default |
| Level 3 | High-Risk Write | Merge, delete, close, permission, or membership changes | Do not expose by default; requires explicit dangerous-action opt-in |
| Admin | Credential / Raw API | Auth, config, arbitrary API calls | Do not expose through Feishu cards |
## Current Command Surface
Top-level command groups currently available:
```text
api
auth
branch
ci
compare
config
dataset
doctor
feishu
health
ignore
issue
label
license
member
milestone
org
pipeline
pr
profile
release
repo
search
user
webhook
workflow
```
## Level 0: Read and Local Analysis
These commands are appropriate inputs for Feishu reports, Bitable records, project dashboards, and owner / contributor digest cards.
### Repository Read
```text
repo +list
repo +info
repo +readme
repo +tree
repo +languages
repo +contributors
repo +contributor-stats
repo +code-stats
repo +watchers
repo +stargazers
```
Feishu use:
```text
project overview
README / Wiki mirror
contributor dashboard
repository health report
organization cockpit
```
### Issue Read
```text
issue +list
issue +view
issue +authors
issue +assigners
issue +priorities
issue +statuses
issue +tags
```
Feishu use:
```text
issue risk summary
triage queue
personal task panel
dashboard by priority / status / stale age
```
### Pull Request Read
```text
pr +list
pr +view
pr +files
pr +diff
pr +versions
pr +version-diff
pr +reviews
```
Feishu use:
```text
PR stage cards
review queue
contributor feedback digest
rebase / conflict / review-round tracking
near-ready merge list
```
### CI and Pipeline Read
```text
ci +builds
ci +logs
pipeline +list
pipeline +view
pipeline +runs
pipeline +logs
pipeline +results
pipeline +save-yaml
```
Feishu use:
```text
CI failure digest
release readiness report
pipeline health dashboard
PR risk enrichment
```
### Webhook Read
```text
webhook +list
webhook +view
webhook +tasks
```
Feishu use:
```text
integration diagnostics
delivery failure summary
owner operational report
```
### Member / Organization Read
```text
member +list
member +invite-info
org +list
org +info
org +members
```
Feishu use:
```text
maintainer roster
reviewer capacity view
repository permission audit preview
```
### Milestone / Release / Dataset / Label Read
```text
milestone +list
milestone +view
release +list
release +view
release +edit
dataset +list
dataset +view
label +list
license +list
```
Feishu use:
```text
milestone Gantt source
release calendar
dataset inventory
issue label taxonomy
```
### Search / User / Profile / Compare
```text
search +repos
search +users
user +me
user +info
profile +ability
profile +activity
profile +contribution
profile +major
profile +role
compare +view
compare +files
```
Feishu use:
```text
contributor profile enrichment
organization talent view
release diff summary
project discovery
```
### Workflow and Health Analysis
```text
workflow +triage
workflow +health
workflow +pr-summary
workflow +repo-report
health +fetch
doctor
version
```
Feishu use:
```text
weekly report
owner digest
issue triage card
PR review summary
repository health dashboard
```
Boundary:
```text
Workflow commands are the safest first-class source for Feishu Stage 1.
They should remain read-only or local-analysis commands.
```
## Level 1: Low-Risk Write
These actions add collaboration information but do not normally destroy project state.
Candidates:
```text
issue +comment
issue +create
pr +comment
pr +review with common/comment
pr +review with approved
pr +review with rejected/request changes
```
Feishu Gateway policy:
```text
Stage 3 only
requires self-built app callback validation
requires Feishu user -> GitLink user mapping
requires repo binding
requires GitLink token and permission check
requires dry-run preview
requires explicit confirmation
requires audit log
```
Why these are lower risk:
```text
comments and reviews are additive
issue creation is visible and reversible by later close/edit
approval/request changes affects review state but does not merge code
```
Still not safe for Stage 1:
```text
These are GitLink writes. They must not be exposed from custom bot cards or unauthenticated webhooks.
```
## Level 2: Medium-Risk Write
These actions change workflow state and can disrupt project management, but they are usually recoverable.
Candidates:
```text
issue +update
pr +create
pr +reopen
ci +restart
ci +stop
pipeline +run
pipeline +enable
pipeline +disable
milestone +create
milestone +update
milestone +close
milestone +reopen
release +create
release +update
dataset +create
dataset +update
label +create
label +update
repo +follow
repo +unfollow
repo +like
repo +unlike
webhook +test
member +accept-invite
```
Feishu Gateway policy:
```text
planned only
disabled by default
requires stronger confirmation than Level 1
requires allowlist by action type and repository
requires audit log
should support dry-run where the underlying command supports it
```
Design note:
```text
Some Level 2 actions can move to Level 1 only after the project has clear policy and tests.
For example, creating a milestone may be low-risk in one organization but not in another.
```
## Level 3: High-Risk Write
These actions should not be exposed by default through Feishu.
Actions:
```text
pr +merge
pr +refuse
issue +close
issue +batch-close
branch +delete
branch +protect
branch +unprotect
release +delete
dataset +delete-attachment
label +delete
repo +delete
member +add
member +batch-add
member +remove
member +role
webhook +create
webhook +update
webhook +delete
pipeline +delete
org +create
repo +create
repo +fork
branch +create
```
Why high risk:
```text
merge changes code history and release state
close/refuse can stop contributor work
delete actions can remove project assets
member actions change access control
webhook actions can exfiltrate or disrupt events
branch protection changes affect repository safety
repo/org creation can create governance and ownership issues
```
Feishu Gateway policy:
```text
do not implement in the main Stage 3 path
only design as experimental
requires --enable-dangerous-actions or equivalent server config
requires maintainer / owner role check
requires repository allowlist
requires action-specific second confirmation
requires audit log with before/after payloads when available
requires rate limiting
requires rollback guidance where possible
```
## Admin and Raw API Surface
These surfaces should not be exposed as Feishu card actions.
```text
auth login
auth logout
config set
api arbitrary METHOD PATH
api --batch-file without strict allowlist
```
Reason:
```text
They operate on credentials, local configuration, or arbitrary GitLink API requests.
They are too broad for a safe Feishu action gateway.
```
Allowed Feishu use:
```text
show auth status diagnostics
show required setup steps
run app-check style read-only environment diagnostics
```
Not allowed:
```text
collect GitLink passwords
display tokens
write credentials from Feishu payloads
execute arbitrary raw API requests from card callbacks
```
## Dry-Run and Confirmation Requirements
Current gitlink-cli has uneven dry-run coverage. Some write commands support dry-run, some do not.
Action Gateway must not assume all commands are safe to preview.
Required gateway behavior:
```text
Level 0:
can run read commands directly after repo binding validation
Level 1:
must construct a dry-run preview
if native dry-run exists, use it
if native dry-run does not exist, render a gateway-level preview and stop before execution
Level 2:
dry-run preview plus explicit confirmation
repository and action allowlist required
Level 3:
disabled by default
dangerous-action opt-in required
second confirmation required
```
## Identity Boundary
GitLink CLI and Feishu identities are different permission domains.
Required mapping:
```text
Feishu open_id / union_id / email -> GitLink username -> GitLink token or allowed service identity
```
Do not assume:
```text
Feishu display name == GitLink username
Feishu email always exists
one Feishu user maps to exactly one GitLink user
group chat actor is authorized for all repository actions
```
Stage policy:
```text
Stage 1:
no personal write actions, identity mapping optional
Stage 2:
mapping can be used for dashboards and personal panels
Stage 3:
mapping is mandatory before any GitLink write
Stage 4:
mapping plus maintainer role verification is mandatory
```
## Feishu Integration Implications
### Safe First Implementation
Use Level 0 commands to produce:
```text
owner digest
contributor digest preview
PR stage summary
Issue risk summary
CI status summary
Bitable-ready records
Doc/Wiki markdown
```
### Low-Risk Action Gateway
Only after self-built app integration exists:
```text
issue comment
PR comment
PR review comment
PR approve
PR request changes
create issue
```
### Explicitly Not First-Line Feishu Actions
```text
merge PR
close issue
delete branch
delete release
add/remove member
change member role
create/update/delete webhook
raw API call
token/config operation
```
## Recommended Bitable Tables from GitLink Data
The current `feishu +bitable-records` command emits summary records. For project management views, GitLink CLI should eventually produce row-level records.
Recommended row-level tables:
```text
repositories
pull_requests
issues
ci_runs
pipeline_runs
milestones
releases
contributors
members
webhook_deliveries
action_audit
```
View mapping:
```text
Kanban:
pull_requests by stage
issues by status
Gantt:
milestones by start_date / due_date
releases by release window
Calendar:
issue due dates
review due dates
release dates
Gallery:
contributors
features / merged PRs
Dashboard:
repository health
PR stage counts
Issue risk counts
CI pass rate
stale work
Personal task panel:
PRs authored by mapped user
issues assigned to mapped user
PRs waiting for mapped reviewer
```
## Final Boundary
The clean GitLink boundary for Feishu is:
```text
Stage 1:
Feishu displays GitLink state.
GitLink is read-only from Feishu.
Stage 2:
Feishu stores GitLink-derived artifacts.
GitLink remains read-only from Feishu.
Stage 3:
Feishu can request low-risk GitLink collaboration actions.
gitlink-cli executes only after validation, dry-run, confirmation, and audit.
Stage 4:
High-risk GitLink actions are designed but disabled by default.
```
This boundary keeps `gitlink-cli` credible as the safe execution layer while still allowing Feishu to become the collaboration entry point.

View File

@ -0,0 +1,12 @@
# PR Review Notes Draft
This file will be finalized after implementation and smoke testing.
Reviewer questions to be filled:
- Should webhook export remain the stable main path?
- Should DocX/Wiki write remain experimental?
- Should Bitable sync enter the stable surface after more validation?
- Should Feishu Task creation belong in gitlink-cli?
- What official GitLink authorization model should be used for future GitLink write actions?
- Should future Feishu card callbacks be implemented in gitlink-cli or a separate service?

View File

@ -0,0 +1,71 @@
# Feishu Bitable Dry-Run Schema
`gitlink-cli feishu` generates Bitable schema and records locally.
`+bitable-schema` and `+bitable-records` do not call Feishu Bitable OpenAPI.
`+bitable-sync` is an experimental Open Platform command. It requires explicit `--send` before it writes.
## Commands
```bash
gitlink-cli feishu +bitable-schema --format markdown
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
gitlink-cli feishu +bitable-sync --from-workflow-json report.json --tables reports,issues,prs,tasks --format table
```
## Tables
Default tables:
```text
issues
prs
contributors
reports
tasks
```
## Record Semantics
Records are summary records derived from `workflow +repo-report` JSON.
They are not one row per GitLink issue or one row per GitLink pull request.
Current behavior:
```text
reports: one summary row per repo report
issues: summary buckets by issue type and priority
prs: summary buckets by change type and risk
contributors: role-oriented contributor digest summary
tasks: task candidates derived from recommendations, high-risk issues, high-risk PRs, and missing information
```
## Real Write Boundary
Implemented experimentally:
```text
Bitable record search by unique_key
Bitable record create
Bitable record update
create-only fallback when search fails
no-delete behavior
```
Not implemented:
```text
pagination
batch create
Base creation
table creation
view creation
field creation
person/open_id mapping
rate limits
```
Real Bitable writes require existing Base app and table IDs. The target tables should include a text field named `unique_key`.

308
docs/feishu-integration.md Normal file
View File

@ -0,0 +1,308 @@
# Feishu Integration
`gitlink-cli feishu` exports local GitLink workflow JSON to Feishu.
The stable command path is intentionally narrow:
```text
workflow JSON -> local preview
workflow JSON -> Feishu custom bot card
workflow JSON -> weekly report
workflow JSON -> owner / contributor digest
workflow JSON -> Bitable schema / records dry-run
workflow JSON -> task candidates
```
## Stable Commands
```text
gitlink-cli feishu +bot-test
gitlink-cli feishu +notify
gitlink-cli feishu +weekly-report
gitlink-cli feishu +owner-digest
gitlink-cli feishu +contributor-digest
gitlink-cli feishu +bitable-schema
gitlink-cli feishu +bitable-records
gitlink-cli feishu +task-preview
```
Experimental commands:
```text
gitlink-cli feishu +doc-export
gitlink-cli feishu +bitable-sync
gitlink-cli feishu +task-create
```
Experimental commands use Feishu self-built app OpenAPI and are not part of the stable custom-bot path.
## Safety Model
- Default behavior is local preview.
- Real Feishu bot delivery requires `--send`.
- `--send` and `--dry-run` cannot be used together.
- Webhook URLs are redacted in command output.
- Secrets and tokens are never intentionally printed.
- The stable commands do not write to GitLink resources.
- `+bitable-schema`, `+bitable-records`, and `+task-preview` are dry-run only and do not call Feishu OpenAPI.
- `+doc-export`, `+bitable-sync`, and `+task-create` require explicit `--send` before attempting Open Platform writes.
- Feishu card buttons are navigation-only.
- GitLink write operations are not implemented.
## Custom Bot Setup
Use a Feishu custom group bot for notification cards.
Recommended local setup:
```powershell
.\scripts\feishu-gitlink-setup.ps1
.\scripts\feishu-gitlink-env-check.ps1 -Layer stable
```
The setup wizard opens Feishu / GitLink pages and stores values only in `.local/feishu-gitlink.env.ps1`.
Environment:
```powershell
$env:FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/..."
$env:FEISHU_WEBHOOK_SECRET="optional signing secret"
```
Preview a test card:
```bash
gitlink-cli feishu +bot-test --format json
```
Send a test card:
```bash
gitlink-cli feishu +bot-test --send --format table
```
## Workflow Report Card
Generate workflow JSON:
```bash
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json > report.json
```
On Windows PowerShell, redirected files may be written with UTF-16 encoding. `feishu` workflow JSON readers accept UTF-8 and UTF-16 BOM files so the redirected output above can be consumed directly.
Preview a card:
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --format json
```
Send a card:
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
```
Send a card with an existing Feishu document or Wiki link:
```bash
gitlink-cli feishu +notify \
--from-workflow-json report.json \
--doc-url "https://example.feishu.cn/wiki/..." \
--send \
--format table
```
## Weekly Report
Render markdown:
```bash
gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
```
Send a weekly summary card:
```bash
gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table
```
## Owner and Contributor Digests
Owner digests summarize the repository state for maintainers:
```bash
gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown
gitlink-cli feishu +owner-digest --from-workflow-json report.json --send --format table
```
Contributor digests summarize role-oriented follow-up work:
```bash
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --send --format table
```
These digests are not Feishu-user-personalized. They do not use `open_id`, `union_id`, or personal routing.
## Bitable Dry Run
Generate recommended table schemas:
```bash
gitlink-cli feishu +bitable-schema --format markdown
```
Generate Bitable-ready records:
```bash
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
```
Default tables:
```text
reports
issues
prs
contributors
tasks
```
These records are summary records derived from workflow repo-report JSON. They are not a per-issue or per-PR synchronization.
## Experimental Bitable Sync
`feishu +bitable-sync` reuses the records produced by `+bitable-records`.
Preview:
```bash
gitlink-cli feishu +bitable-sync \
--from-workflow-json report.json \
--tables reports,issues,prs,contributors,tasks \
--format table
```
Write with existing Base app and table IDs:
```bash
gitlink-cli feishu +bitable-sync \
--from-workflow-json report.json \
--tables reports,issues,prs,tasks \
--send \
--format table
```
The command searches by `unique_key`, updates when found, creates when missing, and never deletes records. If search fails, it falls back to create-only and prints diagnostics.
## Task Preview and Experimental Task Create
Preview task candidates:
```bash
gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown
```
Attempt real Feishu task creation:
```bash
gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table
```
`+task-create` is experimental and requires Feishu Task scopes. It does not create or update GitLink issues.
## Role-Aware Collaboration Roadmap
The Feishu integration is designed to support two different notification modes:
```text
Owner / maintainer: summarized digest.
Contributor: immediate personal feedback.
```
Owner-oriented cards should group PRs by review stage instead of sending one message for every PR event. Recommended stages:
```text
blue: new or unreviewed
grey: active review
green: close to merge or merged
yellow: needs rebase
orange: major changes requested
red: blocked
```
Contributor notifications are different. A contributor should receive fast feedback when their own PR is reviewed, commented on, blocked by rebase/conflict, approved, merged, or closed.
Long-form project material should be exported to Feishu Docs / Wiki:
```text
README summary
contribution guide
owner digest archive
milestone plan
PR stage table
```
Milestone and Gantt support should start as Bitable-ready records and document sections. Real Bitable writes and view creation require a separate permissioned OpenAPI design.
Detailed design:
```text
feishu-export-design/ROLE_BASED_COLLABORATION.md
```
## Experimental DocX / Wiki Export
`feishu +doc-export` is experimental because it uses Feishu self-built app credentials and writes to DocX / Wiki through OpenAPI.
Environment:
See `docs/FEISHU_ENVIRONMENT.md` for all Open Platform variables.
Preview:
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "https://example.feishu.cn/wiki/..." \
--format markdown
```
Write to DocX / Wiki:
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "https://example.feishu.cn/wiki/..." \
--send \
--format table
```
Required Feishu setup:
```text
1. The self-built app must have approved DocX / Drive scopes.
2. The target Wiki / DocX / folder must grant the app write permission.
3. If Feishu returns 1770032: forBidden, credentials are valid but the app cannot write the target document.
```
## Layered Documentation
Detailed boundaries:
```text
docs/FEISHU_CAPABILITY_LAYERS.md
docs/FEISHU_ENVIRONMENT.md
reports/FEISHU_PERMISSION_MATRIX.md
reports/FEISHU_LOCAL_TESTING_GUIDE.md
```
Scripted smoke test:
```powershell
.\scripts\feishu-gitlink-smoke.ps1 -Mode preview
.\scripts\feishu-gitlink-smoke.ps1 -Mode stable
.\scripts\feishu-gitlink-smoke.ps1 -Mode open-platform
.\scripts\feishu-gitlink-smoke.ps1 -Mode all
```

100
docs/feishu-security.md Normal file
View File

@ -0,0 +1,100 @@
# Feishu Security Notes
## Default Behavior
All Feishu shortcut commands default to local preview.
Real network writes require `--send`.
`--send` and `--dry-run` cannot be used together.
## Secrets
Supported environment variables:
```text
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET
FEISHU_APP_ID experimental Open Platform commands only
FEISHU_APP_SECRET experimental Open Platform commands only
FEISHU_BASE_APP_TOKEN experimental bitable-sync only
FEISHU_REPORT_TABLE_ID experimental bitable-sync only
FEISHU_ISSUE_TABLE_ID experimental bitable-sync only
FEISHU_PR_TABLE_ID experimental bitable-sync only
FEISHU_WIKI_URL experimental doc-export only
FEISHU_WIKI_NODE_TOKEN experimental doc-export only
FEISHU_FOLDER_TOKEN experimental doc-export only
```
Do not commit real webhook URLs, app secrets, access tokens, Base app tokens, table IDs, or document tokens.
Command output redacts webhook URLs. Tests use fake webhook IDs.
## Stable Surface
The stable surface uses Feishu custom bot webhooks:
```text
feishu +bot-test
feishu +notify
feishu +weekly-report
feishu +owner-digest
feishu +contributor-digest
```
These commands can send Feishu cards, but they do not read or write Feishu documents, tables, users, or groups.
## Dry-Run Surface
The Bitable commands are local only:
```text
feishu +bitable-schema
feishu +bitable-records
feishu +task-preview
```
They do not call Feishu OpenAPI and cannot create, update, or upsert remote Feishu resources.
## Experimental Surface
These commands are experimental:
```text
feishu +doc-export
feishu +bitable-sync
feishu +task-create
```
They use:
```text
app_id
app_secret
tenant_access_token
Wiki OpenAPI
DocX OpenAPI
Bitable OpenAPI
Task OpenAPI
```
They should not be treated as part of the stable clean export path. If used, grant the self-built app only the minimum required resource permissions.
`+bitable-sync` never deletes records. It searches by `unique_key`, updates when found, creates when missing, and records diagnostics when Feishu rejects the call.
`+task-create` does not deduplicate against existing Feishu tasks unless Feishu-side identifiers and scopes later support that search path.
## Non-Goals
```text
BotBuilder integration
Feishu Robot Assistant workflows
automatic Feishu permission changes
GitLink remote writes
GitLink comments
Issue closure
merge actions
Feishu card callback execution
GitLink write actions from Feishu
```

View File

@ -1,112 +1,255 @@
# feat(workflow): add agent workflow commands for repository maintenance
# feat(feishu): add layered Feishu collaboration exports for workflow reports
## Summary
This PR adds five read-only workflow commands for repository maintenance:
- Add Feishu custom-bot cards for GitLink workflow reports.
- Add weekly, owner, and contributor digests.
- Add Bitable-ready schemas and records.
- Add experimental DocX, Bitable, and Task OpenAPI writes.
- Add read-only Open Platform readiness diagnostics.
- Add English and zh-CN Feishu output.
- Fix workflow Issue/PR list filters and paginate all open items by default.
- Add optional read-only PR review audit for formal reviews and comment actor attribution.
- Keep all GitLink write operations out of scope.
- `workflow +triage`
- `workflow +health`
- `workflow +pr-summary`
- `workflow +repo-report`
- `workflow +stale`
## Data Correctness
The commands provide rule-based, explainable analysis with stable `json`, concise `table`,
and copy-friendly `markdown` output.
`workflow +repo-report` now uses the GitLink API parameters used by the native
Issue and PR commands:
## Motivation
```text
Issue open filter: category=opened
PR open filter: status=0
```
Open-source maintainers often spend time on repetitive information organization before
making actual decisions:
It paginates all open issues and pull requests by default. An explicit
`--issue-limit` or `--pr-limit` enables sampling.
- Issue triage cost
- PR review cost
- repository health visibility
- Agent needs stable structured output
Feishu output labels these values as analyzed counts and includes a scope note.
This avoids presenting a limited sample as a repository total.
This PR adds workflow-level analysis on top of the existing GitLink CLI shortcut architecture
without introducing LLM dependencies or remote write behavior.
## PR Review Attribution
## Changes
`workflow +repo-report --include-pr-review-audit` performs a read-only audit of
the analyzed PRs:
### `workflow +triage`
```text
formal /pulls/{number}/reviews objects mark a PR as reviewed
submitter comments are counted separately and do not mark a PR as reviewed
participant comments are counted separately and do not mark a PR as reviewed
comments from actors with formal review identity are counted as reviewer feedback
maintainer role is not guessed when member lookup is unavailable
```
- Classifies issues by type
- Scores priority and confidence
- Detects missing bug-report information
- Produces risk flags, recommended actions, suggested comments, and reasoning
This keeps metadata risk, formal review status, and conversation attribution as
separate signals.
### `workflow +health`
## Stable Surface
- Scores repository health
- Covers issue/PR backlog, activity, release, CI, docs, license, contributing, and Agent readiness signals
- Tolerates unknown metrics without failing the command
```text
feishu +bot-test
feishu +notify
feishu +weekly-report
feishu +owner-digest
feishu +contributor-digest
feishu +bitable-schema
feishu +bitable-records
feishu +task-preview
```
### `workflow +pr-summary`
Stable commands preview locally by default. Custom-bot delivery requires
explicit `--send`.
- Summarizes PR metadata, changed files, and commits
- Produces change type, risk level, review focus, test suggestions, merge checklist, and reasoning
- Supports local JSON input and remote read-only PR fetch
## Readiness Diagnostics
### `workflow +repo-report`
```text
feishu +app-check
feishu +doc-check
feishu +bitable-check
feishu +task-check
```
- Aggregates health, issue triage, and PR summary signals
- Produces a repository workflow report with score, risk level, recommendations, and reasoning
- Supports partial read-only remote aggregation when optional sections are unavailable
Local mode checks configuration only. `--remote` performs read/check OpenAPI
calls and does not create or modify Feishu or GitLink resources.
### `workflow +stale`
## Experimental Surface
- Scans issue and pull request queues for stale activity without remote writes
- Buckets results into `watch`, `stale`, and `zombie` severity levels
- Produces queue summaries, per-item recommendations, and fallback notes when PR activity requires journal probing
```text
feishu +doc-export
feishu +bitable-sync
feishu +task-create
```
These commands require a Feishu self-built app and explicit `--send`.
## Safety
- Remote mode is read-only
- No LLM dependency
- No labels/comments/close operations
- No PR approve/reject/merge operations
- No `internal/output` change
- No new third-party dependency
- Test fixtures do not contain secrets or tokens
- Preview/check by default.
- Real Feishu side effects require explicit `--send`.
- Remote readiness calls require explicit `--remote`.
- GitLink write operations are not implemented.
- Card buttons are navigation-only.
- Secrets and resource IDs come from ignored local env files.
- CLI and smoke output redact sensitive values.
- Bitable sync never deletes records.
## Tests
## Real Validation
```bash
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
The local test enterprise validated:
```text
custom bot card delivery
English and zh-CN cards
DocX append
Bitable search/create/update
Feishu Task create
app/doc/bitable/task readiness diagnostics
```
The current real repository report validated complete default pagination:
```text
open issues analyzed: 9
open pull requests analyzed: 166
open/merged/closed PR lifecycle totals: 166 / 65 / 74
full review-audit result: 166 audited, 4 reviewed, 162 unreviewed
needs re-review after reviewer feedback: 0
```
Task creation was not repeated during the final smoke because Feishu-side
deduplication is not implemented.
## Validation Commands
```powershell
go run . workflow +repo-report --owner "$env:GITLINK_OWNER" --repo "$env:GITLINK_REPO" --format json > .local\report.json
go run . workflow +repo-report --owner "$env:GITLINK_OWNER" --repo "$env:GITLINK_REPO" --include-pr-review-audit --format json > .local\report.review-audit.full.json
go run . feishu +app-check --remote --format table
go run . feishu +doc-check --remote --format table
go run . feishu +bitable-check --tables reports,issues,prs,tasks --remote --format table
go run . feishu +task-check --remote --format table
go run . feishu +notify --from-workflow-json .local\report.json --send --format table
go run . feishu +owner-digest --from-workflow-json .local\report.review-audit.full.json --send --format table
go run . feishu +doc-export --from-workflow-json .local\report.json --send --format table
go run . feishu +bitable-sync --from-workflow-json .local\report.json --send --format table
go test ./shortcuts/feishu
go test ./shortcuts/workflow
go test ./shortcuts
go test ./...
go build .
go vet ./...
```
Coverage includes:
Local validation on 2026-06-27:
- triage rules
- health scoring
- PR summary rules
- repo report aggregation
- fetch normalization
- partial failure handling
- `json` / `table` / `markdown` rendering
- local `--from` fixtures
- command wiring tests
## Documentation
- `README.md`
- `docs/workflow-agent-design.md`
- `docs/workflow-agent-test-report.md`
- `doc/changes/workflow-stale.md`
## Known Limitations
- `workflow +release-notes` is not implemented.
- Real GitLink API shapes may require follow-up normalization.
## Examples
```bash
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
gitlink-cli workflow +stale --from shortcuts/workflow/testdata/stale_input.json --format markdown
```text
go test ./shortcuts/feishu: pass
go test ./shortcuts/workflow: pass
go test ./shortcuts: pass
go test ./...: pass
go build .: pass
go vet ./...: pass
```
CI note:
```text
.github/workflows/test.yml already includes the upstream i18n validation steps.
This branch adds Feishu/workflow package tests and go vet to the workflow.
The workflow triggers on pull_request and pushes to main/master. A feature-branch
push alone does not create a branch workflow run, so remote CI should be checked
after the PR is opened.
```
Known separate issue:
```text
On this Windows workstation, go run ./internal/i18n/cmd/check still reports
internal\i18n\locales\en-US.json formatting. That fix is intentionally kept in
the separate i18n branch/PR and is not mixed into this Feishu change.
The separate i18n fix is:
fix/i18n-locale-eol
8e24a89 fix(i18n): restore locale validation on Windows
It adds .gitattributes LF handling for locale JSON and the missing
cmd.ignore.short locale key.
```
## Review and Comment Attribution Boundary
Formal reviews and PR-associated Issue journals are consumed only by the
optional read-only audit path. Previous-snapshot comparison, member-role
enrichment, and review-content fingerprint persistence remain designed in:
```text
docs/FEISHU_PR_ACTIVITY_STRATEGY.md
```
## Evidence
Validation evidence is text-only in the repository. No screenshots or other
binary evidence are committed. If visual proof is requested, use redacted
screenshots pasted directly into the PR description, not repository files.
```text
reports/FEISHU_SMOKE_20260626.md
reports/FEISHU_SMOKE_20260627.md
reports/FEISHU_SMOKE_EVIDENCE_20260627.md
reports/FEISHU_PERMISSION_MATRIX.md
docs/FEISHU_OPENAPI_INVENTORY.md
```
Text-only validation summary:
```text
Real Feishu custom bot delivery passed:
- final English notify card: HTTP 200 / Feishu code 0
- final English owner digest card: HTTP 200 / Feishu code 0
Real GitLink repository data:
- repository: Gitlink/gitlink-cli
- open issues analyzed: 9
- open PRs analyzed: 166
- PR lifecycle totals: open 166, merged 65, closed/rejected 74
Full PR review audit:
- PRs audited: 166
- reviewed PRs: 4
- unreviewed PRs: 162
- needs re-review: 0
- formal reviews: 4
- reviewer comments: 6
- submitter comments: 0
- participant comments: 436
- system events: 0
- audit errors: 0
Risk source:
- high-risk PRs from metadata rule `security-sensitive keyword`: 13
```
## Out of Scope
- GitLink issue comment or close.
- GitLink PR review, approve, reject, or merge.
- GitLink member management.
- Feishu callback server.
- Feishu-to-GitLink identity mapping.
- Automatic Base/table/field/view creation.
- Task project/section/assignee placement.
- Feishu-side Task deduplication.
- PR activity snapshot persistence.
- Review-content fingerprint diffing.
- Maintainer-role enrichment without authenticated member data.
## Reviewer Questions
- Should webhook export remain the stable main path?
- Should DocX, Bitable, and Task writes remain experimental?
- Should full PR review activity be a separate workflow command?
- Should member-role enrichment require authenticated GitLink access?
- Should future Feishu callbacks live in gitlink-cli or a separate service?

96
examples/feishu/README.md Normal file
View File

@ -0,0 +1,96 @@
# Feishu Export Examples
## Stable Workflow
### 1. Generate Workflow JSON
```bash
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json > report.json
```
### 2. Preview Notification Card
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --format json
```
### 3. Send Notification Card
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
```
### 4. Render Weekly Report
```bash
gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
```
### 5. Generate Bitable Schema
```bash
gitlink-cli feishu +bitable-schema --format markdown
```
### 6. Generate Bitable Records
```bash
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
```
## Role-Aware Collaboration Direction
Owner digest:
```text
aggregate PR and workflow state
group PRs by review stage
send daily or weekly summary cards
link to Feishu Doc / Wiki full report
```
Contributor feedback:
```text
send fast notifications only for the contributor's own PR events
review comment
changes requested
needs rebase
approved
merged
closed
```
Planned color semantics:
```text
blue = new / unreviewed
green = close to merge
yellow = needs rebase
orange = major changes requested
red = blocked
grey = active review or closed
```
## Experimental DocX / Wiki Export
`+doc-export` is available for Feishu self-built app experiments. It is not part of the stable clean export path.
Preview:
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "https://example.feishu.cn/wiki/..." \
--format markdown
```
Write:
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "https://example.feishu.cn/wiki/..." \
--send \
--format table
```

View File

@ -0,0 +1,740 @@
# Codex Clean Task Chain: GitLink CLI Feishu Export
## Goal
Add a safe `feishu` shortcut module to `gitlink-cli`.
This task only implements:
```text
GitLink workflow JSON
-> Feishu card preview
-> Feishu bot send with explicit --send
-> weekly report
-> Bitable schema
-> Bitable-ready records
-> Agent Skill
```
## Boundaries
Do not implement:
```text
Feishu tasks
Feishu approval
callback server
button callbacks
GitLink remote writes
GitLink comments
Issue closure
code merge actions
direct GitLink webhook creation
real Bitable OpenAPI writes
Feishu Base creation
Feishu table creation
Feishu view creation
open_id person mapping
```
## Hard Rules
```text
1. Target repository: gitlink-cli.
2. Language: Go.
3. Do not add Ruby, Rails, Python, or Node services.
4. First implementation consumes local workflow JSON only.
5. Default behavior is local preview.
6. Real Feishu bot sending requires explicit --send.
7. If --send and --dry-run are both set, return an error.
8. Tests must use local fixtures or mock HTTP servers.
9. Tests must not depend on a real Feishu tenant, bot, token, or webhook.
10. Logs and errors must not print full webhook URLs or secrets.
11. Do not add non-engineering packaging language to code, docs, comments, or reports.
```
## Phase 0: Repository Scan
### Objective
Scan the current codebase and record implementation anchors.
### Read
```text
cmd/root.go
shortcuts/register.go
shortcuts/common/runner.go
shortcuts/common/types.go
shortcuts/workflow/workflow.go
shortcuts/workflow/repo_report.go
shortcuts/workflow/pr_summary.go
shortcuts/workflow/render.go
shortcuts/workflow/testdata/
internal/output/
skills/
docs/
examples/
go.mod
Makefile
.github/
```
### Create
```text
reports/FEISHU_IMPLEMENTATION_SCAN.md
```
### Required Content
```text
Repository anchors
Reusable workflow data and renderers
Reusable output behavior
Shortcut registration pattern
Test fixtures
Current test baseline
Implementation landing points
```
### Verification
```bash
go test ./shortcuts/workflow
go test ./shortcuts
```
Use this when the default Go proxy is unavailable:
```powershell
$env:GOPROXY='https://goproxy.cn,direct'
```
## Phase 1: Feishu Command Skeleton
### Objective
Add a `feishu` shortcut group with five commands. All commands must compile and support `--help`. No command sends network traffic by default.
### Add
```text
shortcuts/feishu/
feishu.go
options.go
redact.go
output.go
```
### Register
Update:
```text
shortcuts/register.go
```
Add:
```text
feishu -> feishu.Shortcuts()
```
Description:
```text
Export GitLink workflow data to Feishu cards and Bitable-ready records
```
### Commands
```bash
gitlink-cli feishu +bot-test
gitlink-cli feishu +notify
gitlink-cli feishu +weekly-report
gitlink-cli feishu +bitable-schema
gitlink-cli feishu +bitable-records
```
Do not add:
```text
+sync-bitable
+approval-create
+sync-tasks
+serve
+webhook-create
```
### Shared Flags
```text
--owner
--repo
--since
--include issues,prs,contributors,health
--format json|table|markdown
--lang zh-CN|en-US
--dry-run
--from-workflow-json
```
### Bot Flags
```text
--webhook-url
--secret
--send
```
### Bitable Preview Flags
```text
--tables issues,prs,contributors,reports
```
### Environment Variables
Read only:
```text
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET
```
Do not read in this task:
```text
FEISHU_TENANT_ACCESS_TOKEN
FEISHU_APP_ID
FEISHU_APP_SECRET
FEISHU_BASE_APP_TOKEN
FEISHU_ISSUE_TABLE_ID
FEISHU_PR_TABLE_ID
FEISHU_CONTRIBUTOR_TABLE_ID
FEISHU_REPORT_TABLE_ID
```
### Verification
```bash
gofmt -w shortcuts/feishu
go test ./shortcuts/feishu
go test ./shortcuts
```
Check help:
```bash
go run . feishu --help
go run . feishu +bot-test --help
go run . feishu +notify --help
go run . feishu +weekly-report --help
go run . feishu +bitable-schema --help
go run . feishu +bitable-records --help
```
## Phase 2: Options and Redaction
### Objective
Implement safe option loading, validation, and redaction.
### Files
```text
shortcuts/feishu/options.go
shortcuts/feishu/redact.go
shortcuts/feishu/options_test.go
shortcuts/feishu/redact_test.go
```
### Options
```go
type Options struct {
Owner string
Repo string
Since string
Include []string
Format string
Lang string
DryRun bool
Send bool
FromWorkflowJSON string
WebhookURL string
WebhookSecret string
Tables []string
}
```
### Functions
```go
func LoadOptions(ctx *common.RuntimeContext) (Options, error)
func ValidateCommonOptions(opts Options) error
func ValidateBotOptions(opts Options) error
func ValidateWorkflowJSONOptions(opts Options) error
func ValidateBitableRecordOptions(opts Options) error
func NormalizeInclude(value string) ([]string, error)
func NormalizeTables(value string) ([]string, error)
func ValidateSendMode(opts Options) error
```
### Send Mode
```text
Default: preview only.
--send: send Feishu bot message.
--send + --dry-run: error.
--send without webhook URL: error.
No --send: no HTTP request.
```
### Redaction
```go
func MaskSecret(value string) string
func MaskWebhookURL(value string) string
func MaskToken(value string) string
func MaskError(err error) string
```
Rules:
```text
secret -> ***
token -> ***
webhook URL -> scheme + host + final 4 path characters
empty string -> ""
short sensitive string -> ***
```
### Verification
```bash
gofmt -w shortcuts/feishu
go test ./shortcuts/feishu
```
## Phase 3: Bot Signature
### Objective
Implement Feishu custom bot signing.
### Files
```text
shortcuts/feishu/signer.go
shortcuts/feishu/signer_test.go
```
### Functions
```go
func BuildBotSign(timestamp int64, secret string) (string, error)
func BuildBotEnvelope(card map[string]any, secret string, now time.Time) (map[string]any, error)
```
### Behavior
```text
Empty secret: no timestamp/sign.
Non-empty secret: add timestamp/sign.
Timestamp: Unix seconds.
Errors must not include secret.
Tests use fixed timestamps.
```
## Phase 4: Card Builders
### Objective
Build Feishu interactive card JSON without network calls.
### Files
```text
shortcuts/feishu/model.go
shortcuts/feishu/card.go
shortcuts/feishu/card_test.go
```
### Models
```go
type ProjectActivity struct {
Repository string
Period string
IssueSummary SummaryBlock
PullRequestSummary SummaryBlock
ContributorSummary SummaryBlock
HealthSummary SummaryBlock
Risks []string
Recommendations []string
}
type SummaryBlock struct {
Title string
Count int
Items []string
}
type WeeklyReport struct {
Repository string
Period string
NewIssues int
ClosedIssues int
NewPullRequests int
MergedPullRequests int
ActiveContributors int
Risks []string
Recommendations []string
Markdown string
}
```
### Functions
```go
func BuildBotTestCard(lang string) map[string]any
func BuildProjectActivityCard(activity ProjectActivity, lang string) map[string]any
func BuildWeeklyReportCard(report WeeklyReport, lang string) map[string]any
```
### Requirements
```text
Use interactive card.
Support zh-CN and en-US text.
Handle empty data.
Do not include secrets or webhook URLs.
```
## Phase 5: Bot Webhook Client
### Objective
Send Feishu bot payloads through an injectable HTTP client.
### Files
```text
shortcuts/feishu/client.go
shortcuts/feishu/client_test.go
```
### API
```go
type Client struct {
HTTPClient *http.Client
}
func NewClient(httpClient *http.Client) *Client
func (c *Client) SendBotMessage(ctx context.Context, webhookURL string, payload any) error
```
### Behavior
```text
POST JSON.
2xx means success.
Non-2xx returns redacted error.
Limit response body read size.
Network errors are redacted.
Never print full webhook URL.
```
### Tests
Use `httptest.Server` for:
```text
200
400
429
500
network error
redaction
```
Do not implement Bitable OpenAPI client in this phase.
## Phase 6: Bot Test Command
### Objective
Preview or explicitly send a test card.
### Commands
Preview:
```bash
go run . feishu +bot-test --format json
```
Send:
```bash
go run . feishu +bot-test --webhook-url "$FEISHU_WEBHOOK_URL" --secret "$FEISHU_WEBHOOK_SECRET" --send
```
### Behavior
```text
Default preview.
Preview prints JSON.
--send sends through mockable client.
--send requires webhook URL.
Output shows only redacted target.
No GitLink data read.
No GitLink write.
```
## Phase 7: Workflow JSON Mapper
### Objective
Read local workflow JSON and map it into Feishu models.
### Files
```text
shortcuts/feishu/mapper.go
shortcuts/feishu/mapper_test.go
shortcuts/feishu/testdata/
repo_report.json
pr_summary.json
health.json
```
### Functions
```go
func LoadWorkflowJSON(path string) (map[string]any, error)
func MapWorkflowToProjectActivity(data map[string]any, opts Options) (ProjectActivity, error)
func MapWorkflowToWeeklyReport(data map[string]any, opts Options) (WeeklyReport, error)
```
### Rules
```text
Support workflow +repo-report JSON first.
Missing fields must not panic.
Unknown fields are ignored.
Do not duplicate workflow analysis logic.
Do not call GitLink remote APIs.
Do not change existing workflow behavior.
```
## Phase 8: Notify Command
### Objective
Create a project activity card from workflow JSON.
### Commands
Preview:
```bash
go run . feishu +notify --from-workflow-json report.json --include issues,prs,contributors,health --format json
```
Send:
```bash
go run . feishu +notify --from-workflow-json report.json --webhook-url "$FEISHU_WEBHOOK_URL" --secret "$FEISHU_WEBHOOK_SECRET" --send
```
### Behavior
```text
Requires --from-workflow-json.
Uses include filter.
Default output is JSON preview.
--send sends a card.
No GitLink write.
```
## Phase 9: Weekly Report Command
### Objective
Create a weekly report from workflow JSON.
### Commands
```bash
go run . feishu +weekly-report --from-workflow-json report.json --format markdown
go run . feishu +weekly-report --from-workflow-json report.json --format json
go run . feishu +weekly-report --from-workflow-json report.json --webhook-url "$FEISHU_WEBHOOK_URL" --secret "$FEISHU_WEBHOOK_SECRET" --send
```
### Behavior
```text
Default output is markdown.
JSON output returns structured WeeklyReport.
--send sends a Feishu card after local report generation.
No GitLink write.
```
## Phase 10: Bitable Schema
### Objective
Generate recommended Bitable schema. Do not connect to Feishu OpenAPI.
### Files
```text
shortcuts/feishu/schema.go
shortcuts/feishu/schema_test.go
```
### Command
```bash
go run . feishu +bitable-schema --tables issues,prs,contributors,reports --format markdown
go run . feishu +bitable-schema --tables issues,prs,contributors,reports --format json
```
### Tables
```text
Issues
Pull Requests
Contributors
Weekly Reports
```
### Rules
```text
Do not create Base.
Do not create tables.
Do not create fields.
Do not create views.
Do not call Feishu OpenAPI.
```
## Phase 11: Bitable Records
### Objective
Generate Bitable-ready records from workflow JSON. Output only.
### Files
```text
shortcuts/feishu/bitable.go
shortcuts/feishu/bitable_test.go
```
### Command
```bash
go run . feishu +bitable-records --from-workflow-json report.json --tables issues,prs,contributors,reports --format json
```
### Rules
```text
Requires --from-workflow-json.
Only outputs records.
Does not read app token.
Does not read table IDs.
Does not call Feishu OpenAPI.
Does not create, update, or upsert.
```
## Phase 12: Skill and Docs
### Add
```text
skills/gitlink-feishu/SKILL.md
docs/feishu-integration.md
docs/feishu-security.md
docs/feishu-bitable-schema.md
examples/feishu/
bot-test.md
notify.md
weekly-report.md
bitable-schema.md
bitable-records.md
```
### Content Rules
```text
Describe engineering usage only.
Default to preview examples.
Show --send only for bot cards.
State that Bitable output is local-only in this task.
Do not include non-engineering packaging language.
```
## Phase 13: Final Verification
### Commands
```bash
gofmt -w shortcuts/feishu
go test ./shortcuts/feishu
go test ./shortcuts/workflow
go test ./shortcuts
```
If dependencies are already available or a temporary proxy is set:
```bash
go test ./...
```
### Completion Report
Create:
```text
reports/FEISHU_TASK_COMPLETION.md
```
Include:
```text
implemented commands
changed files
preview behavior
explicit send behavior
mock test coverage
redaction checks
test results
excluded capabilities
```
## Final Acceptance
```text
feishu --help works.
+bot-test previews JSON.
+bot-test --send is covered by mock HTTP tests.
+notify reads workflow JSON and previews card JSON.
+notify --send is covered by mock HTTP tests.
+weekly-report reads workflow JSON and outputs markdown/json.
+bitable-schema outputs markdown/json.
+bitable-records outputs records JSON.
No output leaks full webhook URL or secret.
No Feishu send happens without --send.
No GitLink remote write exists.
No real Bitable write exists.
Targeted tests pass.
```

View File

@ -0,0 +1,126 @@
# Feishu Export Design Evaluation
## Current Baseline
Working directory:
```text
E:\GitLinkCLI-Competition\gitlink-cli-feishu-clean
```
Branch and base:
```text
feat/feishu-export-clean
origin/master ef7a2c6
```
Baseline tests:
```powershell
$env:GOPROXY='https://goproxy.cn,direct'; go test ./...
```
Result: passed.
The first default `go test ./...` attempt failed only because `proxy.golang.org` was unreachable for new dependencies. With a temporary Go proxy override, the latest master baseline is clean.
## Judgment
The v2 task chain is substantially better than the earlier version and is close to executable. It correctly narrows the feature into a safe one-way export module:
```text
workflow JSON -> local preview
workflow JSON -> Feishu bot card
workflow JSON -> weekly report
workflow JSON -> Bitable schema
workflow JSON -> Bitable-ready records
```
The remaining design adjustments are:
1. Keep the first implementation limited to local workflow JSON input.
2. Use `--send` as the only real Feishu bot send switch.
3. Use `prs` in flags and table keys, while displaying `Pull Requests` to users.
4. Replace any `sync-bitable` wording with `bitable-records`.
5. Do not implement real Bitable OpenAPI writes in this task.
6. Keep `doctor` out of the first implementation unless the other commands already exist.
After checking Feishu Open Platform documentation and the BotBuilder shutdown notice, add one more adjustment:
7. Add Feishu Docs export as the first real custom-app integration after the custom bot MVP.
Reason:
```text
Feishu custom bot = notification.
Feishu DocX = collaborative report artifact.
Bitable = structured tracking.
```
The previous design handled notification and Bitable dry-run, but did not use Feishu Docs. That makes the workflow less useful as a collaboration handoff.
## What Is Complete
The v2 task chain is complete enough for:
- Command skeleton design.
- Safety options and redaction design.
- Feishu bot card and signature design.
- Mocked webhook client design.
- Workflow JSON mapping design.
- Local weekly report generation.
- Bitable schema generation.
- Bitable-ready record generation.
- Agent Skill and documentation outline.
## What Is Not Closed Yet
The following should not be implemented in the first pass:
- Real Bitable record creation.
- Real Bitable record update.
- Bitable unique-key lookup through Feishu OpenAPI.
- Tenant token acquisition.
- Token cache behavior.
- Feishu Base/table/view creation.
- Any GitLink remote write.
The following should be designed before real Bitable writes:
- `feishu +doc-export`
- app_id/app_secret loading
- tenant_access_token fetch and cache
- document folder permission diagnostics
- DocX create-document and create-block behavior
These require a separate authentication and data consistency design.
## Practical Utility
The design is useful in a real CLI workflow:
1. A user generates a workflow report with the existing `workflow +repo-report` command.
2. The new `feishu` command consumes that JSON file.
3. The user previews a card, weekly report, schema, or records locally.
4. Only when `--send` is explicit does the CLI send a Feishu bot message.
This produces a small, testable feature that is useful without requiring Feishu enterprise app credentials.
The stronger practical workflow is:
```text
workflow JSON -> markdown report -> Feishu DocX -> Feishu bot card with doc link -> Bitable dry-run records
```
That path gives users both an immediate group notification and a persistent collaborative report.
## Implementation Scope Rating
```text
Direction: strong
Safety boundary: strong
Repository fit: strong
First implementation size: acceptable after removing real Bitable writes
Direct executability: good after using the clean task chain in this directory
```

View File

@ -0,0 +1,273 @@
# Feishu Official Docs Alignment
## Sources Checked
- Custom bot usage guide: https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
- Send message cards with custom bot: https://open.feishu.cn/document/feishu-cards/quick-start/send-message-cards-with-custom-bot?lang=zh-CN
- Custom app tenant access token: https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN
- Send message API: https://open.feishu.cn/document/server-docs/im-v1/message/create?lang=zh-CN
- Create DocX document: https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/create
- Create DocX blocks: https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document-block/create?lang=zh-CN
- Bitable create record: https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/create?lang=zh-CN
- Bitable batch create records: https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/batch_create?lang=zh-CN
- Docs token FAQ: https://open.feishu.cn/document/faq/trouble-shooting/how-to-get-docs-tokens
- Docs permission FAQ: https://open.feishu.cn/document/server-docs/docs/faq?lang=zh-CN
## Important Product Boundary
The BotBuilder shutdown notice does not affect this design if the implementation uses:
```text
Feishu Open Platform custom bot webhooks
Feishu Open Platform custom app APIs
Feishu Docs / Bitable OpenAPI
```
Do not integrate:
```text
botbuilder.feishu.cn
Feishu Robot Assistant workflows
```
## Correct Integration Modes
### Mode A: Custom Group Bot Webhook
Use this for the first working proof.
Required inputs:
```text
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET optional
```
Capabilities:
```text
Send one-way group notifications.
Send interactive card JSON to a group.
No tenant token.
No app_id/app_secret.
No user, tenant, document, or Bitable data access.
```
Fit in this project:
```text
feishu +bot-test
feishu +notify
feishu +weekly-report --send
```
### Mode B: Open Platform Custom App
Use this for real document and Bitable operations.
Required inputs:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
```
Token flow:
```text
POST /open-apis/auth/v3/tenant_access_token/internal
request: app_id + app_secret
response: tenant_access_token, expire
```
Required implementation:
```text
Token client
token cache with expiry
redacted errors
permission diagnostics
mocked HTTP tests
```
Fit in this project:
```text
Phase 2: feishu +doc-export
Phase 3: feishu +bitable-sync or +bitable-upsert
Optional: app bot message send through im/v1/messages
```
### Mode C: Low-Code Alternatives
Multidimensional table workflows, Aily, and AnyCross are valid migration choices for BotBuilder users, but they are not a good first implementation target inside `gitlink-cli`.
Use them as documentation references only.
## Recommended Product Flow
The practical GitLink-to-Feishu workflow should be:
```text
1. gitlink-cli workflow +repo-report --format json > report.json
2. gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
3. gitlink-cli feishu +doc-export --from-workflow-json report.json --folder-token <folder_token> --send
4. gitlink-cli feishu +notify --from-workflow-json report.json --doc-url <doc_url> --send
5. gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
6. Later: gitlink-cli feishu +bitable-sync --from-workflow-json report.json --send
```
Key point:
```text
Card = notification.
Doc = collaboration artifact.
Bitable = structured tracking data.
```
The earlier design covered card and Bitable dry-run, but missed the document artifact.
## Doc Export Requirements
Add a later `feishu +doc-export` command.
Inputs:
```text
--from-workflow-json report.json
--folder-token <folder_token>
--document-id <document_id> optional later
--wiki-url <wiki_url> optional later
--wiki-node-token <node_token> optional later
--title <title>
--send
```
Environment:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
```
Behavior:
```text
Default preview only.
--send creates or updates a Feishu DocX document.
Create document first.
Then create blocks under the document root block.
Return document_id and URL.
No document operation without --send.
```
Permission notes:
```text
The app must have required DocX/Drive application scopes.
The target folder or document must grant the app document permission.
folder_token/document_id/app_token must be read from URL or OpenAPI.
```
## Knowledge Base / Wiki Fit
Knowledge Base pages are useful for project showcase and reference material.
The supplied project page shape:
```text
https://<tenant>.feishu.cn/wiki/<node_token>
```
Official API flow:
```text
1. Get tenant_access_token with app_id/app_secret.
2. Resolve wiki node token with Wiki API.
3. If obj_type is docx, use obj_token as the DocX document target.
4. Export or append report blocks with DocX block APIs.
5. Send a Feishu bot card with the wiki/doc URL as the collaboration entry.
```
Design impact:
```text
Add wiki-url/wiki-node-token support to doc-export.
Add --doc-url to notify/weekly-report card commands.
Keep Wiki operations behind --send.
Do not edit knowledge base permissions automatically.
```
This makes the project output more suitable for display:
```text
Knowledge Base page = project homepage / reference index.
DocX report blocks = generated workflow report.
Bot card = notification and entry link.
Bitable records = structured data for later dashboards.
```
Observed permission behavior:
```text
tenant_access_token acquisition succeeded.
Wiki get_node succeeded.
DocX create-block failed with HTTP 403 / code 1770032 / forBidden.
```
This means the design must include explicit permission diagnostics:
```text
The self-built app must have both approved DocX/Drive scopes and write access to the target Wiki/DocX page or folder.
```
## Bitable Real Write Requirements
Keep current `+bitable-schema` and `+bitable-records` as dry-run commands.
Only add real write after the app auth layer exists.
Required inputs:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
FEISHU_BASE_APP_TOKEN
FEISHU_REPORT_TABLE_ID
FEISHU_ISSUE_TABLE_ID
FEISHU_PR_TABLE_ID
FEISHU_CONTRIBUTOR_TABLE_ID optional
```
Required behavior:
```text
Fetch tenant_access_token.
Validate table IDs.
Create records or batch create records.
For update/upsert, search existing records first.
Do not call Bitable OpenAPI unless --send is explicit.
```
## Design Verdict
Current design is reasonable as a first safe MVP:
```text
custom bot send
workflow JSON local input
weekly report markdown
Bitable schema/records dry-run
mock tests
```
But it is incomplete for a "Feishu collaboration export" feature because it does not create or update Feishu Docs.
Required design adjustment:
```text
Keep custom bot as the low-friction stable smoke test path.
Keep Bitable as dry-run in the stable path.
Treat doc-export with DocX/Wiki support as experimental because it uses self-built app OpenAPI and document write permissions.
Keep real Bitable writes out of scope.
```

View File

@ -0,0 +1,899 @@
# Role-Based Feishu Collaboration Design
## Purpose
Extend the Feishu export workflow from a simple report sender into a role-aware collaboration layer.
The product should not notify every maintainer about every Pull Request event. It should separate the two communication needs:
```text
Owner / maintainer: periodic, summarized, prioritized project state.
Contributor: immediate, personal feedback for work they own.
```
This keeps Feishu useful as a collaboration surface instead of turning it into a noisy event stream.
## Role Model
### Owner View
Owners need batch summaries and decision support.
Default owner delivery:
```text
daily digest
weekly report
milestone status
review queue summary
high-risk PR summary
stale contribution summary
```
Owners should receive:
```text
repository status
new contributor activity
PRs grouped by review stage
PRs blocked by conflicts or required rebase
PRs close to merge
PRs needing owner decision
review coverage and stale review data
links to Feishu Doc / Wiki pages for full context
```
Owners should not receive by default:
```text
one message for every new PR
one message for every comment
one message for every patchset push
one message for every review reply
```
### Contributor View
Contributors need immediate feedback on their own work.
Default contributor delivery:
```text
review comment received
review status changed
changes requested
rebase required
merge conflict detected
CI or quality gate failed
PR approved
PR merged
PR refused or closed
maintainer requested more information
```
Contributor notifications should be personal and direct where Feishu identity mapping is available. If open_id mapping is not configured, the system should fall back to repository-level cards or dry-run output.
## Event Strategy
### Owner Events
Owner notifications are aggregation jobs, not raw events.
Recommended command shape:
```bash
gitlink-cli feishu +owner-digest \
--owner <owner> \
--repo <repo> \
--period daily \
--webhook-url "$FEISHU_WEBHOOK_URL" \
--send
```
Alternative input-only flow:
```bash
gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json > report.json
gitlink-cli feishu +owner-digest --from-workflow-json report.json --send
```
The first implementation should prefer the input-only flow. Direct GitLink collection can come later once the data model is stable.
### Contributor Events
Contributor notifications can be real-time if GitLink has webhooks, or near-real-time through polling.
Recommended command shape:
```bash
gitlink-cli feishu +contributor-notify \
--from-event-json event.json \
--send
```
Polling shape:
```bash
gitlink-cli feishu +contributor-watch \
--owner <owner> \
--repo <repo> \
--interval 5m \
--state-file .gitlink-feishu-state.json \
--send
```
The state file records delivered event IDs so repeated polling does not resend old notifications.
## Feishu Channel Matrix
Different Feishu surfaces should not be used for the same job.
| Surface | Best Fit | Delivery Style | Current Status |
| --- | --- | --- | --- |
| Custom group bot webhook | Owner digest, weekly report, project status card | One-way group card | Implemented for current notify/report path |
| Self-built app IM API | Personal contributor notifications | Direct message or mention | Future, needs open_id mapping and IM scopes |
| DocX / Wiki | Long-form report, README mirror, project knowledge base | Persistent document | Experimental `+doc-export` exists |
| Bitable records | PR stage table, milestones, dashboard source | Structured rows | Dry-run records only |
| Bitable Gantt view | Milestone timeline | Visual project planning | Manual view first, OpenAPI later |
| Feishu AI summary / weekly report | Summarize generated docs and records | Feishu-side automation | Do not depend on private API |
Practical rule:
```text
Card = attention.
Doc / Wiki = context.
Bitable = structured state.
Gantt = milestone visualization.
AI summary = Feishu-side value added on top of structured content.
```
## Scheduling Model
Owner notifications should be scheduled. Contributor notifications should be event-driven where possible.
Owner cadence:
```text
daily: review queue and blocked items
weekly: contributor activity, milestone progress, risk trend
on demand: full report preview or manual send
```
Contributor cadence:
```text
immediate: review/comment/merge/rebase/conflict events
debounced: repeated comments in the same PR within a short window
digest fallback: if personal identity mapping is missing
```
Recommended debounce rule:
```text
If the same contributor receives multiple comments on the same PR within 10 minutes,
combine them into one notification with a count and latest link.
```
This avoids replacing owner spam with contributor spam.
## Pull Request Stage Colors
Feishu cards should use color as a stage signal, not as decoration.
Default stage rules:
| Stage | Card Color | Meaning | Typical Inputs |
| --- | --- | --- | --- |
| `new` | blue | New PR, not reviewed yet | no reviews, one patchset, recently opened |
| `active-review` | grey | Review is active, no clear risk yet | comments or common reviews exist |
| `near-ready` | green | Small gap to merge | approved or low-risk review, no conflict, checks pass |
| `needs-rebase` | yellow | Contributor action needed before review can continue | base branch changed, conflict, stale branch, merge check failed |
| `major-changes` | orange | Larger change request or high-risk delta | rejected review, large diff, missing tests, repeated review cycles |
| `blocked` | red | Owner or platform action needed | permission issue, failing required checks, unresolved dependency |
| `merged` | green | Completed | merged status |
| `closed` | grey | Closed without merge | closed/refused status |
The first stable implementation should support defaults only. User customization can be added as a config file after the stage model is proven.
Recommended config shape:
```yaml
feishu:
pr_stages:
near_ready:
color: green
max_unresolved_comments: 2
require_no_conflicts: true
needs_rebase:
color: yellow
require_mergeable: false
major_changes:
color: orange
min_review_rounds: 2
high_risk_labels:
- missing-tests
- large-diff
```
## Review Degree Model
The stage calculation should be explainable. A card should not only show a color; it should also show why the PR is in that stage.
Recommended derived fields:
```text
review_rounds
patchset_count
last_review_status
unresolved_comment_count
requested_changes_count
approved_count
changed_files_count
additions
deletions
mergeable
needs_rebase
ci_status
last_activity_at
```
GitLink anchors already available in the CLI ecosystem:
```text
pr +list
pr +view
pr +reviews
pr +versions
pr +files
```
The design should avoid scraping pages. It should use existing GitLink APIs or existing CLI JSON outputs.
## Stage Classification Order
The stage classifier should be deterministic. Later rules should not override higher-priority terminal or blocking states.
Recommended order:
```text
1. merged
2. closed
3. blocked
4. needs-rebase
5. major-changes
6. near-ready
7. active-review
8. new
```
Classification logic:
```text
merged:
pull_request_status == merged
closed:
pull_request_status == closed/refused
blocked:
required check failed, permission issue, unresolved dependency, or owner-defined blocked label
needs-rebase:
mergeable == false, conflict exists, stale base branch, or merge check says rebase is required
major-changes:
last review rejected, requested_changes_count > 0, large diff threshold exceeded, or repeated review rounds
near-ready:
approved_count > 0, no conflict, no requested changes, low remaining risk
active-review:
common review/comment exists, patchset_count > 1, or maintainer has interacted
new:
no review, no maintainer interaction, recently opened
```
Every stage output should include `reasons`:
```json
{
"stage": "needs-rebase",
"color": "yellow",
"reasons": [
"merge check failed",
"base branch changed after latest patchset"
]
}
```
## Data Contracts
The role-aware extension should accept local JSON first. This keeps tests deterministic and avoids changing existing GitLink network behavior.
### Owner Digest Input
Recommended minimal JSON:
```json
{
"repository": "Gitlink/gitlink-cli",
"period": {
"start": "2026-06-09",
"end": "2026-06-16"
},
"pull_requests": [
{
"number": 123,
"title": "feat: add export flow",
"author": "contributor-a",
"url": "https://www.gitlink.org.cn/org/repo/pulls/123",
"status": "open",
"stage": "needs-rebase",
"color": "yellow",
"reasons": ["merge check failed"],
"review_rounds": 2,
"patchset_count": 3,
"last_activity_at": "2026-06-16T10:30:00+08:00"
}
],
"milestones": [],
"contributors": []
}
```
### Owner Digest Output
Recommended output:
```json
{
"repository": "Gitlink/gitlink-cli",
"period_label": "2026-06-09 to 2026-06-16",
"stage_counts": {
"near-ready": 3,
"needs-rebase": 2,
"major-changes": 1,
"new": 4
},
"top_actions": [
"Review 4 new PRs",
"Ask 2 contributors to rebase",
"Merge 3 near-ready PRs"
],
"doc_url": "https://example.feishu.cn/wiki/...",
"dry_run": true
}
```
### Contributor Event Input
Recommended minimal event JSON:
```json
{
"event_id": "repo-pr-123-review-456",
"event_type": "review_comment",
"repository": "Gitlink/gitlink-cli",
"pr": {
"number": 123,
"title": "feat: add export flow",
"url": "https://www.gitlink.org.cn/org/repo/pulls/123",
"author": "contributor-a"
},
"actor": "maintainer-a",
"recipient_gitlink_user": "contributor-a",
"summary": "Maintainer requested changes in the export options.",
"required_action": "Update the PR and push a new patchset.",
"created_at": "2026-06-16T10:30:00+08:00"
}
```
### Contributor Notification Output
Recommended output:
```json
{
"event_id": "repo-pr-123-review-456",
"recipient_gitlink_user": "contributor-a",
"recipient_feishu_id": "",
"delivery_mode": "dry-run",
"card_title": "PR feedback received",
"required_action": "Update the PR and push a new patchset.",
"dry_run": true
}
```
If `recipient_feishu_id` is empty, direct personal delivery must not be attempted.
## Owner Digest Card
Owner digest cards should be compact and action-oriented.
Recommended sections:
```text
1. Repository and period
2. Review queue by stage
3. Near-ready PRs
4. PRs needing rebase
5. High-risk or major-change PRs
6. New contributors
7. Stale PRs
8. Milestone progress
9. Link to Feishu Wiki / Doc full report
```
Example card semantics:
```text
Header: GitLink Owner Digest
Green section: 3 PRs close to merge
Yellow section: 2 PRs need rebase
Orange section: 1 PR needs major changes
Grey section: 4 new/unreviewed PRs
Button: Open Feishu report
Button: Open GitLink PR queue
```
The owner card should cap inline PR rows. A full report belongs in Feishu Doc / Wiki.
Recommended card limits:
```text
maximum stage groups shown: 5
maximum PR rows per stage: 3
maximum total inline PR rows: 10
always include full report link when available
```
If the owner digest exceeds the inline limits, the card should say how many rows are hidden and link to the Doc / Wiki report.
## Contributor Notification Card
Contributor cards should be immediate and specific.
Recommended sections:
```text
1. PR title and repository
2. Event type
3. Reviewer or actor
4. Required action
5. Short feedback summary
6. Link to PR
7. Link to Feishu reference doc if relevant
```
Example event mapping:
| Event | Card Intent |
| --- | --- |
| review comment | Read maintainer feedback |
| rejected review | Modify PR according to requested changes |
| approved review | Wait for merge or owner decision |
| merged | Contribution accepted |
| needs rebase | Rebase branch before further review |
| conflict | Resolve merge conflict |
Contributor delivery requires identity mapping:
```text
GitLink username -> Feishu open_id / union_id / email
```
Until that mapping exists, the CLI should generate dry-run records instead of attempting direct personal delivery.
## Identity Mapping
Identity mapping is a separate concern from PR analysis.
Supported mapping sources, in priority order:
```text
1. explicit local mapping file
2. Bitable mapping table
3. email match from GitLink user profile and Feishu directory
4. no mapping, dry-run only
```
First implementation should only support the local file:
```yaml
contributors:
contributor-a:
feishu_open_id: ou_xxx
display_name: Contributor A
contributor-b:
email: contributor-b@example.com
```
Validation rules:
```text
mapping file is optional
missing mapping downgrades to dry-run
mapping values are redacted in logs
the CLI does not call Feishu directory APIs in the first pass
```
## Feishu Docs / Wiki
Feishu Docs and Wiki should be treated as the long-form project artifact.
Recommended generated content:
```text
project overview
README summary
contribution guide summary
review policy
milestone plan
daily or weekly owner digest archive
PR stage table
high-risk change notes
```
README export should not replace the repository README. It should produce a Feishu-readable version for maintainers and contributors.
Recommended command shape:
```bash
gitlink-cli feishu +readme-doc \
--owner <owner> \
--repo <repo> \
--wiki-url "<wiki_url>" \
--send
```
Permission boundary:
```text
The owner configures Feishu app scopes and document permissions.
The CLI never changes Feishu document permissions automatically.
The CLI prints permission diagnostics when write access fails.
```
This matches the current `+doc-export` boundary and avoids hidden permission changes.
## README and Knowledge Base Export
The README export should be deterministic and conservative.
Recommended sections:
```text
1. Project title
2. Short repository summary
3. Quick start
4. Contribution workflow
5. Review policy
6. Current milestones
7. Current owner digest link
8. Source repository links
```
The command should accept local files before remote reads:
```bash
gitlink-cli feishu +readme-doc \
--from-readme README.md \
--from-contributing CONTRIBUTING.md \
--wiki-url "<wiki_url>" \
--format markdown
```
Later remote mode can use GitLink repository file APIs:
```bash
gitlink-cli feishu +readme-doc \
--owner <owner> \
--repo <repo> \
--ref master \
--wiki-url "<wiki_url>"
```
Doc write behavior:
```text
preview by default
--send required for document writes
append or update target must be explicit
do not change sharing settings
return permission diagnostics on 403
```
## Milestones and Gantt
Gantt-style planning belongs to milestone tracking, not raw notification cards.
Recommended data model:
```text
milestone_id
milestone_title
start_date
due_date
status
linked_issues
linked_prs
owner
progress_percent
risk_level
```
Feishu implementation options:
```text
Doc / Wiki: milestone narrative and current status.
Bitable records: structured milestone rows.
Bitable Gantt view: created manually by owner at first.
Later OpenAPI sync: update milestone rows after table IDs are configured.
```
The first implementation should only generate milestone-ready records and Doc content. Automatic Bitable view creation should remain out of scope until real Bitable writes are implemented.
Recommended Bitable milestone fields:
```text
milestone_key
repository
title
owner
start_date
due_date
status
progress_percent
risk_level
linked_prs
linked_issues
last_updated_at
```
Manual Gantt setup:
```text
1. Owner creates a Bitable table using generated schema.
2. Owner imports generated milestone records.
3. Owner creates a Gantt view from start_date and due_date.
4. Later CLI sync updates rows, not views.
```
## Feishu AI Summary Fit
The CLI should not depend on a private Feishu AI summary API for the first implementation.
Instead, the CLI should generate structured Feishu Docs and cards that are easy for Feishu-side summary, daily report, weekly report, and knowledge-base features to consume.
Practical split:
```text
gitlink-cli: collect, normalize, stage, render, send.
Feishu: summarize, archive, search, collaborate, display.
Owner: configure permissions, choose digest schedule, tune stage rules.
```
## Configuration File
A future config file should keep project policy out of command-line flags.
Recommended path:
```text
.gitlink-feishu.yaml
```
Recommended shape:
```yaml
repository: Gitlink/gitlink-cli
owner_digest:
enabled: true
cadence: weekly
webhook_env: FEISHU_WEBHOOK_URL
doc_url_env: FEISHU_PROJECT_DOC_URL
inline_limit: 10
contributor_notifications:
enabled: true
delivery: dry-run
identity_mapping: .gitlink-feishu-users.yaml
debounce_window: 10m
pr_stage_rules:
near_ready:
color: green
require_approved: true
require_no_conflicts: true
needs_rebase:
color: yellow
require_mergeable: false
major_changes:
color: orange
min_review_rounds: 2
min_changed_files: 20
docs:
wiki_url_env: FEISHU_PROJECT_WIKI_URL
readme_sources:
- README.md
- CONTRIBUTING.md
milestones:
enabled: true
records_only: true
```
Rules:
```text
environment variable names may be stored
secret values must not be stored
unknown config keys should warn, not crash
invalid stage colors should fail validation
```
## Implementation Phases
### Phase A: Role-Aware Dry Run
Add local outputs only:
```text
owner digest model
contributor event model
PR stage model
default stage color rules
milestone record model
README-to-doc preview model
```
Commands:
```text
feishu +owner-digest --from-workflow-json
feishu +pr-stage-report --from-pr-json
feishu +contributor-events --from-event-json
feishu +readme-doc --from-readme
```
No GitLink writes. No Feishu writes by default.
Acceptance:
```text
owner digest JSON is stable
PR stage classification is deterministic
card color is derived from stage
missing optional fields do not panic
large input is capped in card preview
all tests use fixtures
```
### Phase B: Owner Digest Send
Enable bot cards for aggregated owner summaries:
```text
feishu +owner-digest --send
```
Use custom bot webhook, same safety model as current `+notify`.
Acceptance:
```text
--send is required for webhook delivery
--send without webhook URL fails
--send --dry-run fails
webhook URL is redacted
mock HTTP tests cover 200, 400, 429, and 500
```
### Phase C: Contributor Direct Notifications
Add contributor delivery after identity mapping exists:
```text
GitLink username -> Feishu user ID
```
Supported delivery modes:
```text
custom group bot mention
self-built app IM message
dry-run only if identity mapping is missing
```
Acceptance:
```text
missing identity mapping downgrades to dry-run
direct message mode requires explicit --send
recipient IDs are redacted in logs
event_id/state prevents duplicate delivery
debounce behavior is covered by tests
```
### Phase D: Docs / Wiki Project Space
Extend experimental document export:
```text
README summary
owner digest archive
milestone page
PR stage table
```
Keep all document writes behind `--send`.
Acceptance:
```text
README preview renders markdown
DocX/Wiki write requires app credentials and --send
403 errors include permission diagnostics
document permission changes are not attempted
```
### Phase E: Milestone / Gantt Data
Generate milestone-ready Bitable records first.
Real Bitable sync can follow only after:
```text
tenant token flow
table IDs
unique keys
upsert behavior
permission diagnostics
partial failure handling
```
Acceptance:
```text
milestone records include stable unique keys
records are usable for manual Bitable import
no Bitable OpenAPI calls happen without explicit send behavior
Gantt view creation remains manual in this phase
```
## Non-Goals
Do not implement in the first role-aware extension:
```text
automatic GitLink merge
automatic GitLink review
automatic GitLink comments
automatic Feishu permission changes
automatic Bitable view creation
direct dependency on BotBuilder
notification spam for every owner-visible event
```
## Design Verdict
This direction is stronger than a raw PR event notifier.
The product should be:
```text
Owner: Feishu digest and knowledge-base workspace.
Contributor: immediate personal feedback loop.
Project: Docs/Wiki for long-form context, Bitable/Gantt for milestone tracking.
```
The current implementation already supports the lowest-risk part:
```text
workflow JSON -> Feishu card / weekly report / Doc link / Bitable-ready records
```
The next useful design step is to add role-aware models and dry-run outputs before adding new Feishu or GitLink network behavior.

View File

@ -0,0 +1,440 @@
# Feishu Export Technical Plan
## Latest Master Anchors
Current command registration:
```text
shortcuts/register.go
```
Add imports:
```go
github.com/gitlink-org/gitlink-cli/shortcuts/feishu
```
Add group:
```go
"feishu": feishu.Shortcuts(),
```
Add description:
```go
"feishu": "Export GitLink workflow data to Feishu cards and Bitable-ready records",
```
Shortcut mounting:
```text
shortcuts/common/runner.go
```
Important behavior:
- Boolean flags are read from Cobra as strings in `RuntimeContext.Args`.
- A bool flag with default `true` cannot tell whether the user explicitly set it. Therefore `--send` should be the positive action flag, and `--dry-run` should remain a preview indicator.
- If implementation must detect explicit `--dry-run`, it needs a command-specific Cobra command instead of the generic shortcut runner. Avoid that for the first implementation.
## Send Flag Decision
Use this rule:
```text
--send=false or omitted: preview only.
--send=true and dry-run=false: send.
--send=true and dry-run=true: error.
```
Since generic shortcut flags cannot detect whether `--dry-run` was explicitly passed, set `--dry-run` default to `false` in Feishu commands and treat preview as `!Send`. This avoids a default conflict where `--send` would always collide with default `--dry-run=true`.
Effective mode:
```go
Preview := !opts.Send
```
Validation:
```go
if opts.Send && opts.DryRun {
return error
}
if opts.Send && opts.WebhookURL == "" {
return error
}
```
User-facing meaning:
```text
No --send: preview.
--dry-run: preview and forbid send.
--send: real bot send.
--send --dry-run: invalid.
```
## Output Strategy
Do not extend `internal/output` in the first implementation.
Reason:
- Global output supports `json`, `yaml`, and generic `table`.
- Workflow uses local renderers for markdown.
- Feishu needs markdown for weekly reports and schema docs.
Implement local Feishu render helpers:
```go
func renderJSON(w io.Writer, value any) error
func renderMarkdown(w io.Writer, value any) error
func renderTable(w io.Writer, value any) error
func normalizeFormat(format string, defaultFormat string) string
```
Default formats:
```text
+bot-test: json
+notify: json
+weekly-report: markdown
+bitable-schema: markdown
+bitable-records: json
```
## Workflow JSON Input
First implementation only supports local JSON files.
Expected source:
```bash
gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json > report.json
```
Mapping should accept both shapes if found:
1. Raw `RepoReportResult`.
2. Envelope-like object with `data`.
Fields to use:
```text
repository
health.health_score
health.risk_level
issue_summary.total
issue_summary.high_risk
issue_summary.missing_info
pr_summary.total
pr_summary.high_risk
pr_summary.review_focus
recommendations
risk_level
report_score
source
```
Do not import unexported workflow readers. Use JSON mapping to avoid changing workflow internals.
## Feishu Signature
Use Feishu custom bot signing:
```text
string_to_sign = timestamp + "\n" + secret
sign = base64(hmac_sha256(string_to_sign, secret))
```
Implementation notes:
- `timestamp` is Unix seconds and serialized as a string.
- Empty secret means no timestamp/sign.
- Tests use fixed timestamp.
- Errors must be redacted.
## Feishu Card Shape
Use custom bot interactive card payload:
```json
{
"msg_type": "interactive",
"card": {
"header": {
"title": {
"tag": "plain_text",
"content": "GitLink Project Activity"
}
},
"elements": []
}
}
```
Envelope with signature:
```json
{
"timestamp": "1710000000",
"sign": "redacted in logs",
"msg_type": "interactive",
"card": {}
}
```
## HTTP Client
Use package-local client:
```go
type Client struct {
HTTPClient *http.Client
}
```
Use `httptest.Server` for all tests.
Do not use `internal/client.Client` for Feishu because it appends GitLink `.json` suffixes and injects GitLink auth.
## Naming
CLI flags:
```text
prs
--include issues,prs,contributors,health
--tables issues,prs,contributors,reports
```
User-facing labels:
```text
Pull Requests
```
Internal model names:
```text
PullRequestSummary
NewPullRequests
MergedPullRequests
```
Do not use `pulls` in new user-facing flags.
## Bitable Scope
First implementation:
```text
+bitable-schema
+bitable-records
```
No Feishu OpenAPI calls.
No environment variables for app tokens or table IDs.
No tenant token.
No create/update/upsert.
The records command outputs local JSON only:
```go
type BitableRecordsOutput struct {
Repository string `json:"repository"`
Tables []BitableTableRecords `json:"tables"`
}
type BitableTableRecords struct {
Table string `json:"table"`
Records []BitableRecord `json:"records"`
}
type BitableRecord struct {
UniqueKey string `json:"unique_key"`
Fields map[string]any `json:"fields"`
}
```
## Feishu Docs Scope
Official Feishu Open Platform docs show that cloud document integration belongs to the self-built app flow, not the custom bot flow.
Experimental command:
```text
feishu +doc-export
```
Inputs:
```text
--from-workflow-json
--folder-token
--document-id optional later
--wiki-url optional later
--wiki-node-token optional later
--title
--send
```
Environment:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
```
API flow:
```text
1. POST /open-apis/auth/v3/tenant_access_token/internal
2. Optional: GET /open-apis/wiki/v2/spaces/get_node?token=<wiki_node_token>
3. POST /open-apis/docx/v1/documents
4. POST /open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children
```
Implementation notes:
- Default remains preview only.
- `--send` is required before creating or updating a document.
- The app must have both application scopes and document/folder-level permission.
- The command should return a document ID and URL for `+notify --doc-url`.
- If `--wiki-url` or `--wiki-node-token` is provided, resolve the wiki node first and use the underlying `docx` object token when possible.
- Mock all HTTP tests.
- Do not implement document sharing or permission changes in the first doc export pass.
Stable product flow:
```text
workflow +repo-report -> feishu +weekly-report -> feishu +notify --doc-url -> feishu +bitable-records
```
Experimental product flow:
```text
workflow +repo-report -> feishu +doc-export --wiki-url -> feishu +notify --doc-url -> feishu +bitable-records
```
DocX export should remain clearly marked as experimental until tenant permissions, scopes, and document-write behavior are stable.
## Role-Based Collaboration Extension
The next design layer is role-aware delivery, documented in:
```text
feishu-export-design/ROLE_BASED_COLLABORATION.md
```
Core split:
```text
Owner / maintainer: periodic aggregated digest, not per-event spam.
Contributor: immediate personal feedback for PR comments, reviews, rebase needs, and merge results.
```
Owner-facing features should build on aggregated workflow and PR data:
```text
daily digest
weekly report
review queue summary
PR stage color report
milestone status
Feishu Doc / Wiki full report link
```
Contributor-facing features need a separate event model:
```text
review comment received
changes requested
needs rebase
conflict detected
approved
merged
closed or refused
```
PR stage colors should be derived from explainable inputs such as review status, patchset count, mergeability, requested changes, and stale activity:
```text
blue: new / unreviewed
grey: active review
green: near ready or merged
yellow: needs rebase
orange: major changes requested
red: blocked
```
The first role-aware extension should stay local and dry-run by default:
```text
feishu +owner-digest --from-workflow-json
feishu +pr-stage-report --from-pr-json
feishu +contributor-events --from-event-json
feishu +readme-doc --from-readme
```
Do not add real-time contributor delivery until identity mapping is designed:
```text
GitLink username -> Feishu open_id / union_id / email
```
Feishu Docs / Wiki should hold long-form project context:
```text
README summary
contribution guide summary
owner digest archive
milestone plan
PR stage table
```
The owner configures Feishu permissions. The CLI must not change document permissions automatically.
## Tests To Add
```text
shortcuts/feishu/options_test.go
shortcuts/feishu/redact_test.go
shortcuts/feishu/signer_test.go
shortcuts/feishu/card_test.go
shortcuts/feishu/client_test.go
shortcuts/feishu/mapper_test.go
shortcuts/feishu/schema_test.go
shortcuts/feishu/bitable_test.go
shortcuts/feishu/commands_test.go
```
Minimum assertions:
```text
No --send means no HTTP.
--send means HTTP in mock tests.
--send --dry-run errors.
Missing webhook URL with --send errors.
Errors redact webhook URL and secret.
Workflow JSON can be loaded from fixture.
Missing fields do not panic.
Weekly report markdown is stable.
Bitable schema JSON is parseable.
Bitable records JSON is parseable.
```
## Baseline Verification
Current latest master passes with:
```powershell
$env:GOPROXY='https://goproxy.cn,direct'; go test ./...
```
Keep this as the baseline before implementation.

View File

@ -0,0 +1,204 @@
# Feishu API Collection Checklist
Date: 2026-06-26
Branch:
```text
feat/feishu-export-clean
```
Current commit at collection time:
```text
73da46c143b37cb2b26e9e624b8c39963ad52d77
```
## Current Local State
```text
Feishu desktop/web state: test account is logged in.
Local env file: .local/feishu-gitlink.env.ps1 is configured and ignored.
Stable previews: available from .local/report.json and .local/report.zh-CN.json.
Real Feishu sends: passed through custom bot webhook.
Real DocX append: passed through self-built app OpenAPI.
Real Bitable sync: passed after target table fields were created.
Split Bitable sync: passed with five separate tables.
Real Task create: passed; project/section placement remains unmapped.
Read/check diagnostics: passed for app, DocX target, Bitable tables, and Task credentials.
GitLink write operations: not implemented and not tested.
Test enterprise permissions: intentionally broad for validation; production should use minimum scopes.
```
## API Collection Status
| Item | Status | Evidence | Next action |
| --- | --- | --- | --- |
| Custom bot webhook | Complete and real-tested | `shortcuts/feishu/client.go`, `sign.go`, `card.go` | Image evidence deferred |
| Custom bot signing | Complete and real-tested | `SignCustomBotRequest` unit test plus signed bot smoke | Keep secrets redacted |
| tenant_access_token | Complete and real-tested | `OpenAPIClient.TenantAccessToken`, `+app-check --remote` | Add richer scope hints later |
| Wiki node resolution | Complete | `OpenAPIClient.GetWikiNode` | Still depends on target Wiki node permission |
| DocX create | Complete | `OpenAPIClient.CreateDocument` | Requires folder permission when creating new docs |
| DocX block append | Complete and real-tested | `OpenAPIClient.CreateBlocks` | App must have target DocX edit permission |
| Bitable search | Complete and real-tested | `SearchBitableRecord` | Requires `unique_key` field |
| Bitable create | Complete and real-tested | `CreateBitableRecord` | Requires existing table and compatible fields; split-table write passed |
| Bitable update | Complete and real-tested | `UpdateBitableRecord` | Never deletes records |
| Task create | Complete at minimal level and real-tested | `CreateTask` sends summary and description | Confirm project/section request fields |
| IM app bot message | Planned | Official API collected | Not needed for stable webhook path |
| Card callbacks | Future | Official capability identified | Requires server, signature validation, identity mapping |
| User identity mapping | Future | Required for personalized contributor routing | Not implemented in this branch |
| GitLink write actions | Explicitly out of scope | Capability boundary docs | Do not implement in this branch |
## Command Checklist
| Command | Layer | Current status | Real side effect? | Needs user setup? |
| --- | --- | --- | --- | --- |
| `feishu +bot-test` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` |
| `feishu +notify` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` |
| `feishu +weekly-report` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` |
| `feishu +owner-digest` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` |
| `feishu +contributor-digest` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` |
| `feishu +app-check` | Stable diagnostics | Implemented | No writes; optional read/check with `--remote` | App credentials for remote token check |
| `feishu +doc-check` | Stable diagnostics | Implemented | No writes; optional Wiki read with `--remote` | App credentials and DocX/Wiki target |
| `feishu +bitable-check` | Stable diagnostics | Implemented | No writes; optional Bitable search with `--remote` | Base app token, table IDs, `unique_key` |
| `feishu +task-check` | Stable diagnostics | Implemented | No writes; optional token check with `--remote` | App credentials |
| `feishu +bitable-schema` | Stable dry-run | Implemented | No | No |
| `feishu +bitable-records` | Stable dry-run | Implemented | No | No |
| `feishu +task-preview` | Stable dry-run | Implemented | No | No |
| `feishu +doc-export` | Experimental | Implemented and real-tested | Only with `--send` | App scopes and document/folder permission |
| `feishu +bitable-sync` | Experimental | Implemented and real-tested | Only with `--send` | Base app token, table IDs, fields, scopes |
| `feishu +task-create` | Experimental | Implemented and real-tested at minimal level | Only with `--send` | Task scopes; project/section placement pending |
## What Is Complete
```text
1. Feishu API families are mapped to current commands.
2. Current code endpoints are inventoried.
3. Stable custom bot boundary is clear.
4. Experimental Open Platform boundary is clear.
5. GitLink write action boundary is clear.
6. User-required environment variables are documented.
7. Resource-level permission requirements are documented.
8. Task project/section limitation is explicitly called out.
9. Feishu configuration diagnostics are available before running --send writes.
```
## What Still Needs User Action
These remain manual or owner-side tasks and should not be committed to the
repository.
```text
1. Defer Feishu UI screenshots and image evidence for this upload.
2. Keep the split-table validation as text evidence.
3. Decide later whether the final demonstration should use split tables only or
also keep the one-table/multiple-view proof as background evidence.
4. Decide whether `+bitable-sync` should stay experimental or be narrowed to
dry-run-only for upstream review.
5. Confirm Feishu Task project/section request fields before placing tasks in
a specific project or section.
6. Keep all real app credentials, webhook URLs, table IDs, and tokens in local
env only.
```
## Commands To Run After User Setup
Preview first:
```powershell
.\scripts\feishu-gitlink-env-check.ps1 -Layer all
go run . feishu +notify --from-workflow-json .local\report.json --format table
go run . feishu +owner-digest --from-workflow-json .local\report.json --format table
go run . feishu +contributor-digest --from-workflow-json .local\report.json --format table
go run . feishu +bitable-records --from-workflow-json .local\report.json --format json
go run . feishu +bitable-sync --from-workflow-json .local\report.json --format table
go run . feishu +doc-export --from-workflow-json .local\report.json --format table
go run . feishu +task-preview --from-workflow-json .local\report.json --format markdown
```
Real sends/writes only after preview is correct:
```powershell
go run . feishu +notify --from-workflow-json .local\report.json --send --format table
go run . feishu +weekly-report --from-workflow-json .local\report.json --send --format table
go run . feishu +owner-digest --from-workflow-json .local\report.json --send --format table
go run . feishu +contributor-digest --from-workflow-json .local\report.json --send --format table
go run . feishu +doc-export --from-workflow-json .local\report.json --send --format table
go run . feishu +bitable-sync --from-workflow-json .local\report.json --send --format table
go run . feishu +task-create --from-workflow-json .local\report.json --send --format table
```
Test suite:
```powershell
gofmt -w shortcuts\feishu
go test ./shortcuts/feishu
go test ./shortcuts/workflow
go test ./shortcuts
go test ./...
```
## Current Blockers
```text
Task project/section placement needs official request-field confirmation.
Current Base output is summary-oriented and not yet row-level project cockpit data.
Image evidence is deferred and is not part of this upload.
```
## Verification Run
Executed on 2026-06-26 after the API inventory update:
| Check | Result | Notes |
| --- | --- | --- |
| Computer-use Feishu desktop read-only check | Pass | Feishu test account visible |
| `go run . feishu --help` | Pass | Expected stable and experimental commands are registered |
| Env check | Pass | Required stable and Open Platform variables present; task project/section optional |
| `+notify` preview | Pass | Local preview mode |
| `+owner-digest` preview | Pass | Repository `Gitlink/gitlink-cli`, risk `high`, score `49` |
| `+contributor-digest` preview | Pass | Role-oriented digest, not personalized routing |
| `+notify --send` | Pass | Custom bot delivered English/default and Chinese cards |
| `+weekly-report --send` | Pass | Custom bot delivered weekly report |
| `+owner-digest --send` | Pass | Custom bot delivered English/default and Chinese owner digest |
| `+contributor-digest --send` | Pass | Custom bot delivered English/default and Chinese contributor digest |
| `+app-check --remote` | Pass | Custom bot, app credentials, and tenant_access_token validated with redacted output |
| `+doc-check --remote` | Pass | App credentials and DocX/folder targets checked; write permission not probed without appending |
| `+bitable-check --remote` | Pass | Five split tables passed sentinel `unique_key` search without record writes |
| `+task-check --remote` | Pass with warnings | Tenant token passed; project/section/dedupe remain next-stage boundaries |
| `+bitable-sync` preview | Pass | 1 report, 5 issue, 2 PR, 1 contributor, 7 task records |
| `+bitable-sync --send` | Pass | Search/create/update real-tested after field creation |
| Split-table `+bitable-sync --send` | Pass | Created 1 report, 5 issue, 2 PR, 1 contributor, and 7 task records across five separate Bitable tables |
| `+doc-export` preview | Pass | 9 DocX-ready blocks |
| `+doc-export --send` | Pass | Appended English/default and Chinese DocX blocks |
| `+task-preview` preview | Pass | 7 task candidates |
| `+task-create --send` | Pass | 7 tasks created; reruns may duplicate tasks |
| Feishu command i18n | Pass | zh-CN cards, digest, DocX blocks, and task titles previewed/sent |
| `go run ./internal/i18n/cmd/check` | Expected fail | Existing `en-US.json` formatting issue outside Feishu module |
| `go test ./shortcuts/feishu` | Pass | Includes task preview count regression test |
| `go test ./shortcuts/workflow` | Pass | Workflow report source remains valid |
| `go test ./shortcuts` | Pass | Shortcut package regression passed |
| `go test ./...` | Pass | Full repository test suite passed |
| Raw secret scan | Pass | No raw secret values found in tracked/unignored candidate files |
| Image evidence | Deferred | No screenshots or image files are included in this upload |
## Do Not Commit
```text
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET
FEISHU_APP_ID
FEISHU_APP_SECRET
tenant_access_token
user_access_token
FEISHU_BASE_APP_TOKEN
table IDs
Wiki node token
folder token
chat_id
open_id
union_id
GITLINK_TOKEN
personal account credentials
```

View File

@ -0,0 +1,284 @@
# Feishu Local Testing Guide
Date: 2026-06-26
This guide verifies the layered Feishu integration without committing secrets.
## 1. Run Local Setup Wizard
```powershell
.\scripts\feishu-gitlink-setup.ps1
```
The setup wizard opens these pages as needed:
```text
Feishu custom bot documentation
Feishu developer console
Feishu Docs / Wiki
Feishu Base / Bitable
Feishu Tasks
GitLink
```
The user logs in and authorizes in the browser. Values are pasted into the local PowerShell prompt, not into ChatGPT.
The wizard writes only to:
```text
.local/feishu-gitlink.env.ps1
```
This file is ignored and must not be committed. A tracked empty example is available at:
```text
.local/feishu-gitlink.env.example.ps1
```
## 2. Check Local Environment
```powershell
.\scripts\feishu-gitlink-env-check.ps1 -Layer stable
.\scripts\feishu-gitlink-env-check.ps1 -Layer open-platform
.\scripts\feishu-gitlink-env-check.ps1 -Layer all
```
The checker prints only redacted values.
## 3. Run Feishu CLI Diagnostics
Local diagnostics:
```bash
gitlink-cli feishu +app-check --format table
gitlink-cli feishu +doc-check --format table
gitlink-cli feishu +bitable-check --tables reports,issues,prs,contributors,tasks --format table
gitlink-cli feishu +task-check --format table
```
Optional remote diagnostics:
```bash
gitlink-cli feishu +app-check --remote --format table
gitlink-cli feishu +doc-check --remote --format table
gitlink-cli feishu +bitable-check --tables reports,issues,prs,contributors,tasks --remote --format table
gitlink-cli feishu +task-check --remote --format table
```
Remote diagnostics call only read/check endpoints. They do not create DocX
blocks, Bitable records, Feishu tasks, or GitLink writes.
## 4. Configure GitLink Test Repository Manually If Needed
```powershell
$env:GITLINK_OWNER="OWNER"
$env:GITLINK_REPO="REPO"
```
If the current workflow command cannot filter specific PR IDs, keep the PR IDs in the smoke report:
```powershell
$env:GITLINK_TEST_PR_IDS="1,2,3"
```
## 5. Run Scripted Smoke Tests
Preview only:
```powershell
.\scripts\feishu-gitlink-smoke.ps1 -Mode preview
```
Stable custom bot sends:
```powershell
.\scripts\feishu-gitlink-smoke.ps1 -Mode stable
```
Experimental Open Platform sends:
```powershell
.\scripts\feishu-gitlink-smoke.ps1 -Mode open-platform
```
All available tests:
```powershell
.\scripts\feishu-gitlink-smoke.ps1 -Mode all
```
The smoke runner writes:
```text
.local/report.json
reports/FEISHU_SMOKE_YYYYMMDD.md
reports/feishu-real-smoke-terminal.log
```
The terminal log is ignored and must not be committed after real runs.
## 6. Generate Workflow Report JSON Manually
```bash
gitlink-cli workflow +repo-report \
--owner "$GITLINK_OWNER" \
--repo "$GITLINK_REPO" \
--format json > report.json
```
Windows PowerShell redirection may produce UTF-16 with BOM. The Feishu workflow JSON reader supports UTF-8 and UTF-16 BOM inputs.
## 7. Preview Feishu Notify Card
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --format json
```
## 8. Send Feishu Notify Card
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
```
Requires:
```text
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET optional
```
## 9. Render Weekly Report
```bash
gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
```
## 10. Send Weekly Report
```bash
gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table
```
## 11. Generate Owner Digest
```bash
gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown
```
## 12. Send Owner Digest
```bash
gitlink-cli feishu +owner-digest --from-workflow-json report.json --send --format table
```
## 13. Generate Contributor Digest
```bash
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown
```
## 14. Send Contributor Digest
```bash
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --send --format table
```
## 15. Generate Bitable-Ready Records
```bash
gitlink-cli feishu +bitable-schema --tables reports,issues,prs,contributors,tasks --format markdown
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
```
## 16. Preview Bitable Sync
```bash
gitlink-cli feishu +bitable-sync \
--from-workflow-json report.json \
--tables reports,issues,prs,contributors,tasks \
--format table
```
## 17. Execute Bitable Sync
```bash
gitlink-cli feishu +bitable-sync \
--from-workflow-json report.json \
--tables reports,issues,prs,contributors,tasks \
--send \
--format table
```
Requires:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
FEISHU_BASE_APP_TOKEN
FEISHU_REPORT_TABLE_ID
FEISHU_ISSUE_TABLE_ID
FEISHU_PR_TABLE_ID
FEISHU_CONTRIBUTOR_TABLE_ID optional
FEISHU_TASK_TABLE_ID optional
```
## 18. Preview DocX / Wiki Export
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "$FEISHU_WIKI_URL" \
--format markdown
```
## 19. Execute DocX / Wiki Export
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "$FEISHU_WIKI_URL" \
--send \
--format table
```
## 20. Preview Feishu Tasks
```bash
gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown
```
## 21. Create Feishu Tasks
```bash
gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table
```
Requires:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
FEISHU_TASK_PROJECT_ID optional
FEISHU_TASK_SECTION_ID optional
```
## 22. Image Evidence
Image evidence is deferred for this round. Do not add screenshots or image files
to the upload.
## 23. Run Go Tests
```bash
gofmt -w shortcuts/feishu
go test ./shortcuts/feishu
go test ./shortcuts/workflow
go test ./shortcuts
go test ./...
```
## 24. Capture Evidence
Capture terminal logs and command output only.
Do not capture raw secrets. Redact webhook URLs, app secrets, app tokens, table IDs, Wiki node tokens, folder tokens, GitLink tokens, tenant tokens, open IDs, and union IDs.

View File

@ -0,0 +1,24 @@
# Feishu Permission Matrix
Date: 2026-06-26
GitLink write permission is `No` for every implemented command in this branch.
| Capability | Command | Layer | Needs webhook? | Needs app_id/app_secret? | Needs DocX/Wiki scope? | Needs Base scope? | Needs Task scope? | Needs GitLink token? | Needs GitLink write permission? | Tested locally? | Test result | Known limitation |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Custom bot test | `feishu +bot-test` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit/mock and real send passed | Custom bot only posts to configured chat |
| Workflow card | `feishu +notify` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | preview and real send passed, including zh-CN | Consumes workflow JSON; no direct Feishu identity routing |
| Weekly report | `feishu +weekly-report` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | preview and real send passed | Card is summary-level |
| Owner digest | `feishu +owner-digest` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit, preview, and real send passed, including zh-CN | Role-oriented, not personalized |
| Contributor digest | `feishu +contributor-digest` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit, preview, and real send passed, including zh-CN | Role-oriented, not open_id routed |
| App diagnostics | `feishu +app-check` | Stable diagnostics | No | Yes for `--remote` | No | No | No | No | No | Yes | unit, mock remote, and real remote passed | Remote mode only gets tenant_access_token; no writes |
| Doc diagnostics | `feishu +doc-check` | Stable diagnostics | No | Yes for `--remote` | Yes for Wiki node read | No | No | No | No | Yes | local and real remote diagnostics passed | Does not prove edit permission without append |
| Bitable diagnostics | `feishu +bitable-check` | Stable diagnostics | No | Yes for `--remote` | No | Yes for remote search | No | No | No | Yes | unit, mock remote, and five-table real remote passed | Checks table access and unique_key search; does not create fields |
| Task diagnostics | `feishu +task-check` | Stable diagnostics | No | Yes for `--remote` | No | No | No; token check only | No | No | Yes | mock remote and real remote passed with expected warnings | Does not create tasks; project/section still next-stage |
| Bitable schema | `feishu +bitable-schema` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed | Does not create tables or views |
| Bitable records | `feishu +bitable-records` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed | Summary records, not one row per raw issue/PR |
| Task preview | `feishu +task-preview` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed, including zh-CN | Local candidates only |
| DocX / Wiki export | `feishu +doc-export` | Experimental Open Platform | No | Yes for `--send` | Yes | No | No | No | No | Yes | mock, preview, and real DocX append passed, including zh-CN | App must have scopes and document/folder permission |
| Bitable sync | `feishu +bitable-sync` | Experimental Open Platform | No | Yes for `--send` | No | Yes | No | No | No | Yes | mock, preview, one-table real write, and split-table real write passed | Requires existing tables and compatible fields; CLI does not create production tables/views |
| Task create | `feishu +task-create` | Experimental Open Platform | No | Yes for `--send` | No | No | Yes | No | No | Yes | mock, preview, and real create passed | Dedupe is local unique_key only; project/section/assignee/follower support is next-stage boundary |
| GitLink action gateway | not implemented | Future planning | No | Planned | No | No | No | Planned | Yes | No | not implemented | Requires official authorization model |

View File

@ -0,0 +1,231 @@
# Feishu Smoke Report
Date: 2026-06-26 20:58:49 +08:00
## Branch
```text
feat/feishu-export-clean
```
## Commit
```text
73da46c143b37cb2b26e9e624b8c39963ad52d77
```
The worktree was dirty during this smoke run because the Feishu implementation
and documentation were still being updated.
## Mode
```text
real Feishu test enterprise plus local previews
```
## Test Environment
```text
Feishu test enterprise: used
Custom bot in test group: used
Self-built app with broad test permissions: used
Feishu DocX target: used
Feishu Base target: used
Feishu Task API: used
GitLink real repository data: Gitlink/gitlink-cli
Reference PR IDs for smoke notes: 95, 29, 75
GitLink write operations: not used
```
All Feishu resource IDs, tokens, webhook URLs, app credentials, table IDs, and
document IDs were kept in `.local/feishu-gitlink.env.ps1` and are not committed.
The Feishu self-built app in this test enterprise was intentionally granted
broad permissions for validation. This is not the recommended production
permission model. A production deployment should use the smallest scopes and
resource permissions required by the enabled commands.
## Redacted Environment Presence
| Variable | Present? | Notes |
| --- | --- | --- |
| `FEISHU_WEBHOOK_URL` | present | redacted in CLI output |
| `FEISHU_WEBHOOK_SECRET` | present | redacted in CLI output |
| `FEISHU_APP_ID` | present | redacted where printed |
| `FEISHU_APP_SECRET` | present | never printed |
| `FEISHU_FOLDER_TOKEN` | present | redacted |
| `FEISHU_DOCUMENT_ID` | present | redacted |
| `FEISHU_BASE_APP_TOKEN` | present | redacted |
| `FEISHU_REPORT_TABLE_ID` | present | split test table |
| `FEISHU_ISSUE_TABLE_ID` | present | split test table |
| `FEISHU_PR_TABLE_ID` | present | split test table |
| `FEISHU_CONTRIBUTOR_TABLE_ID` | present | split test table |
| `FEISHU_TASK_TABLE_ID` | present | split test table |
| `FEISHU_TASK_PROJECT_ID` | missing | optional; current request body does not place tasks into project/section |
| `FEISHU_TASK_SECTION_ID` | missing | optional; current request body does not place tasks into project/section |
| `GITLINK_OWNER` | present | `Gitlink` |
| `GITLINK_REPO` | present | `gitlink-cli` |
| `GITLINK_TEST_PR_IDS` | present | `95,29,75` |
| `GITLINK_TOKEN` | missing | not required for the read-only workflow report in this run |
## GitLink Report Source
Command:
```powershell
go run . workflow +repo-report --owner $env:GITLINK_OWNER --repo $env:GITLINK_REPO --format json > .local\report.json
go run . workflow +repo-report --owner $env:GITLINK_OWNER --repo $env:GITLINK_REPO --lang zh-CN --format json > .local\report.zh-CN.json
```
Result:
| Item | Value |
| --- | --- |
| Repository | `Gitlink/gitlink-cli` |
| Report score | `49` |
| Risk level | `high` |
| Health score | `58` |
| Issues | `19` |
| Pull requests | `10` |
| Source | `remote-read-only-fetch` |
The workflow command does not currently filter the report by explicit PR IDs, so
`GITLINK_TEST_PR_IDS` is recorded as smoke context rather than a hard filter.
## Real Feishu Results
| Command | Result | Details |
| --- | --- | --- |
| `feishu +bot-test --send` | pass | custom bot returned Feishu code `0` |
| `feishu +notify --send` | pass | English/default workflow card delivered |
| `feishu +weekly-report --send` | pass | weekly report card delivered |
| `feishu +owner-digest --send` | pass | owner digest card delivered |
| `feishu +contributor-digest --send` | pass | contributor digest card delivered |
| `feishu +notify --lang zh-CN --send` | pass | Chinese workflow card delivered |
| `feishu +owner-digest --lang zh-CN --send` | pass | Chinese owner digest delivered |
| `feishu +contributor-digest --lang zh-CN --send` | pass | Chinese contributor digest delivered |
| `feishu +app-check --remote` | pass | custom bot, app credentials, and tenant_access_token checked with redacted output |
| `feishu +doc-check --remote` | pass | app credentials and configured DocX/folder targets checked; edit/create permission intentionally not checked without writing |
| `feishu +bitable-check --remote` | pass | five split Bitable tables passed sentinel `unique_key` search without writing records |
| `feishu +task-check --remote` | pass with warnings | app credentials and tenant_access_token checked; project/section/dedupe remain next-stage boundaries |
| `feishu +doc-export --send` | pass | appended 9 DocX blocks to the configured document |
| `feishu +doc-export --lang zh-CN --send` | pass | appended 9 localized DocX blocks |
| `feishu +bitable-sync --tables reports --send` | pass after table fields were added | created the report record |
| `feishu +bitable-sync --tables reports,issues,prs,contributors,tasks --send` | pass | updated 1 report, created 5 issue buckets, 2 PR buckets, 1 contributor bucket, 7 task buckets |
| `feishu +bitable-sync --lang zh-CN --send` | pass | updated existing records from the Chinese workflow JSON |
| split-table `feishu +bitable-sync --send` | pass | wrote to 5 separate Bitable tables: reports=1, issues=5, prs=2, contributors=1, tasks=7 |
| `feishu +task-preview --lang zh-CN` | pass | generated 7 Chinese task candidates |
| `feishu +task-create --lang zh-CN --send` | pass | created 7 Feishu tasks |
## Bitable Setup Observation
The provided Feishu Base URLs pointed to one Base and one table with multiple
views. The test enterprise initially had only the default fields. A direct
OpenAPI inspection found one table and the default fields only, so the test
table was expanded with the fields expected by the CLI records:
```text
unique_key, repository, health_score, risk_level, report_score,
issue_total, issue_high_risk, issue_missing_info, pr_total, pr_high_risk,
review_focus_count, generated_at, source, doc_url, issue_group, priority,
count, risk_reason, recommended_action, gitlink_url, pr_group, review_focus,
contributor, role, open_items, risk_items, task_title, task_type, source_type,
source_key, recommended_owner, status, due_hint
```
This confirms that `+bitable-sync` can search, create, and update records when
the target table already has compatible fields. It does not yet create Base
tables or views itself.
After the first one-table validation, five dedicated test tables were created
or reused in the same Base:
```text
gitlink_reports
gitlink_issues
gitlink_prs
gitlink_contributors
gitlink_tasks
```
Each table was populated with its own required fields and then validated with
`+bitable-sync --send`. The split-table run created records in every table:
```text
reports: 1
issues: 5
prs: 2
contributors: 1
tasks: 7
```
This split-table validation is better evidence for the project-management model
than the earlier one-table/multiple-view validation.
## i18n Result
Feishu command-level Chinese output is usable:
```text
workflow +repo-report --lang zh-CN
feishu +notify --lang zh-CN
feishu +owner-digest --lang zh-CN
feishu +contributor-digest --lang zh-CN
feishu +doc-export --lang zh-CN
feishu +task-preview --lang zh-CN
feishu +task-create --lang zh-CN
```
The Feishu module localizes stable card labels, digest headings, common
recommendations, DocX block headings, and task candidate titles. For best
results, generate the source workflow report with `--lang zh-CN` and pass
`--lang zh-CN` again to the Feishu command.
Repository-wide i18n formatting check:
```text
go run ./internal/i18n/cmd/check
```
Result:
```text
fail: internal/i18n/locales/en-US.json is not formatted
```
That appears to be an existing locale formatting issue outside the Feishu
module. It was not fixed in this smoke run to avoid unrelated locale churn.
## Tests
| Check | Result |
| --- | --- |
| `go test ./shortcuts/feishu` | pass |
| `go test ./shortcuts/workflow` | pass |
| `go test ./shortcuts` | pass |
| `go test ./...` | pass |
| `go build .` | pass |
| `go vet ./...` | pass |
| Raw secret scan over tracked/unignored candidate files | pass |
## Known Limitations
```text
1. Bitable sync requires existing Base/table/fields; CLI does not create tables or views.
2. The first smoke used one test table for all record groups; a later smoke created split tables and proved every record group can write to its own table.
3. Current Bitable records are summary buckets, not row-level PR/Issue/CI records.
4. `+bitable-check --remote` can verify table access and unique_key search before writes, but it still does not create fields or validate every field type.
5. Feishu task creation does not yet map project/section placement into the request body; this is a next-stage capability boundary.
6. Feishu-side task dedupe/search is not implemented; avoid repeated real task-create runs unless duplicates are acceptable.
7. No Feishu callback server is implemented.
8. No GitLink write operation is implemented.
9. Image evidence is deferred and is not part of this upload.
```
## Image Evidence
Image files are intentionally not included in this upload.
The validation evidence for this round is command output, real OpenAPI results,
the permission matrix, and the smoke report. UI screenshots can be collected in
a later documentation pass if needed.

View File

@ -0,0 +1,268 @@
# Feishu Smoke Report
Date: 2026-06-27
## Branch and Base Commit
```text
branch: feat/feishu-export-clean
base: origin/master
runtime validation head: 9b82841 docs(feishu): finalize non-image PR validation notes
```
The data-correctness, PR review-audit, diagnostics, and text-only evidence
changes are committed in this branch.
Commit `9b82841` only updates validation notes and does not change runtime
behavior. Later documentation-only closing commits do not change the runtime
smoke target.
## Environment
```text
Real Feishu test enterprise: used
Real GitLink repository: used
Custom bot: used
Self-built Feishu app: used
DocX target: used
Five split Bitable tables: used
GitLink write operations: not used
```
Real credentials and resource IDs remained in the ignored file:
```text
.local/feishu-gitlink.env.ps1
```
## Readiness Diagnostics
| Command | Result | Side effect |
| --- | --- | --- |
| `feishu +app-check` | pass | none |
| `feishu +doc-check` | pass | none |
| `feishu +bitable-check --tables reports,issues,prs,tasks` | pass | none |
| `feishu +task-check` | pass with expected project/section/dedupe warnings | none |
| `feishu +app-check --remote` | pass | tenant token check only |
| `feishu +doc-check --remote` | pass with write-permission checks skipped | Wiki/DocX read/check only |
| `feishu +bitable-check --remote` | pass for four tables | sentinel search only |
| `feishu +task-check --remote` | pass with expected warnings | tenant token check only |
## Data-Correctness Finding
The first report generated:
```text
issues analyzed: 19
pull requests analyzed: 10
```
The GitLink UI showed:
```text
open issues: 9
open pull requests: 166
```
The values were real API-derived values, but the workflow request semantics
were wrong:
1. Issue workflow fetch sent `state=open`. GitLink Issue list requires
`category=opened`, so the API ignored the filter and returned 9 open plus 10
closed issues.
2. PR workflow fetch sent `state=open`. GitLink PR list requires `status=0`, so
the API ignored the filter and returned all states.
3. The old repo-report defaults analyzed only 20 issues and 10 PRs.
4. The API can cap a requested page at 50 records, so stopping only because a
page is shorter than the requested limit can truncate a report.
## Data-Correctness Fix
The branch now:
```text
uses category=opened for open Issue queries
uses status=0 for open PR queries
paginates until the API total_count is reached
deduplicates list items by stable identifiers
recognizes GitLink PR index as the user-facing PR number
analyzes all open issues and PRs by default
uses --issue-limit/--pr-limit only as explicit sampling controls
labels Feishu counts as analyzed counts
adds a scope note that sampled values are not repository totals
```
Real post-fix result:
```text
open issues analyzed: 9
open pull requests analyzed: 166
open PR lifecycle total: 166
merged PR lifecycle total: 65
closed/rejected PR lifecycle total: 74
```
These values match the GitLink web UI badges used during the smoke run.
## PR Risk Source
The bulk repo report uses PR list metadata, not changed files, commits, reviews,
or journal comments. The 13 critical metadata classifications came from the
existing `security-sensitive keyword` rule:
| PR | Metadata hit |
| --- | --- |
| 76 | token |
| 77 | token |
| 115 | token |
| 146 | token |
| 167 | secret |
| 171 | token, secret, credential |
| 173 | secret |
| 183 | secret |
| 189 | token |
| 225 | token |
| 254 | token |
| 280 | token |
| 293 | token |
This is a metadata risk hint, not a formal reviewer conclusion. Detailed code
risk requires files and commits. Formal review status must be reported
separately.
## Review and Journal API Validation
Three historical PRs were used only as local read-only samples:
| Sample | Formal review | Journal result |
| --- | --- | --- |
| PR 95 | one approved review with reviewer identity and content | review comments and merged event readable |
| PR 29 | one approved review with reviewer identity and content | review comment and merged event readable |
| PR 75 | no formal review object | review-like comments and rejected/closed event readable |
The repository member list returned 401 without GitLink authentication.
Therefore a generic implementation must not guess maintainer identity. It can
still reliably distinguish the submitter, formal reviewer, participant, and
system event. The cross-repository strategy is documented in:
```text
docs/FEISHU_PR_ACTIVITY_STRATEGY.md
```
## Full PR Review Audit
After the strategy was implemented as an explicit read-only audit path, the
full open-PR inventory was audited with:
```text
workflow +repo-report --include-pr-review-audit
```
Result:
```text
PRs analyzed: 166
PRs review-audited: 166
PRs with formal review evidence: 4
PRs without formal review evidence: 162
PRs needing re-review after reviewer feedback: 0
formal reviews: 4
reviewer comments: 6
submitter comments: 0
participant comments: 436
system events: 0
audit errors: 0
```
Review judgment is conservative:
```text
formal /pulls/{number}/reviews objects mark a PR as reviewed
submitter comments do not mark a PR as reviewed
participant comments do not mark a PR as reviewed
comments by actors with formal review identity are counted as reviewer feedback
reviewed PRs are marked needs_re_review when later submitter comments, later commits, or later PR updates appear after the last reviewer feedback
maintainer identity is not guessed without member-role data
```
## Real Feishu Writes
| Command group | Result |
| --- | --- |
| Eight webhook test/report/digest sends | HTTP 200, Feishu code 0 |
| Corrected full-analysis notify card | HTTP 200, Feishu code 0 |
| Corrected full-analysis owner digest | HTTP 200, Feishu code 0 |
| Full review-audit notify card | HTTP 200, Feishu code 0 |
| Full review-audit owner digest | HTTP 200, Feishu code 0 |
| Final English smoke notify card | HTTP 200, Feishu code 0 |
| Final English smoke owner digest | HTTP 200, Feishu code 0 |
| English and Chinese DocX append | 9 blocks each |
| Corrected Chinese DocX append | 11 blocks |
| Bitable full-analysis upsert | reports 1 updated; issues 5 updated; PRs 5 created/3 updated; contributors 1 updated; tasks 2 created/7 updated |
| Task create | intentionally skipped in this run to avoid duplicates |
## Current Boundaries
```text
No GitLink write operation.
No callback server.
No automatic Base/table/view creation.
No Feishu-side Task dedupe.
No PR activity snapshot persistence.
No previous-snapshot review diff.
No review-content fingerprint persistence.
No maintainer-role guess when member lookup is unavailable.
```
## Test Results
| Check | Result |
| --- | --- |
| `go test ./shortcuts/feishu` | pass on 2026-06-27 |
| `go test ./shortcuts/workflow` | pass on 2026-06-27 |
| `go test ./shortcuts` | pass on 2026-06-27 |
| `go test ./...` | pass on 2026-06-27 |
| `go build .` | pass on 2026-06-27 |
| `go vet ./...` | pass on 2026-06-27 |
| `go run ./internal/i18n/cmd/check` | blocked by existing Windows locale line-ending issue |
| `go run ./internal/i18n/cmd/check --scan-code` | blocked by the same formatting check |
The i18n line-ending and missing-key fix remains in its independent branch/PR
and is intentionally not duplicated into this Feishu change.
## CI Status
`.github/workflows/test.yml` already runs i18n validation on `origin/master`.
This branch adds Feishu/workflow package tests and `go vet` to that workflow.
The workflow triggers on `pull_request` and pushes to `main`/`master`, so a
push to `feat/feishu-export-clean` alone does not create a branch workflow run.
The GitHub Actions branch filter showed zero runs for this feature branch before
opening a PR. The PR page should be used as the source of truth after the PR is
created.
If CI reaches the i18n steps before the independent i18n fix is merged, it may
fail because this branch intentionally does not include:
```text
fix/i18n-locale-eol
8e24a89 fix(i18n): restore locale validation on Windows
```
That separate fix adds `.gitattributes` locale LF handling and the missing
`cmd.ignore.short` locale key.
## Screenshot Status
Screenshot files are intentionally excluded from the repository. The project
owner asked that validation images not be committed because binary screenshots
remain in Git history and duplicate the text report evidence.
No screenshot was fabricated or committed. Visual proof, if needed, should be
redacted and pasted directly into the PR description instead of being stored in
the worktree. Text evidence and API-derived results remain the committed
evidence for this run. See:
```text
reports/FEISHU_SMOKE_EVIDENCE_20260627.md
```

View File

@ -0,0 +1,101 @@
# Feishu Smoke Evidence
Date: 2026-06-27
Branch:
```text
feat/feishu-export-clean
```
Head commit:
```text
138d886 feat(feishu): add full PR inventory and review attribution
```
## Evidence Policy
No screenshot or other binary evidence is committed in this branch.
Visual validation can be attached directly to the PR description after manual
redaction when needed. It must not be stored under repository paths such as
`assets/validation-screenshots/`, `reports/images/`, or `docs/images/`.
## Text Evidence
```text
reports/FEISHU_SMOKE_20260626.md
reports/FEISHU_SMOKE_20260627.md
reports/FEISHU_PERMISSION_MATRIX.md
reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md
docs/FEISHU_OPENAPI_INVENTORY.md
docs/FEISHU_PR_ACTIVITY_STRATEGY.md
```
## PR Description Evidence
The following text is safe to paste into the PR description instead of adding
image files:
```text
Validation evidence is text-only in the repository. No screenshots are committed.
Real Feishu custom bot delivery passed:
- final English notify card: HTTP 200 / Feishu code 0
- final English owner digest card: HTTP 200 / Feishu code 0
Real GitLink repository data:
- repository: Gitlink/gitlink-cli
- open issues analyzed: 9
- open PRs analyzed: 166
- PR lifecycle totals: open 166, merged 65, closed/rejected 74
Full PR review audit:
- PRs audited: 166
- reviewed PRs: 4
- unreviewed PRs: 162
- needs re-review: 0
- formal reviews: 4
- reviewer comments: 6
- submitter comments: 0
- participant comments: 436
- system events: 0
- audit errors: 0
Risk source:
- high-risk PRs from metadata rule `security-sensitive keyword`: 13
Tests passed:
- go test ./shortcuts/feishu
- go test ./shortcuts/workflow
- go test ./shortcuts
- go test ./...
- go build .
- go vet ./...
Known separate issue:
- go run ./internal/i18n/cmd/check is blocked by an existing locale formatting
issue handled in a separate branch/PR.
```
## Redaction Checklist
```text
[x] No webhook URL committed
[x] No app secret committed
[x] No tenant_access_token committed
[x] No document token committed
[x] No Base app token committed
[x] No table ID committed
[x] No task ID committed
[x] No open_id / union_id committed
[x] No personal account credential committed
[x] No screenshot committed
```
## Capture Rule
Screenshots may be used only outside the repository, for example pasted into the
PR description after redaction. If a local screenshot is temporarily captured,
keep it outside the worktree and delete it after the PR evidence is prepared.

View File

@ -0,0 +1,191 @@
# Feishu Export Task Completion
## Branch
```text
feat/feishu-export-clean
```
## Implemented Commands
```text
gitlink-cli feishu +bot-test
gitlink-cli feishu +notify
gitlink-cli feishu +weekly-report
gitlink-cli feishu +owner-digest
gitlink-cli feishu +contributor-digest
gitlink-cli feishu +bitable-schema
gitlink-cli feishu +bitable-records
gitlink-cli feishu +bitable-sync
gitlink-cli feishu +doc-export
gitlink-cli feishu +task-preview
gitlink-cli feishu +task-create
```
## Implemented Behavior
- Added a `feishu` shortcut group.
- Added local preview by default.
- Added explicit `--send` for real Feishu custom bot delivery.
- Added `--send` and `--dry-run` conflict validation.
- Added webhook URL redaction in command output.
- Added Feishu custom bot interactive card builders.
- Added Feishu custom bot signing helper.
- Added injectable HTTP webhook client.
- Added workflow JSON input support for `RepoReportInput`, `RepoReportResult`, and envelope-like `data`.
- Added project activity card generation from workflow JSON.
- Added weekly report rendering from workflow JSON.
- Added role-aware owner digest rendering and optional custom bot sending.
- Added role-oriented contributor digest rendering and optional custom bot sending.
- Added `--doc-url` support for notification cards.
- Added experimental `feishu +doc-export` for Feishu DocX / Wiki export.
- Added self-built app tenant token acquisition.
- Added Wiki node resolution.
- Added DocX block creation client.
- Added document export preview and explicit `--send` behavior.
- Added Bitable dry-run schema generation.
- Added Bitable-ready dry-run records.
- Expanded Bitable records to `reports`, `issues`, `prs`, `contributors`, and `tasks`.
- Added experimental `feishu +bitable-sync` with `unique_key` search-before-update, create fallback, and no-delete behavior.
- Added local task candidate generation through `feishu +task-preview`.
- Added experimental `feishu +task-create` using Feishu Open Platform Task API.
- Registered the new shortcut group in `shortcuts/register.go`.
- Updated shortcut registration tests.
## Feishu Smoke Checks
Custom bot send was tested against a real Feishu custom bot webhook.
Result:
```text
HTTP status: 200
Feishu code: 0
Message: success
```
Webhook output was redacted.
A second notification card was sent with a Feishu Wiki URL as the report entry link.
The 2026-06-26 implementation smoke in the current shell did not have Feishu environment variables available, so real `--send` calls were not repeated in that shell. Public GitLink read smoke and local preview commands passed; see `reports/FEISHU_SMOKE_20260626.md`.
## Open Platform Checks
Self-built app authentication was checked with the Feishu Open Platform tenant token endpoint.
Result:
```text
tenant_access_token: acquired
expire: 7199 seconds
```
The supplied Feishu Wiki URL was resolved through Wiki OpenAPI.
Result:
```text
wiki node: resolved
object type: docx
object token: present
```
No document content was modified in this check.
The first real DocX block write attempt reached the DocX block endpoint but Feishu rejected the write:
```text
HTTP status: 403
Feishu code: 1770032
Message: forBidden
```
Interpretation:
```text
The app credentials are valid and the Wiki node is readable, but the app does not currently have write permission on the target Wiki-backed DocX page or lacks the required document scope approval.
```
The command now reports a permission hint for this case.
Additional experimental Open Platform paths added after the original smoke:
```text
bitable-sync: mock HTTP tested for tenant token, search, and create.
task-create: mock HTTP tested for tenant token and task create.
```
## Knowledge Base Design Update
Added official-docs alignment notes:
```text
feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md
```
Design now treats Feishu Knowledge Base / Wiki pages as a project showcase and reference target:
```text
workflow JSON -> DocX/Wiki report -> bot card with doc URL -> Bitable dry-run records
```
After scope review, DocX / Wiki export is explicitly experimental and not part of the stable clean workflow.
Stable path:
```text
workflow JSON -> bot card / weekly report / owner digest / contributor digest / Bitable dry-run records / task preview
```
Experimental path:
```text
workflow JSON -> DocX/Wiki export / Bitable sync / Task create through self-built app OpenAPI
```
## Tests
Commands run:
```bash
go test ./shortcuts/feishu
go test ./shortcuts/workflow
go test ./shortcuts
go test ./...
```
Result:
```text
passed
```
## Explicitly Not Implemented
```text
BotBuilder integration
Feishu Robot Assistant workflows
Feishu approval creation
callback server
button callbacks
GitLink remote writes
GitLink comments
Issue closure
code merge actions
direct GitLink webhook creation
Feishu Base/table/view creation
document permission modification
```
Note: experimental `doc-export`, `bitable-sync`, and `task-create` can attempt Open Platform writes when explicitly invoked with `--send`, but they remain outside the stable custom-bot export path.
## Next Engineering Step
For stable delivery, continue validating custom bot delivery, weekly reports, and Bitable dry-run output.
For experimental DocX/Wiki export, complete the Feishu document permission setup and rerun:
```text
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url <wiki_url> --send --format table
```

View File

@ -0,0 +1,116 @@
# Feishu Test Enterprise Smoke Report
Date: 2026-06-15
Branch:
```text
feat/feishu-export-clean
```
## Scope
This smoke run validates the stable Feishu export path in a real Feishu test enterprise.
Stable path:
```text
bot-test
notify
weekly-report
bitable-schema
bitable-records
```
Experimental path:
```text
doc-export
```
Secrets, full webhook URLs, app secrets, and real Wiki tokens are not recorded in this report.
## Local Verification
Commands:
```bash
gofmt -w shortcuts/feishu
git diff --check
go test ./shortcuts/feishu ./shortcuts ./...
```
Result:
```text
passed
```
## Windows JSON Encoding Check
The smoke run generated workflow JSON through Windows PowerShell redirection.
Initial result before fix:
```text
parse workflow JSON: invalid character 'ÿ' looking for beginning of value
```
Fix:
```text
workflow JSON reader now accepts UTF-8 BOM, UTF-16LE BOM, and UTF-16BE BOM.
```
Post-fix result:
```text
workflow JSON generated by PowerShell redirection is accepted.
```
## Real Feishu Bot Smoke
| Step | Result | Notes |
| --- | --- | --- |
| bot-test preview | pass | local preview |
| bot-test send | pass | HTTP 200 / Feishu code 0 |
| notify preview | pass | local preview |
| notify send | pass | HTTP 200 / Feishu code 0 |
| notify send with doc URL | pass | HTTP 200 / Feishu code 0 |
| weekly-report markdown | pass | markdown rendered |
| weekly-report send | pass | HTTP 200 / Feishu code 0 |
## Bitable Dry-Run Smoke
| Step | Result | Notes |
| --- | --- | --- |
| bitable-schema json | pass | JSON schema generated |
| bitable-schema markdown | pass | Markdown schema generated |
| bitable-records json | pass | dry-run records generated |
| bitable-records table | pass | reports/issues/prs records summarized; contributors reserved |
## Experimental Doc Export Smoke
| Step | Result | Notes |
| --- | --- | --- |
| doc-export preview | pass | target resolved as Wiki append preview |
| doc-export send | expected failure | Feishu returned HTTP 403 / code 1770032 / forBidden |
Interpretation:
```text
The self-built app credentials can get tenant_access_token and resolve the Wiki node, but the app cannot write blocks to the target Wiki-backed DocX page yet.
```
Required Feishu-side action:
```text
Grant the self-built app approved DocX/Drive scopes and write access to the target Wiki/DocX page or use a writable folder token.
```
## Conclusion
Stable Feishu export path is working in the test enterprise.
`doc-export` remains experimental and permission-blocked for real writes.

View File

@ -0,0 +1,104 @@
# Feishu / GitLink Project Completion Checklist
Date: 2026-06-26
This checklist records what still needs manual evidence or product decisions
after the real Feishu validation run. Do not write real secrets, webhook URLs,
app secrets, table IDs, document IDs, open IDs, union IDs, or GitLink tokens in
this file.
## Current Validation State
```text
Custom bot webhook: configured and real send passed.
Self-built app credentials: configured and tenant_access_token passed.
DocX target: configured and real append passed.
Bitable Base: configured.
Bitable one-table validation: passed.
Bitable split-table validation: passed.
Feishu task creation: basic task create passed.
GitLink repository source: Gitlink/gitlink-cli real workflow report generated.
zh-CN output: available for Feishu cards, digests, DocX blocks, and task candidates.
Image evidence: deferred and not part of this upload.
```
## Image Evidence
Screenshots and other image files are intentionally deferred for this round.
```text
Do not add screenshots or image files in this upload.
Use the text smoke report and permission matrix as current evidence.
```
If visual evidence is needed later, capture it in a separate documentation pass
and redact all visible IDs or secrets before committing.
## Bitable Demonstration State
The first validation used one test table with multiple views. The follow-up
validation created or reused five separate tables:
```text
gitlink_reports
gitlink_issues
gitlink_prs
gitlink_contributors
gitlink_tasks
```
Real split-table write result:
```text
reports: 1 record
issues: 5 records
prs: 2 records
contributors: 1 record
tasks: 7 records
```
Use the split-table text evidence for this upload because it demonstrates that
each supported table can receive records independently.
## Test Permission Note
The test enterprise used a self-built Feishu app with broad permissions so the
CLI could validate DocX, Base, and Task APIs end to end. This is only for local
validation. Production deployments should use least-privilege scopes and
resource-level access.
## Next-Stage Capability Boundary
These are intentionally not implemented in the current branch:
```text
Feishu Task project placement
Feishu Task section placement
Feishu Task assignees and followers
Feishu-side task dedupe/search
Bitable Base/table/field/view creation as a product command
Feishu card callback server
Feishu-triggered GitLink writes
GitLink issue comments from Feishu
GitLink PR reviews from Feishu
GitLink merge/close/member/webhook actions from Feishu
```
## Manual Decisions
```text
1. Decide later whether visual evidence is needed at all.
2. Decide whether experimental Open Platform writes should remain enabled in
the submitted branch or stay documented as validation-only.
3. Decide whether the next implementation stage should prioritize Task
assignees/followers or Bitable view/table automation.
```
## Safety
```text
Keep .local/feishu-gitlink.env.ps1 ignored.
Do not commit real Feishu or GitLink credentials.
Do not commit screenshots containing secrets or raw IDs.
Do not rerun task-create repeatedly unless duplicate test tasks are acceptable.
```

View File

@ -0,0 +1,88 @@
param(
[ValidateSet("stable", "open-platform", "all")]
[string]$Layer = "stable"
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
$LocalEnv = Join-Path $RepoRoot ".local/feishu-gitlink.env.ps1"
function Redact-Value {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return "" }
if ($Value.Length -le 8) { return "***" }
return "$($Value.Substring(0, 4))...$($Value.Substring($Value.Length - 4))"
}
function Show-Var {
param(
[string]$Name,
[bool]$Sensitive = $true
)
$value = [Environment]::GetEnvironmentVariable($Name)
if ([string]::IsNullOrWhiteSpace($value)) {
Write-Host ("{0}: missing" -f $Name)
return $false
}
if ($Sensitive) {
Write-Host ("{0}: set ({1})" -f $Name, (Redact-Value $value))
} else {
Write-Host ("{0}: set ({1})" -f $Name, $value)
}
return $true
}
function Test-Group {
param(
[string]$Title,
[string[]]$Names,
[string[]]$Optional = @(),
[string[]]$NonSensitive = @()
)
Write-Host ""
Write-Host "[$Title]"
$missingRequired = @()
foreach ($name in $Names) {
$isOptional = $Optional -contains $name
$isSensitive = -not ($NonSensitive -contains $name)
$present = Show-Var -Name $name -Sensitive:$isSensitive
if (-not $present -and -not $isOptional) {
$missingRequired += $name
}
}
return $missingRequired
}
if (Test-Path $LocalEnv) {
. $LocalEnv
Write-Host "Loaded local env: .local/feishu-gitlink.env.ps1"
} else {
Write-Host "No local env file found. Run scripts/feishu-gitlink-setup.ps1 or copy .local/feishu-gitlink.env.example.ps1."
}
$allMissing = @()
if ($Layer -eq "stable" -or $Layer -eq "all") {
$allMissing += Test-Group -Title "Stable webhook" -Names @("FEISHU_WEBHOOK_URL", "FEISHU_WEBHOOK_SECRET") -Optional @("FEISHU_WEBHOOK_SECRET")
}
if ($Layer -eq "open-platform" -or $Layer -eq "all") {
$allMissing += Test-Group -Title "Open Platform app" -Names @("FEISHU_APP_ID", "FEISHU_APP_SECRET")
$allMissing += Test-Group -Title "DocX / Wiki" -Names @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN", "FEISHU_DOCUMENT_ID") -Optional @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN", "FEISHU_DOCUMENT_ID")
$allMissing += Test-Group -Title "Base / Bitable" -Names @("FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID", "FEISHU_CONTRIBUTOR_TABLE_ID", "FEISHU_TASK_TABLE_ID") -Optional @("FEISHU_CONTRIBUTOR_TABLE_ID", "FEISHU_TASK_TABLE_ID")
$allMissing += Test-Group -Title "Feishu Task" -Names @("FEISHU_TASK_PROJECT_ID", "FEISHU_TASK_SECTION_ID") -Optional @("FEISHU_TASK_PROJECT_ID", "FEISHU_TASK_SECTION_ID")
}
if ($Layer -eq "all") {
$allMissing += Test-Group -Title "GitLink test input" -Names @("GITLINK_OWNER", "GITLINK_REPO", "GITLINK_TEST_PR_IDS", "GITLINK_TOKEN") -Optional @("GITLINK_TEST_PR_IDS", "GITLINK_TOKEN") -NonSensitive @("GITLINK_OWNER", "GITLINK_REPO", "GITLINK_TEST_PR_IDS")
}
if ($allMissing.Count -gt 0) {
Write-Host ""
Write-Host "Missing required values for layer '$Layer': $($allMissing -join ', ')"
exit 1
}
Write-Host ""
Write-Host "Layer '$Layer' is ready."
exit 0

View File

@ -0,0 +1,201 @@
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
$LocalDir = Join-Path $RepoRoot ".local"
$LocalEnv = Join-Path $LocalDir "feishu-gitlink.env.ps1"
function Redact {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return "missing" }
if ($Value.Length -le 8) { return "***" }
return "$($Value.Substring(0, 4))...$($Value.Substring($Value.Length - 4))"
}
function Escape-PSString {
param([string]$Value)
if ($null -eq $Value) { return "" }
return $Value.Replace('`', '``').Replace('"', '`"')
}
function SecureString-ToPlainText {
param([System.Security.SecureString]$Secure)
if ($null -eq $Secure) { return "" }
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)
} finally {
if ($ptr -ne [IntPtr]::Zero) {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
}
}
}
function Read-OptionalValue {
param(
[string]$Name,
[string]$CurrentValue,
[string]$Hint
)
$currentLabel = if ([string]::IsNullOrWhiteSpace($CurrentValue)) { "empty" } else { Redact $CurrentValue }
Write-Host ""
Write-Host "$Name ($currentLabel)"
if ($Hint) { Write-Host $Hint }
$value = Read-Host "Enter value, or press Enter to keep current"
if ([string]::IsNullOrWhiteSpace($value)) { return $CurrentValue }
return $value.Trim()
}
function Read-SecretValue {
param(
[string]$Name,
[string]$CurrentValue,
[string]$Hint
)
$currentLabel = if ([string]::IsNullOrWhiteSpace($CurrentValue)) { "empty" } else { "set" }
Write-Host ""
Write-Host "$Name ($currentLabel)"
if ($Hint) { Write-Host $Hint }
$secure = Read-Host "Enter secret, or press Enter to keep current" -AsSecureString
$plain = SecureString-ToPlainText $secure
if ([string]::IsNullOrWhiteSpace($plain)) { return $CurrentValue }
return $plain.Trim()
}
function Open-Url {
param(
[string]$Url,
[string]$Reason
)
Write-Host ""
Write-Host "Opening: $Reason"
Write-Host $Url
Start-Process $Url
}
function Pause-User {
param([string]$Message)
[void](Read-Host $Message)
}
function Env {
param([string]$Name)
return [Environment]::GetEnvironmentVariable($Name)
}
function Missing {
param([string[]]$Names)
foreach ($name in $Names) {
if ([string]::IsNullOrWhiteSpace((Env $name))) { return $true }
}
return $false
}
New-Item -ItemType Directory -Force -Path $LocalDir | Out-Null
if (Test-Path $LocalEnv) {
. $LocalEnv
Write-Host "Loaded existing local env: .local/feishu-gitlink.env.ps1"
}
Write-Host "This setup writes values only to .local/feishu-gitlink.env.ps1."
Write-Host "Do not paste secrets into chat or tracked docs."
if (Missing @("FEISHU_WEBHOOK_URL")) {
Open-Url "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot" "Feishu custom bot guide"
Pause-User "Add a custom bot to your Feishu test group and copy the webhook URL. Press Enter when ready."
$env:FEISHU_WEBHOOK_URL = Read-OptionalValue "FEISHU_WEBHOOK_URL" (Env "FEISHU_WEBHOOK_URL") "Used by +bot-test, +notify, +weekly-report, +owner-digest, and +contributor-digest."
$env:FEISHU_WEBHOOK_SECRET = Read-SecretValue "FEISHU_WEBHOOK_SECRET" (Env "FEISHU_WEBHOOK_SECRET") "Optional signing secret from the custom bot security settings."
}
if (Missing @("FEISHU_APP_ID", "FEISHU_APP_SECRET")) {
Open-Url "https://open.feishu.cn/app" "Feishu developer console"
Pause-User "Create/open a self-built app, enable required scopes, and copy app_id/app_secret. Press Enter when ready."
$env:FEISHU_APP_ID = Read-OptionalValue "FEISHU_APP_ID" (Env "FEISHU_APP_ID") "Used by experimental +doc-export, +bitable-sync, and +task-create."
$env:FEISHU_APP_SECRET = Read-SecretValue "FEISHU_APP_SECRET" (Env "FEISHU_APP_SECRET") "Self-built app secret."
}
if (Missing @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN", "FEISHU_DOCUMENT_ID")) {
Open-Url "https://www.feishu.cn/" "Feishu Docs / Wiki"
Pause-User "Open your target Wiki/Doc page or folder and copy the URL/token. Press Enter when ready."
$env:FEISHU_WIKI_URL = Read-OptionalValue "FEISHU_WIKI_URL" (Env "FEISHU_WIKI_URL") "Existing Wiki or Doc URL for +doc-export."
$env:FEISHU_WIKI_NODE_TOKEN = Read-OptionalValue "FEISHU_WIKI_NODE_TOKEN" (Env "FEISHU_WIKI_NODE_TOKEN") "Optional. Usually parsed from FEISHU_WIKI_URL."
$env:FEISHU_FOLDER_TOKEN = Read-OptionalValue "FEISHU_FOLDER_TOKEN" (Env "FEISHU_FOLDER_TOKEN") "Optional. Used when creating a new DocX in a folder."
$env:FEISHU_DOCUMENT_ID = Read-OptionalValue "FEISHU_DOCUMENT_ID" (Env "FEISHU_DOCUMENT_ID") "Optional. Existing DocX document ID for append."
}
if (Missing @("FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID")) {
Open-Url "https://www.feishu.cn/" "Feishu Base / Bitable"
Pause-User "Open/create the Base for reports, issues, prs, contributors, and tasks. Press Enter when table IDs are ready."
Write-Host "Expected tables: reports, issues, prs, contributors, tasks."
Write-Host "Each selected table should contain at least unique_key and repository."
$env:FEISHU_BASE_APP_TOKEN = Read-OptionalValue "FEISHU_BASE_APP_TOKEN" (Env "FEISHU_BASE_APP_TOKEN") "Base app token."
$env:FEISHU_REPORT_TABLE_ID = Read-OptionalValue "FEISHU_REPORT_TABLE_ID" (Env "FEISHU_REPORT_TABLE_ID") "Reports table ID."
$env:FEISHU_ISSUE_TABLE_ID = Read-OptionalValue "FEISHU_ISSUE_TABLE_ID" (Env "FEISHU_ISSUE_TABLE_ID") "Issues table ID."
$env:FEISHU_PR_TABLE_ID = Read-OptionalValue "FEISHU_PR_TABLE_ID" (Env "FEISHU_PR_TABLE_ID") "Pull request table ID."
$env:FEISHU_CONTRIBUTOR_TABLE_ID = Read-OptionalValue "FEISHU_CONTRIBUTOR_TABLE_ID" (Env "FEISHU_CONTRIBUTOR_TABLE_ID") "Optional contributor table ID."
$env:FEISHU_TASK_TABLE_ID = Read-OptionalValue "FEISHU_TASK_TABLE_ID" (Env "FEISHU_TASK_TABLE_ID") "Optional task candidate table ID."
}
if (Missing @("FEISHU_TASK_PROJECT_ID", "FEISHU_TASK_SECTION_ID")) {
Open-Url "https://www.feishu.cn/" "Feishu Tasks"
Pause-User "Open the Feishu Task project/section if needed. Press Enter when IDs are ready."
$env:FEISHU_TASK_PROJECT_ID = Read-OptionalValue "FEISHU_TASK_PROJECT_ID" (Env "FEISHU_TASK_PROJECT_ID") "Optional. Some Task API paths may not require it."
$env:FEISHU_TASK_SECTION_ID = Read-OptionalValue "FEISHU_TASK_SECTION_ID" (Env "FEISHU_TASK_SECTION_ID") "Optional."
}
if (Missing @("GITLINK_OWNER", "GITLINK_REPO")) {
Open-Url "https://www.gitlink.org.cn/" "GitLink"
Pause-User "Open the target GitLink repository and the previous 3 PRs. Press Enter when ready."
$env:GITLINK_OWNER = Read-OptionalValue "GITLINK_OWNER" (Env "GITLINK_OWNER") "Repository owner, for example Gitlink."
$env:GITLINK_REPO = Read-OptionalValue "GITLINK_REPO" (Env "GITLINK_REPO") "Repository name, for example gitlink-cli."
$env:GITLINK_TEST_PR_IDS = Read-OptionalValue "GITLINK_TEST_PR_IDS" (Env "GITLINK_TEST_PR_IDS") "Comma-separated PR IDs for smoke report references."
$env:GITLINK_TOKEN = Read-SecretValue "GITLINK_TOKEN" (Env "GITLINK_TOKEN") "Optional if gitlink-cli is already logged in."
}
if (-not [string]::IsNullOrWhiteSpace($env:GITLINK_OWNER) -and -not [string]::IsNullOrWhiteSpace($env:GITLINK_REPO)) {
Open-Url "https://www.gitlink.org.cn/$env:GITLINK_OWNER/$env:GITLINK_REPO" "Target GitLink repository"
if (-not [string]::IsNullOrWhiteSpace($env:GITLINK_TEST_PR_IDS)) {
Write-Host "GITLINK_TEST_PR_IDS set: $env:GITLINK_TEST_PR_IDS"
Write-Host "Verify the PR page URL pattern manually if needed."
}
}
$vars = @(
"FEISHU_WEBHOOK_URL",
"FEISHU_WEBHOOK_SECRET",
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_WIKI_URL",
"FEISHU_WIKI_NODE_TOKEN",
"FEISHU_FOLDER_TOKEN",
"FEISHU_DOCUMENT_ID",
"FEISHU_BASE_APP_TOKEN",
"FEISHU_REPORT_TABLE_ID",
"FEISHU_ISSUE_TABLE_ID",
"FEISHU_PR_TABLE_ID",
"FEISHU_CONTRIBUTOR_TABLE_ID",
"FEISHU_TASK_TABLE_ID",
"FEISHU_TASK_PROJECT_ID",
"FEISHU_TASK_SECTION_ID",
"GITLINK_OWNER",
"GITLINK_REPO",
"GITLINK_TEST_PR_IDS",
"GITLINK_TOKEN"
)
$lines = @(
"# Local Feishu/GitLink smoke-test environment.",
"# Generated by scripts/feishu-gitlink-setup.ps1.",
"# Do not commit this file."
)
foreach ($name in $vars) {
$value = [Environment]::GetEnvironmentVariable($name)
$lines += ('$' + ('env:{0}="{1}"' -f $name, (Escape-PSString $value)))
}
$lines | Set-Content -LiteralPath $LocalEnv -Encoding utf8
Write-Host ""
Write-Host "Saved local env: .local/feishu-gitlink.env.ps1"
Write-Host "Redacted summary:"
foreach ($name in $vars) {
$value = [Environment]::GetEnvironmentVariable($name)
Write-Host ("{0}: {1}" -f $name, (Redact $value))
}

View File

@ -0,0 +1,354 @@
param(
[ValidateSet("preview", "stable", "open-platform", "all")]
[string]$Mode = "preview"
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
$LocalDir = Join-Path $RepoRoot ".local"
$LocalEnv = Join-Path $LocalDir "feishu-gitlink.env.ps1"
$ReportJSON = Join-Path $LocalDir "report.json"
$TerminalLog = Join-Path $RepoRoot "reports/feishu-real-smoke-terminal.log"
$DateStamp = Get-Date -Format "yyyyMMdd"
$SmokeReport = Join-Path $RepoRoot "reports/FEISHU_SMOKE_$DateStamp.md"
$Results = New-Object System.Collections.Generic.List[object]
$Notes = New-Object System.Collections.Generic.List[string]
function Env {
param([string]$Name)
return [Environment]::GetEnvironmentVariable($Name)
}
function Has-Env {
param([string[]]$Names)
foreach ($name in $Names) {
if ([string]::IsNullOrWhiteSpace((Env $name))) { return $false }
}
return $true
}
function Redact-Value {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return "" }
if ($Value.Length -le 8) { return "***" }
return "$($Value.Substring(0, 4))...$($Value.Substring($Value.Length - 4))"
}
function Redact-Text {
param([string]$Text)
if ($null -eq $Text) { return "" }
$redacted = $Text
$names = @(
"FEISHU_WEBHOOK_URL",
"FEISHU_WEBHOOK_SECRET",
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_WIKI_URL",
"FEISHU_WIKI_NODE_TOKEN",
"FEISHU_FOLDER_TOKEN",
"FEISHU_BASE_APP_TOKEN",
"FEISHU_REPORT_TABLE_ID",
"FEISHU_ISSUE_TABLE_ID",
"FEISHU_PR_TABLE_ID",
"FEISHU_CONTRIBUTOR_TABLE_ID",
"FEISHU_TASK_TABLE_ID",
"FEISHU_TASK_PROJECT_ID",
"FEISHU_TASK_SECTION_ID",
"GITLINK_TOKEN"
)
foreach ($name in $names) {
$value = Env $name
if (-not [string]::IsNullOrWhiteSpace($value)) {
$redacted = [regex]::Replace($redacted, [regex]::Escape($value), (Redact-Value $value))
}
}
$redacted = [regex]::Replace($redacted, 'tenant_access_token"\s*:\s*"[^"]+', 'tenant_access_token":"REDACTED')
return $redacted
}
function Add-Result {
param(
[string]$Name,
[string]$Status,
[string]$Details
)
$Results.Add([pscustomobject]@{
Name = $Name
Status = $Status
Details = (Redact-Text $Details)
}) | Out-Null
}
function Write-Log {
param([string]$Text)
Add-Content -LiteralPath $TerminalLog -Value (Redact-Text $Text)
}
function Invoke-Cmd {
param(
[string]$Name,
[string[]]$CommandArgs,
[bool]$Required = $false
)
$display = $CommandArgs -join " "
Write-Host "RUN: $Name"
Write-Log ""
Write-Log "## $Name"
Write-Log "COMMAND: $display"
$exe = $CommandArgs[0]
$rest = @()
if ($CommandArgs.Count -gt 1) {
$rest = $CommandArgs[1..($CommandArgs.Count - 1)]
}
$output = & $exe @rest 2>&1 | Out-String
$exit = $LASTEXITCODE
Write-Log $output
if ($exit -eq 0) {
Add-Result $Name "pass" "exit=0"
} else {
Add-Result $Name "fail" ("exit={0}; {1}" -f $exit, (($output -split "`r?`n" | Select-Object -First 4) -join " "))
if ($Required) {
throw "$Name failed with exit $exit"
}
}
}
function Invoke-ReportGeneration {
$owner = Env "GITLINK_OWNER"
$repo = Env "GITLINK_REPO"
if ([string]::IsNullOrWhiteSpace($owner) -or [string]::IsNullOrWhiteSpace($repo)) {
$owner = "Gitlink"
$repo = "gitlink-cli"
$Notes.Add("GITLINK_OWNER/GITLINK_REPO were missing. Preview smoke used public Gitlink/gitlink-cli as a fallback.") | Out-Null
}
if (-not [string]::IsNullOrWhiteSpace((Env "GITLINK_TEST_PR_IDS"))) {
$Notes.Add("GITLINK_TEST_PR_IDS was provided, but workflow +repo-report currently does not support explicit PR ID filtering. The smoke test used the real repository report instead.") | Out-Null
}
Write-Host "Generating workflow report for $owner/$repo"
Write-Log ""
Write-Log "## Generate workflow report"
$output = & go run . workflow +repo-report --owner $owner --repo $repo --format json 2>&1 | Out-String
$exit = $LASTEXITCODE
if ($exit -ne 0) {
Write-Log $output
Add-Result "workflow +repo-report" "fail" ("exit={0}; {1}" -f $exit, (($output -split "`r?`n" | Select-Object -First 4) -join " "))
throw "workflow +repo-report failed"
}
$output | Set-Content -LiteralPath $ReportJSON -Encoding utf8
Write-Log "Report written to .local/report.json"
Add-Result "workflow +repo-report" "pass" "report=.local/report.json; owner=$owner; repo=$repo"
}
function Add-Skip {
param([string]$Name, [string]$Reason)
Write-Host "SKIP: $Name - $Reason"
Write-Log ""
Write-Log "## $Name"
Write-Log "SKIP: $Reason"
Add-Result $Name "skip" $Reason
}
function Env-Status {
param([string]$Name)
if ([string]::IsNullOrWhiteSpace((Env $Name))) { return "missing" }
return "present"
}
function Escape-Table {
param([string]$Value)
if ($null -eq $Value) { return "" }
return ($Value -replace '\|', '\|' -replace "`r?`n", " ")
}
function Write-SmokeReport {
$branch = (git branch --show-current | Out-String).Trim()
$commit = (git rev-parse HEAD | Out-String).Trim()
$envNames = @(
"FEISHU_WEBHOOK_URL",
"FEISHU_WEBHOOK_SECRET",
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_WIKI_URL",
"FEISHU_WIKI_NODE_TOKEN",
"FEISHU_FOLDER_TOKEN",
"FEISHU_BASE_APP_TOKEN",
"FEISHU_REPORT_TABLE_ID",
"FEISHU_ISSUE_TABLE_ID",
"FEISHU_PR_TABLE_ID",
"FEISHU_CONTRIBUTOR_TABLE_ID",
"FEISHU_TASK_TABLE_ID",
"FEISHU_TASK_PROJECT_ID",
"FEISHU_TASK_SECTION_ID",
"GITLINK_OWNER",
"GITLINK_REPO",
"GITLINK_TEST_PR_IDS",
"GITLINK_TOKEN"
)
$lines = @(
"# Feishu Smoke Report",
"",
"Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')",
"",
"## Branch",
"",
'```text',
$branch,
'```',
"",
"## Commit",
"",
'```text',
$commit,
'```',
"",
"## Mode",
"",
'```text',
$Mode,
'```',
"",
"## Redacted Environment Presence",
"",
"| Variable | Present? |",
"| --- | --- |"
)
foreach ($name in $envNames) {
$lines += ('| `{0}` | {1} |' -f $name, (Env-Status $name))
}
$lines += @(
"",
"## Results",
"",
"| Command | Result | Details |",
"| --- | --- | --- |"
)
foreach ($result in $Results) {
$lines += "| $(Escape-Table $result.Name) | $(Escape-Table $result.Status) | $(Escape-Table $result.Details) |"
}
$lines += @(
"",
"## Notes",
""
)
if ($Notes.Count -eq 0) {
$lines += "- None."
} else {
foreach ($note in $Notes) {
$lines += "- $(Redact-Text $note)"
}
}
$lines += @(
"",
"## Terminal Log",
"",
'Local redacted terminal log: `reports/feishu-real-smoke-terminal.log`',
"",
"This log file is ignored and should not be committed after real runs.",
"",
"## Image Evidence",
"",
"Image files are intentionally not part of this smoke output.",
"Use the command results, permission matrix, and redacted terminal log as evidence for this round."
)
$lines | Set-Content -LiteralPath $SmokeReport -Encoding utf8
Write-Host "Smoke report written: $SmokeReport"
}
Push-Location $RepoRoot
try {
New-Item -ItemType Directory -Force -Path $LocalDir | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $RepoRoot "reports") | Out-Null
if (Test-Path $LocalEnv) {
. $LocalEnv
Write-Host "Loaded local env: .local/feishu-gitlink.env.ps1"
} else {
$Notes.Add("No .local/feishu-gitlink.env.ps1 file found. Preview smoke can run with public fallback data; real sends are skipped.") | Out-Null
}
"# Feishu/GitLink smoke terminal log $(Get-Date -Format o)" | Set-Content -LiteralPath $TerminalLog -Encoding utf8
$helpCommands = @(
"+owner-digest",
"+contributor-digest",
"+app-check",
"+doc-check",
"+bitable-check",
"+bitable-sync",
"+task-preview",
"+task-check",
"+task-create"
)
Invoke-Cmd "feishu help" @("go", "run", ".", "feishu", "--help") $true
foreach ($command in $helpCommands) {
Invoke-Cmd "feishu $command help" @("go", "run", ".", "feishu", $command, "--help") $true
}
Invoke-ReportGeneration
$runPreview = $Mode -in @("preview", "stable", "open-platform", "all")
$runStable = $Mode -in @("stable", "all")
$runOpenPlatform = $Mode -in @("open-platform", "all")
if ($runPreview) {
Invoke-Cmd "notify preview" @("go", "run", ".", "feishu", "+notify", "--from-workflow-json", $ReportJSON, "--format", "table") $true
Invoke-Cmd "weekly report preview" @("go", "run", ".", "feishu", "+weekly-report", "--from-workflow-json", $ReportJSON, "--format", "markdown") $true
Invoke-Cmd "owner digest preview" @("go", "run", ".", "feishu", "+owner-digest", "--from-workflow-json", $ReportJSON, "--format", "table") $true
Invoke-Cmd "contributor digest preview" @("go", "run", ".", "feishu", "+contributor-digest", "--from-workflow-json", $ReportJSON, "--format", "table") $true
Invoke-Cmd "bitable records preview" @("go", "run", ".", "feishu", "+bitable-records", "--from-workflow-json", $ReportJSON, "--tables", "reports,issues,prs,contributors,tasks", "--format", "table") $true
Invoke-Cmd "task preview" @("go", "run", ".", "feishu", "+task-preview", "--from-workflow-json", $ReportJSON, "--format", "table") $true
}
if ($runStable) {
if (Has-Env @("FEISHU_WEBHOOK_URL")) {
Invoke-Cmd "bot-test send" @("go", "run", ".", "feishu", "+bot-test", "--send", "--format", "table") $false
Invoke-Cmd "notify send" @("go", "run", ".", "feishu", "+notify", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false
Invoke-Cmd "weekly report send" @("go", "run", ".", "feishu", "+weekly-report", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false
Invoke-Cmd "owner digest send" @("go", "run", ".", "feishu", "+owner-digest", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false
Invoke-Cmd "contributor digest send" @("go", "run", ".", "feishu", "+contributor-digest", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false
} else {
Add-Skip "stable webhook send" "FEISHU_WEBHOOK_URL missing"
}
}
if ($runOpenPlatform) {
if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET")) {
Invoke-Cmd "app-check remote" @("go", "run", ".", "feishu", "+app-check", "--remote", "--format", "table") $false
Invoke-Cmd "task-check remote" @("go", "run", ".", "feishu", "+task-check", "--remote", "--format", "table") $false
} else {
Add-Skip "app/task diagnostics" "missing FEISHU_APP_ID/FEISHU_APP_SECRET"
}
if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET") -and (Has-Env @("FEISHU_WIKI_URL") -or Has-Env @("FEISHU_WIKI_NODE_TOKEN") -or Has-Env @("FEISHU_FOLDER_TOKEN") -or Has-Env @("FEISHU_DOCUMENT_ID"))) {
Invoke-Cmd "doc-check remote" @("go", "run", ".", "feishu", "+doc-check", "--remote", "--format", "table") $false
} else {
Add-Skip "doc diagnostics" "missing FEISHU_APP_ID/FEISHU_APP_SECRET or DocX/Wiki target"
}
if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID")) {
Invoke-Cmd "bitable-check remote" @("go", "run", ".", "feishu", "+bitable-check", "--tables", "reports,issues,prs,contributors,tasks", "--remote", "--format", "table") $false
} else {
Add-Skip "bitable diagnostics" "missing Feishu app credentials, base app token, or required table IDs"
}
if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET") -and (Has-Env @("FEISHU_WIKI_URL") -or Has-Env @("FEISHU_WIKI_NODE_TOKEN") -or Has-Env @("FEISHU_FOLDER_TOKEN") -or Has-Env @("FEISHU_DOCUMENT_ID"))) {
Invoke-Cmd "doc-export send" @("go", "run", ".", "feishu", "+doc-export", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false
} else {
Add-Skip "doc-export send" "missing FEISHU_APP_ID/FEISHU_APP_SECRET or DocX/Wiki target"
}
if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID")) {
Invoke-Cmd "bitable-sync send" @("go", "run", ".", "feishu", "+bitable-sync", "--from-workflow-json", $ReportJSON, "--tables", "reports,issues,prs,contributors,tasks", "--send", "--format", "table") $false
} else {
Add-Skip "bitable-sync send" "missing app/base/table variables"
}
if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET")) {
Invoke-Cmd "task-create send" @("go", "run", ".", "feishu", "+task-create", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false
} else {
Add-Skip "task-create send" "missing FEISHU_APP_ID/FEISHU_APP_SECRET"
}
}
Write-SmokeReport
} finally {
Pop-Location
}

477
shortcuts/feishu/bitable.go Normal file
View File

@ -0,0 +1,477 @@
package feishu
import (
"fmt"
"io"
"sort"
"strings"
"text/tabwriter"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
type BitableSchema struct {
DryRun bool `json:"dry_run"`
Tables []BitableTableSchema `json:"tables"`
}
type BitableTableSchema struct {
Name string `json:"name"`
Description string `json:"description"`
Fields []BitableField `json:"fields"`
}
type BitableField struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description,omitempty"`
}
type BitableRecords struct {
DryRun bool `json:"dry_run"`
Repository string `json:"repository"`
Tables map[string][]BitableRecord `json:"tables"`
Schema []BitableTableSchema `json:"schema"`
Notes []string `json:"notes,omitempty"`
}
type BitableRecord struct {
UniqueKey string `json:"unique_key"`
Fields map[string]interface{} `json:"fields"`
}
func BuildBitableSchema(tables []string) BitableSchema {
result := BitableSchema{DryRun: true}
for _, table := range normalizeTables(tables) {
result.Tables = append(result.Tables, schemaForTable(table))
}
return result
}
func BuildBitableRecords(report workflow.RepoReportResult, tables []string, docURL string) BitableRecords {
tables = normalizeTables(tables)
result := BitableRecords{
DryRun: true,
Repository: report.Repository,
Tables: map[string][]BitableRecord{},
Schema: BuildBitableSchema(tables).Tables,
Notes: []string{
"Dry-run by default: +bitable-records does not call Feishu Bitable OpenAPI.",
"Records use stable unique_key values so experimental +bitable-sync can search-before-update.",
"Generated records are workflow-report summaries, not Feishu user-personalized records.",
},
}
for _, table := range tables {
switch table {
case "reports":
result.Tables[table] = reportRecords(report, docURL)
case "issues":
result.Tables[table] = issueRecords(report)
case "prs":
result.Tables[table] = prRecords(report)
case "contributors":
result.Tables[table] = contributorRecords(report)
case "tasks":
result.Tables[table] = taskRecords(report, docURL)
}
}
return result
}
func normalizeTables(tables []string) []string {
if len(tables) == 0 {
tables = parseList(defaultTables)
}
allowed := map[string]bool{"reports": true, "issues": true, "prs": true, "contributors": true, "tasks": true}
seen := map[string]bool{}
result := []string{}
for _, table := range tables {
if table == "pulls" {
table = "prs"
}
if !allowed[table] || seen[table] {
continue
}
seen[table] = true
result = append(result, table)
}
return result
}
func schemaForTable(table string) BitableTableSchema {
switch table {
case "issues":
return BitableTableSchema{
Name: "issues",
Description: "Issue summary and risk buckets from workflow repo report output.",
Fields: bitableFields([]string{
"unique_key:text",
"repository:text",
"issue_group:single_select",
"priority:single_select",
"count:number",
"risk_reason:multi_text",
"recommended_action:multi_text",
"gitlink_url:url",
}),
}
case "prs":
return BitableTableSchema{
Name: "prs",
Description: "Pull request summary and review-risk buckets from workflow repo report output.",
Fields: bitableFields([]string{
"unique_key:text",
"repository:text",
"pr_group:single_select",
"risk_level:single_select",
"count:number",
"review_focus:multi_text",
"recommended_action:multi_text",
"gitlink_url:url",
}),
}
case "contributors":
return BitableTableSchema{
Name: "contributors",
Description: "Role-oriented contributor summary records derived from workflow report signals.",
Fields: bitableFields([]string{
"unique_key:text",
"repository:text",
"contributor:text",
"role:single_select",
"open_items:number",
"risk_items:number",
"recommended_action:multi_text",
"gitlink_url:url",
}),
}
case "tasks":
return BitableTableSchema{
Name: "tasks",
Description: "Task candidates derived from workflow recommendations, high-risk issues, PRs, and missing information.",
Fields: bitableFields([]string{
"unique_key:text",
"repository:text",
"task_title:text",
"task_type:single_select",
"priority:single_select",
"source_type:single_select",
"source_key:text",
"recommended_owner:text",
"status:single_select",
"due_hint:text",
"gitlink_url:url",
}),
}
default:
return BitableTableSchema{
Name: "reports",
Description: "One row per repository workflow report.",
Fields: bitableFields([]string{
"unique_key:text",
"repository:text",
"health_score:number",
"risk_level:single_select",
"report_score:number",
"issue_total:number",
"issue_high_risk:number",
"issue_missing_info:number",
"pr_total:number",
"pr_high_risk:number",
"review_focus_count:number",
"generated_at:datetime",
"source:text",
"doc_url:url",
}),
}
}
}
func bitableFields(specs []string) []BitableField {
fields := make([]BitableField, 0, len(specs))
for _, spec := range specs {
parts := strings.SplitN(spec, ":", 2)
fieldType := "text"
if len(parts) == 2 {
fieldType = parts[1]
}
fields = append(fields, BitableField{Name: parts[0], Type: fieldType})
}
return fields
}
func reportRecords(report workflow.RepoReportResult, docURL string) []BitableRecord {
healthScore := interface{}(nil)
if report.Health != nil {
healthScore = report.Health.HealthScore
}
fields := map[string]interface{}{
"unique_key": stableKey("report", report.Repository),
"repository": report.Repository,
"health_score": healthScore,
"risk_level": report.RiskLevel,
"report_score": report.ReportScore,
"issue_total": report.IssueSummary.Total,
"issue_high_risk": report.IssueSummary.HighRisk,
"issue_missing_info": report.IssueSummary.MissingInfo,
"pr_total": report.PRSummary.Total,
"pr_high_risk": report.PRSummary.HighRisk,
"review_focus_count": len(report.PRSummary.ReviewFocus),
"generated_at": time.Now().UTC().Format(time.RFC3339),
"source": report.Source,
}
if strings.TrimSpace(docURL) != "" {
fields["doc_url"] = strings.TrimSpace(docURL)
}
return []BitableRecord{{UniqueKey: fields["unique_key"].(string), Fields: fields}}
}
func issueRecords(report workflow.RepoReportResult) []BitableRecord {
records := []BitableRecord{}
keys := sortedIntMapKeys(report.IssueSummary.ByPriority)
for _, priority := range keys {
fields := issueRecordFields(report, "priority", priority, report.IssueSummary.ByPriority[priority])
records = append(records, BitableRecord{UniqueKey: fields["unique_key"].(string), Fields: fields})
}
keys = sortedIntMapKeys(report.IssueSummary.ByType)
for _, issueType := range keys {
fields := issueRecordFields(report, "type", issueType, report.IssueSummary.ByType[issueType])
records = append(records, BitableRecord{UniqueKey: fields["unique_key"].(string), Fields: fields})
}
if len(records) == 0 {
fields := issueRecordFields(report, "summary", "total", report.IssueSummary.Total)
records = append(records, BitableRecord{UniqueKey: fields["unique_key"].(string), Fields: fields})
}
return records
}
func issueRecordFields(report workflow.RepoReportResult, groupType string, group string, count int) map[string]interface{} {
recommended := []string{"Review issue triage details in GitLink."}
if report.IssueSummary.MissingInfo > 0 {
recommended = append(recommended, "Request missing reproduction steps, logs, or environment details.")
}
if report.IssueSummary.HighRisk > 0 {
recommended = append(recommended, "Prioritize high-risk issue review.")
}
fields := map[string]interface{}{
"unique_key": stableKey("issue", report.Repository, groupType, group),
"repository": report.Repository,
"issue_group": groupType + ":" + group,
"priority": group,
"count": count,
"risk_reason": []string{fmt.Sprintf("high_risk=%d", report.IssueSummary.HighRisk), fmt.Sprintf("missing_info=%d", report.IssueSummary.MissingInfo)},
"recommended_action": uniqueDigestStrings(recommended),
}
if repoURL := gitlinkRepoURL(report.Repository); repoURL != "" {
fields["gitlink_url"] = repoURL + "/issues"
}
return fields
}
func prRecords(report workflow.RepoReportResult) []BitableRecord {
records := []BitableRecord{}
keys := sortedIntMapKeys(report.PRSummary.ByRisk)
for _, risk := range keys {
fields := prRecordFields(report, "risk", risk, report.PRSummary.ByRisk[risk])
records = append(records, BitableRecord{UniqueKey: fields["unique_key"].(string), Fields: fields})
}
keys = sortedIntMapKeys(report.PRSummary.ByType)
for _, changeType := range keys {
fields := prRecordFields(report, "change_type", changeType, report.PRSummary.ByType[changeType])
records = append(records, BitableRecord{UniqueKey: fields["unique_key"].(string), Fields: fields})
}
if len(records) == 0 {
fields := prRecordFields(report, "summary", "total", report.PRSummary.Total)
records = append(records, BitableRecord{UniqueKey: fields["unique_key"].(string), Fields: fields})
}
return records
}
func prRecordFields(report workflow.RepoReportResult, groupType string, group string, count int) map[string]interface{} {
recommended := []string{"Review PR focus items in GitLink."}
if report.PRSummary.HighRisk > 0 {
recommended = append(recommended, "Prioritize high-risk pull requests before lower-risk changes.")
}
fields := map[string]interface{}{
"unique_key": stableKey("pr", report.Repository, groupType, group),
"repository": report.Repository,
"pr_group": groupType + ":" + group,
"risk_level": group,
"count": count,
"review_focus": report.PRSummary.ReviewFocus,
"recommended_action": uniqueDigestStrings(recommended),
}
if repoURL := gitlinkRepoURL(report.Repository); repoURL != "" {
fields["gitlink_url"] = repoURL + "/pulls"
}
return fields
}
func contributorRecords(report workflow.RepoReportResult) []BitableRecord {
openItems := report.PRSummary.Total + report.IssueSummary.Total
riskItems := report.PRSummary.HighRisk + report.IssueSummary.HighRisk
fields := map[string]interface{}{
"unique_key": stableKey("contributor", report.Repository, "role-oriented"),
"repository": report.Repository,
"contributor": "role-oriented digest",
"role": "contributor",
"open_items": openItems,
"risk_items": riskItems,
"recommended_action": BuildContributorDigest(report, "").NextSteps,
}
if repoURL := gitlinkRepoURL(report.Repository); repoURL != "" {
fields["gitlink_url"] = repoURL
}
return []BitableRecord{{UniqueKey: fields["unique_key"].(string), Fields: fields}}
}
func taskRecords(report workflow.RepoReportResult, docURL string) []BitableRecord {
tasks := BuildTaskCandidates(report, docURL)
records := make([]BitableRecord, 0, len(tasks))
for _, task := range tasks {
fields := map[string]interface{}{
"unique_key": task.UniqueKey,
"repository": task.Repository,
"task_title": task.Title,
"task_type": task.TaskType,
"priority": task.Priority,
"source_type": task.SourceType,
"source_key": task.SourceKey,
"recommended_owner": task.RecommendedOwner,
"status": task.Status,
"due_hint": task.DueHint,
}
if task.GitLinkURL != "" {
fields["gitlink_url"] = task.GitLinkURL
}
records = append(records, BitableRecord{UniqueKey: task.UniqueKey, Fields: fields})
}
return records
}
func sortedIntMapKeys(values map[string]int) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func stableKey(parts ...string) string {
cleaned := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.ToLower(strings.TrimSpace(part))
part = strings.NewReplacer(" ", "-", "/", "_", "\\", "_", ":", "-", "#", "").Replace(part)
if part == "" {
part = "unknown"
}
cleaned = append(cleaned, part)
}
return strings.Join(cleaned, ":")
}
func renderBitableSchema(w io.Writer, schema BitableSchema, format string) error {
switch normalizeFormat(format) {
case "markdown":
return writeSchemaMarkdown(w, schema)
case "table":
return writeSchemaTable(w, schema)
default:
return writeJSON(w, schema)
}
}
func renderBitableRecords(w io.Writer, records BitableRecords, format string) error {
switch normalizeFormat(format) {
case "markdown":
return writeRecordsMarkdown(w, records)
case "table":
return writeRecordsTable(w, records)
default:
return writeJSON(w, records)
}
}
func writeSchemaMarkdown(w io.Writer, schema BitableSchema) error {
if _, err := fmt.Fprint(w, "# Feishu Bitable Schema\n\nDry run: `true`\n\n"); err != nil {
return err
}
for _, table := range schema.Tables {
if _, err := fmt.Fprintf(w, "## %s\n\n%s\n\n", table.Name, table.Description); err != nil {
return err
}
if _, err := fmt.Fprintln(w, "| Field | Type | Description |\n| --- | --- | --- |"); err != nil {
return err
}
for _, field := range table.Fields {
if _, err := fmt.Fprintf(w, "| %s | %s | %s |\n", field.Name, field.Type, field.Description); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
return nil
}
func writeSchemaTable(w io.Writer, schema BitableSchema) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "TABLE\tFIELD\tTYPE\tDESCRIPTION"); err != nil {
return err
}
for _, table := range schema.Tables {
for _, field := range table.Fields {
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", table.Name, field.Name, field.Type, field.Description); err != nil {
return err
}
}
}
return tw.Flush()
}
func writeRecordsMarkdown(w io.Writer, records BitableRecords) error {
if _, err := fmt.Fprint(w, "# Feishu Bitable Records\n\nDry run: `true`\n\n"); err != nil {
return err
}
for _, note := range records.Notes {
if _, err := fmt.Fprintf(w, "- %s\n", note); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
tableNames := sortedTableNames(records.Tables)
for _, table := range tableNames {
rows := records.Tables[table]
if _, err := fmt.Fprintf(w, "## %s\n\nRecords: `%d`\n\n", table, len(rows)); err != nil {
return err
}
}
return nil
}
func writeRecordsTable(w io.Writer, records BitableRecords) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "TABLE\tRECORDS"); err != nil {
return err
}
for _, table := range sortedTableNames(records.Tables) {
if _, err := fmt.Fprintf(tw, "%s\t%d\n", table, len(records.Tables[table])); err != nil {
return err
}
}
return tw.Flush()
}
func sortedTableNames(records map[string][]BitableRecord) []string {
tableNames := make([]string, 0, len(records))
for table := range records {
tableNames = append(tableNames, table)
}
sort.Strings(tableNames)
return tableNames
}

View File

@ -0,0 +1,246 @@
package feishu
import (
"context"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type BitableSyncOptions struct {
AppID string `json:"-"`
AppSecret string `json:"-"`
BaseAppToken string `json:"-"`
TableIDs map[string]string `json:"-"`
Tables []string `json:"tables"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
}
type BitableSyncOutput struct {
Mode string `json:"mode"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
BaseAppToken string `json:"base_app_token,omitempty"`
Tables []BitableSyncTableResult `json:"tables"`
Warnings []string `json:"warnings,omitempty"`
}
type BitableSyncTableResult struct {
Table string `json:"table"`
TableID string `json:"table_id,omitempty"`
RecordCount int `json:"record_count"`
Created int `json:"created,omitempty"`
Updated int `json:"updated,omitempty"`
Skipped bool `json:"skipped,omitempty"`
Error string `json:"error,omitempty"`
Records []BitableSyncRecordResult `json:"records,omitempty"`
}
type BitableSyncRecordResult struct {
UniqueKey string `json:"unique_key"`
Action string `json:"action"`
RecordID string `json:"record_id,omitempty"`
Error string `json:"error,omitempty"`
}
func bitableSyncOptionsFromContext(ctx *common.RuntimeContext) (BitableSyncOptions, error) {
opts := BitableSyncOptions{
AppID: firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")),
AppSecret: firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET")),
BaseAppToken: firstNonEmpty(ctx.Arg("base-app-token"), os.Getenv("FEISHU_BASE_APP_TOKEN")),
TableIDs: map[string]string{
"reports": firstNonEmpty(ctx.Arg("report-table-id"), os.Getenv("FEISHU_REPORT_TABLE_ID")),
"issues": firstNonEmpty(ctx.Arg("issue-table-id"), os.Getenv("FEISHU_ISSUE_TABLE_ID")),
"prs": firstNonEmpty(ctx.Arg("pr-table-id"), os.Getenv("FEISHU_PR_TABLE_ID")),
"contributors": firstNonEmpty(ctx.Arg("contributor-table-id"), os.Getenv("FEISHU_CONTRIBUTOR_TABLE_ID")),
"tasks": firstNonEmpty(ctx.Arg("task-table-id"), os.Getenv("FEISHU_TASK_TABLE_ID")),
},
Tables: normalizeTables(parseList(firstNonEmpty(ctx.Arg("tables"), defaultTables))),
Send: parseBool(ctx.Arg("send")),
DryRun: parseBool(ctx.Arg("dry-run")),
}
if opts.Send && opts.DryRun {
return BitableSyncOptions{}, fmt.Errorf("--send and --dry-run cannot be used together")
}
if opts.Send {
if opts.AppID == "" {
return BitableSyncOptions{}, fmt.Errorf("--send requires --app-id or FEISHU_APP_ID")
}
if opts.AppSecret == "" {
return BitableSyncOptions{}, fmt.Errorf("--send requires --app-secret or FEISHU_APP_SECRET")
}
if opts.BaseAppToken == "" {
return BitableSyncOptions{}, fmt.Errorf("--send requires --base-app-token or FEISHU_BASE_APP_TOKEN")
}
}
return opts, nil
}
func syncBitableOrPreview(ctx *common.RuntimeContext, opts BitableSyncOptions, records BitableRecords) error {
output := BitableSyncOutput{
Mode: "preview",
Send: opts.Send,
DryRun: !opts.Send,
BaseAppToken: redactToken(opts.BaseAppToken),
Warnings: []string{
"Experimental: Feishu Base writes require self-built app Base/Bitable scopes and table-level access.",
"Records are matched by the unique_key field. No records are deleted.",
},
}
for _, table := range opts.Tables {
output.Tables = append(output.Tables, BitableSyncTableResult{
Table: table,
TableID: redactToken(opts.TableIDs[table]),
RecordCount: len(records.Tables[table]),
})
}
if !opts.Send {
return renderBitableSyncOutput(os.Stdout, output, formatOrDefault(ctx, "markdown"))
}
client := NewOpenAPIClient(nil)
token, err := client.TenantAccessToken(context.Background(), opts.AppID, opts.AppSecret)
if err != nil {
return err
}
output.Mode = "sent"
output.DryRun = false
for i, tableResult := range output.Tables {
tableID := opts.TableIDs[tableResult.Table]
if strings.TrimSpace(tableID) == "" {
output.Tables[i].Skipped = true
output.Tables[i].Error = fmt.Sprintf("missing table ID for %s", tableResult.Table)
continue
}
for _, record := range records.Tables[tableResult.Table] {
result := BitableSyncRecordResult{UniqueKey: record.UniqueKey}
fields := normalizeBitableWriteFields(record.Fields)
search, err := client.SearchBitableRecord(context.Background(), token.Value, opts.BaseAppToken, tableID, record.UniqueKey)
if err != nil {
output.Warnings = append(output.Warnings, diagnoseOpenAPIError(err, "bitable", tableResult.Table)+"; falling back to create-only for this record")
created, createErr := client.CreateBitableRecord(context.Background(), token.Value, opts.BaseAppToken, tableID, fields)
if createErr != nil {
result.Action = "create"
result.Error = diagnoseOpenAPIError(createErr, "bitable", tableResult.Table)
output.Tables[i].Records = append(output.Tables[i].Records, result)
_ = renderBitableSyncOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return createErr
}
result.Action = "create"
result.RecordID = redactToken(created.RecordID)
output.Tables[i].Created++
output.Tables[i].Records = append(output.Tables[i].Records, result)
continue
}
if search.Found {
updated, err := client.UpdateBitableRecord(context.Background(), token.Value, opts.BaseAppToken, tableID, search.RecordID, fields)
if err != nil {
result.Action = "update"
result.RecordID = redactToken(search.RecordID)
result.Error = diagnoseOpenAPIError(err, "bitable", tableResult.Table)
output.Tables[i].Records = append(output.Tables[i].Records, result)
_ = renderBitableSyncOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return err
}
result.Action = "update"
result.RecordID = redactToken(updated.RecordID)
output.Tables[i].Updated++
} else {
created, err := client.CreateBitableRecord(context.Background(), token.Value, opts.BaseAppToken, tableID, fields)
if err != nil {
result.Action = "create"
result.Error = diagnoseOpenAPIError(err, "bitable", tableResult.Table)
output.Tables[i].Records = append(output.Tables[i].Records, result)
_ = renderBitableSyncOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return err
}
result.Action = "create"
result.RecordID = redactToken(created.RecordID)
output.Tables[i].Created++
}
output.Tables[i].Records = append(output.Tables[i].Records, result)
}
}
return renderBitableSyncOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
}
func normalizeBitableWriteFields(fields map[string]interface{}) map[string]interface{} {
normalized := make(map[string]interface{}, len(fields))
for key, value := range fields {
switch typed := value.(type) {
case []string:
normalized[key] = strings.Join(typed, "\n")
case []interface{}:
parts := make([]string, 0, len(typed))
for _, item := range typed {
parts = append(parts, fmt.Sprint(item))
}
normalized[key] = strings.Join(parts, "\n")
default:
normalized[key] = value
}
}
return normalized
}
func renderBitableSyncOutput(w io.Writer, output BitableSyncOutput, format string) error {
switch normalizeFormat(format) {
case "markdown":
return writeBitableSyncMarkdown(w, output)
case "table":
return writeBitableSyncTable(w, output)
default:
return writeJSON(w, output)
}
}
func writeBitableSyncMarkdown(w io.Writer, output BitableSyncOutput) error {
if _, err := fmt.Fprintf(w, "# Feishu Bitable Sync %s\n\n", titleWord(output.Mode)); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "- Send: `%t`\n- Dry run: `%t`\n- Base app token: `%s`\n\n", output.Send, output.DryRun, firstNonEmpty(output.BaseAppToken, "not configured")); err != nil {
return err
}
for _, warning := range output.Warnings {
if _, err := fmt.Fprintf(w, "- %s\n", warning); err != nil {
return err
}
}
if len(output.Warnings) > 0 {
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
for _, table := range output.Tables {
if _, err := fmt.Fprintf(w, "## %s\n\n- Table ID: `%s`\n- Records: `%d`\n- Created: `%d`\n- Updated: `%d`\n", table.Table, firstNonEmpty(table.TableID, "not configured"), table.RecordCount, table.Created, table.Updated); err != nil {
return err
}
if table.Skipped || table.Error != "" {
if _, err := fmt.Fprintf(w, "- Skipped: `%t`\n- Error: `%s`\n", table.Skipped, table.Error); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
return nil
}
func writeBitableSyncTable(w io.Writer, output BitableSyncOutput) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "TABLE\tTABLE_ID\tRECORDS\tCREATED\tUPDATED\tSKIPPED\tERROR"); err != nil {
return err
}
for _, table := range output.Tables {
if _, err := fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%d\t%t\t%s\n", table.Table, firstNonEmpty(table.TableID, "not configured"), table.RecordCount, table.Created, table.Updated, table.Skipped, table.Error); err != nil {
return err
}
}
return tw.Flush()
}

219
shortcuts/feishu/card.go Normal file
View File

@ -0,0 +1,219 @@
package feishu
import (
"fmt"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
type Card map[string]interface{}
type WebhookPayload struct {
Timestamp string `json:"timestamp,omitempty"`
Sign string `json:"sign,omitempty"`
MsgType string `json:"msg_type"`
Card Card `json:"card,omitempty"`
Content map[string]string `json:"content,omitempty"`
}
func NewInteractivePayload(card Card) WebhookPayload {
return WebhookPayload{
MsgType: "interactive",
Card: card,
}
}
func BuildBotTestCard(title, message, lang string) Card {
title = firstNonEmpty(title, feishuLabel(lang, "bot_title"))
message = firstNonEmpty(message, feishuLabel(lang, "bot_message"))
return baseCard(title, "blue", []interface{}{
div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "bot_status"), feishuLabel(lang, "ready"))),
div(message),
note(feishuLabel(lang, "bot_generated")),
})
}
func BuildWorkflowCard(report workflow.RepoReportResult, include []string, title string, lang string, docURL string) Card {
title = firstNonEmpty(title, reportTitle(report, lang))
elements := []interface{}{
div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "repository"), escapeMD(report.Repository))),
fields([]fieldValue{
{Label: feishuLabel(lang, "report_score"), Value: fmt.Sprintf("%d", report.ReportScore)},
{Label: feishuLabel(lang, "risk_level"), Value: report.RiskLevel},
{Label: feishuLabel(lang, "source"), Value: report.Source},
}),
}
if hasItem(include, "health") {
healthScore := "N/A"
healthRisk := "N/A"
if report.Health != nil {
healthScore = fmt.Sprintf("%d", report.Health.HealthScore)
healthRisk = report.Health.RiskLevel
}
elements = append(elements, fields([]fieldValue{
{Label: feishuLabel(lang, "health_score"), Value: healthScore},
{Label: feishuLabel(lang, "health_risk"), Value: healthRisk},
}))
}
if hasItem(include, "issues") {
elements = append(elements, fields([]fieldValue{
{Label: feishuLabel(lang, "issues_analyzed"), Value: fmt.Sprintf("%d", report.IssueSummary.Total)},
{Label: feishuLabel(lang, "high_risk_issues"), Value: fmt.Sprintf("%d", report.IssueSummary.HighRisk)},
{Label: feishuLabel(lang, "missing_info"), Value: fmt.Sprintf("%d", report.IssueSummary.MissingInfo)},
}))
}
if hasItem(include, "prs") {
elements = append(elements, fields([]fieldValue{
{Label: feishuLabel(lang, "prs_analyzed"), Value: fmt.Sprintf("%d", report.PRSummary.Total)},
{Label: feishuLabel(lang, "high_risk_prs"), Value: fmt.Sprintf("%d", report.PRSummary.HighRisk)},
}))
if report.PRLifecycle != nil {
elements = append(elements, fields([]fieldValue{
{Label: feishuLabel(lang, "open_prs"), Value: fmt.Sprintf("%d", report.PRLifecycle.Open)},
{Label: feishuLabel(lang, "merged_prs"), Value: fmt.Sprintf("%d", report.PRLifecycle.Merged)},
{Label: feishuLabel(lang, "closed_prs"), Value: fmt.Sprintf("%d", report.PRLifecycle.ClosedOrRejected)},
}))
}
if report.PRReviewAudit != nil {
elements = append(elements, fields([]fieldValue{
{Label: feishuLabel(lang, "review_audited"), Value: fmt.Sprintf("%d", report.PRReviewAudit.Audited)},
{Label: feishuLabel(lang, "reviewed_prs"), Value: fmt.Sprintf("%d", report.PRReviewAudit.Reviewed)},
{Label: feishuLabel(lang, "unreviewed_prs"), Value: fmt.Sprintf("%d", report.PRReviewAudit.Unreviewed)},
{Label: feishuLabel(lang, "needs_re_review"), Value: fmt.Sprintf("%d", report.PRReviewAudit.NeedsReReview)},
{Label: feishuLabel(lang, "formal_reviews"), Value: fmt.Sprintf("%d", report.PRReviewAudit.FormalReviews)},
}))
elements = append(elements, div(fmt.Sprintf("**%s**\n%s",
feishuLabel(lang, "review_actor_attribution"),
bulletList(reviewAuditActorLines(report.PRReviewAudit, lang), 6))))
}
if len(report.PRSummary.ReviewFocus) > 0 {
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "review_focus"), bulletList(localizeFeishuLines(report.PRSummary.ReviewFocus, lang), 4))))
}
if lines := riskSourceLines(report.PRSummary.RiskSources); len(lines) > 0 {
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "risk_sources"), bulletList(lines, 8))))
}
}
if len(report.Recommendations) > 0 {
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "recommendations"), bulletList(localizeFeishuLines(report.Recommendations, lang), 5))))
}
if strings.TrimSpace(docURL) != "" {
elements = append(elements, actionButton(feishuLabel(lang, "open_feishu_report"), docURL))
}
elements = append(elements, note(feishuLabel(lang, "analysis_scope")))
elements = append(elements, note(feishuLabel(lang, "preview_note")))
return baseCard(title, templateForRisk(report.RiskLevel), elements)
}
func reportTitle(report workflow.RepoReportResult, lang string) string {
return fmt.Sprintf(feishuLabel(lang, "workflow_report_title"), report.Repository)
}
func baseCard(title string, template string, elements []interface{}) Card {
return Card{
"config": map[string]interface{}{
"wide_screen_mode": true,
},
"header": map[string]interface{}{
"template": template,
"title": map[string]interface{}{
"tag": "plain_text",
"content": title,
},
},
"elements": elements,
}
}
func div(content string) map[string]interface{} {
return map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": content,
},
}
}
type fieldValue struct {
Label string
Value string
}
func fields(values []fieldValue) map[string]interface{} {
result := make([]interface{}, 0, len(values))
for _, value := range values {
result = append(result, map[string]interface{}{
"is_short": true,
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("**%s**\n%s", value.Label, escapeMD(value.Value)),
},
})
}
return map[string]interface{}{
"tag": "div",
"fields": result,
}
}
func note(content string) map[string]interface{} {
return map[string]interface{}{
"tag": "note",
"elements": []interface{}{
map[string]interface{}{
"tag": "plain_text",
"content": content,
},
},
}
}
func actionButton(text string, url string) map[string]interface{} {
return map[string]interface{}{
"tag": "action",
"actions": []interface{}{
map[string]interface{}{
"tag": "button",
"text": map[string]interface{}{
"tag": "plain_text",
"content": text,
},
"type": "primary",
"url": strings.TrimSpace(url),
},
},
}
}
func bulletList(values []string, limit int) string {
if limit <= 0 || limit > len(values) {
limit = len(values)
}
lines := make([]string, 0, limit)
for _, value := range values[:limit] {
lines = append(lines, "- "+escapeMD(value))
}
return strings.Join(lines, "\n")
}
func templateForRisk(risk string) string {
switch strings.ToLower(strings.TrimSpace(risk)) {
case "critical":
return "red"
case "high":
return "orange"
case "medium":
return "yellow"
default:
return "green"
}
}
func escapeMD(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "N/A"
}
return strings.ReplaceAll(value, "\n", " ")
}

View File

@ -0,0 +1,76 @@
package feishu
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
type WebhookClient struct {
URL string
Secret string
HTTP *http.Client
Now func() time.Time
}
type WebhookResponse struct {
StatusCode int `json:"status_code"`
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Body string `json:"body,omitempty"`
}
func (c WebhookClient) Send(ctx context.Context, payload WebhookPayload) (*WebhookResponse, error) {
if c.HTTP == nil {
c.HTTP = http.DefaultClient
}
now := time.Now
if c.Now != nil {
now = c.Now
}
if c.Secret != "" {
ts := timestampSeconds(now())
payload.Timestamp = strconv.FormatInt(ts, 10)
payload.Sign = SignCustomBotRequest(ts, c.Secret)
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
result := &WebhookResponse{StatusCode: resp.StatusCode, Body: string(respBody)}
var decoded struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(respBody, &decoded); err == nil {
result.Code = decoded.Code
result.Message = decoded.Msg
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return result, fmt.Errorf("Feishu webhook returned HTTP %d", resp.StatusCode)
}
if decoded.Code != 0 {
return result, fmt.Errorf("Feishu webhook returned code %d: %s", decoded.Code, decoded.Msg)
}
return result, nil
}

View File

@ -0,0 +1,394 @@
package feishu
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type DiagnosticOutput struct {
Mode string `json:"mode"`
Remote bool `json:"remote"`
Layer string `json:"layer"`
Summary DiagnosticSummary `json:"summary"`
Checks []DiagnosticCheck `json:"checks"`
Warnings []string `json:"warnings,omitempty"`
}
type DiagnosticSummary struct {
Passed int `json:"passed"`
Warned int `json:"warned"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
}
type DiagnosticCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Required bool `json:"required"`
Target string `json:"target,omitempty"`
Value string `json:"value,omitempty"`
Detail string `json:"detail,omitempty"`
Hint string `json:"hint,omitempty"`
}
func runFeishuAppCheck(ctx *common.RuntimeContext) error {
output := DiagnosticOutput{
Mode: "check",
Remote: parseBool(ctx.Arg("remote")),
Layer: "app",
Warnings: []string{
"Diagnostic commands never write Feishu resources or GitLink resources.",
"Use --remote to call Feishu OpenAPI read/check endpoints.",
},
}
webhookURL := firstNonEmpty(ctx.Arg("webhook-url"), os.Getenv("FEISHU_WEBHOOK_URL"))
webhookSecret := firstNonEmpty(ctx.Arg("secret"), os.Getenv("FEISHU_WEBHOOK_SECRET"))
appID := firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID"))
appSecret := firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET"))
if strings.TrimSpace(webhookURL) == "" {
output.addCheck(warnCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", "missing", "stable webhook send commands need FEISHU_WEBHOOK_URL"))
} else if err := validateWebhookURL(webhookURL); err != nil {
output.addCheck(failCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", redactWebhookURL(webhookURL), err.Error(), "copy the full custom bot webhook URL from the Feishu group bot settings"))
} else {
output.addCheck(passCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", redactWebhookURL(webhookURL), "configured"))
}
if strings.TrimSpace(webhookSecret) == "" {
output.addCheck(warnCheck("custom bot signing secret", "FEISHU_WEBHOOK_SECRET", "missing", "only required when the custom bot enables signature verification"))
} else {
output.addCheck(passCheck("custom bot signing secret", "FEISHU_WEBHOOK_SECRET", redactToken(webhookSecret), "configured"))
}
output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", appID, "required for DocX, Bitable, and Task OpenAPI validation"))
output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", appSecret, "required for tenant_access_token"))
if output.Remote && output.Summary.Failed == 0 {
output.remoteTenantToken(appID, appSecret)
} else if output.Remote {
output.addCheck(skipCheck("tenant_access_token", "Feishu OpenAPI", "skipped because app credentials are incomplete"))
}
return finishDiagnostic(ctx, output)
}
func runFeishuDocCheck(ctx *common.RuntimeContext) error {
opts := DocExportOptions{
AppID: firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")),
AppSecret: firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET")),
FolderToken: firstNonEmpty(ctx.Arg("folder-token"), os.Getenv("FEISHU_FOLDER_TOKEN"), os.Getenv("FEISHU_DOC_FOLDER_TOKEN")),
DocumentID: firstNonEmpty(ctx.Arg("document-id"), os.Getenv("FEISHU_DOCUMENT_ID")),
WikiURL: firstNonEmpty(ctx.Arg("wiki-url"), os.Getenv("FEISHU_WIKI_URL")),
WikiNodeToken: firstNonEmpty(ctx.Arg("wiki-node-token"), os.Getenv("FEISHU_WIKI_NODE_TOKEN")),
}
if opts.WikiNodeToken == "" && opts.WikiURL != "" {
opts.WikiNodeToken = wikiNodeTokenFromURL(opts.WikiURL)
}
if opts.DocumentID == "" && opts.WikiURL != "" {
opts.DocumentID = docxTokenFromURL(opts.WikiURL)
}
output := DiagnosticOutput{
Mode: "check",
Remote: parseBool(ctx.Arg("remote")),
Layer: "docx",
Warnings: []string{
"Doc check does not append blocks or create documents.",
"Actual DocX/Wiki writes still require --send on +doc-export.",
},
}
output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for DocX/Wiki OpenAPI"))
output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token"))
targets := 0
if opts.DocumentID != "" {
targets++
output.addCheck(passCheck("existing document target", "FEISHU_DOCUMENT_ID", redactToken(opts.DocumentID), "configured for append path"))
}
if opts.WikiNodeToken != "" {
targets++
output.addCheck(passCheck("wiki node target", "FEISHU_WIKI_NODE_TOKEN", redactToken(opts.WikiNodeToken), "configured or parsed from wiki URL"))
}
if opts.FolderToken != "" {
targets++
output.addCheck(passCheck("folder target", "FEISHU_FOLDER_TOKEN", redactToken(opts.FolderToken), "configured for create path"))
}
if opts.WikiURL != "" {
output.addCheck(passCheck("wiki url", "FEISHU_WIKI_URL", redactResourceURL(opts.WikiURL), "configured"))
}
if targets == 0 {
output.addCheck(failCheck("docx target", "DocX/Wiki", "missing", "no document, wiki, or folder target configured", "set FEISHU_DOCUMENT_ID, FEISHU_WIKI_URL, FEISHU_WIKI_NODE_TOKEN, or FEISHU_FOLDER_TOKEN"))
}
if output.Remote && output.Summary.Failed == 0 {
token, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret)
if ok && opts.WikiNodeToken != "" {
output.remoteWikiNode(token.Value, opts.WikiNodeToken)
}
if ok && opts.WikiNodeToken == "" {
output.addCheck(skipCheck("wiki node read", "Feishu Wiki", "skipped because no wiki node token was configured"))
}
if ok && opts.DocumentID != "" {
output.addCheck(skipCheck("document edit permission", "Feishu DocX", "not checked without writing blocks"))
}
if ok && opts.FolderToken != "" {
output.addCheck(skipCheck("folder create permission", "Feishu Drive", "not checked without creating a document"))
}
} else if output.Remote {
output.addCheck(skipCheck("remote docx check", "Feishu OpenAPI", "skipped because required config is incomplete"))
}
return finishDiagnostic(ctx, output)
}
func runFeishuBitableCheck(ctx *common.RuntimeContext) error {
opts, err := bitableSyncOptionsFromContext(ctx)
if err != nil {
return err
}
output := DiagnosticOutput{
Mode: "check",
Remote: parseBool(ctx.Arg("remote")),
Layer: "bitable",
Warnings: []string{
"Bitable check does not create, update, or delete records.",
"Remote mode searches a sentinel unique_key to verify table access and the unique_key field.",
},
}
output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for Base/Bitable OpenAPI"))
output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token"))
output.addCheck(requiredSecretCheck("base app token", "FEISHU_BASE_APP_TOKEN", opts.BaseAppToken, "required for Bitable tables"))
for _, table := range opts.Tables {
envName := tableEnvName(table)
tableID := opts.TableIDs[table]
output.addCheck(requiredSecretCheck(table+" table id", envName, tableID, "required for "+table+" sync"))
fields := schemaForTable(table).Fields
output.addCheck(passCheck(table+" expected fields", table, strings.Join(fieldNames(fields), ","), "schema expected by +bitable-sync"))
}
if output.Remote && output.Summary.Failed == 0 {
token, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret)
if ok {
client := NewOpenAPIClient(http.DefaultClient)
checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for _, table := range opts.Tables {
_, err := client.SearchBitableRecord(checkCtx, token.Value, opts.BaseAppToken, opts.TableIDs[table], "__gitlink_cli_check__")
if err != nil {
output.addCheck(failCheck("remote bitable table search", table, redactToken(opts.TableIDs[table]), sanitizeDiagnostic(diagnoseOpenAPIError(err, "bitable", table), opts.AppID, opts.AppSecret, opts.BaseAppToken, opts.TableIDs[table]), "grant Base scopes, share the Base with the app, and ensure unique_key exists"))
continue
}
output.addCheck(passCheck("remote bitable table search", table, redactToken(opts.TableIDs[table]), "table accessible and unique_key search completed"))
}
}
} else if output.Remote {
output.addCheck(skipCheck("remote bitable check", "Feishu Base", "skipped because required config is incomplete"))
}
return finishDiagnostic(ctx, output)
}
func runFeishuTaskCheck(ctx *common.RuntimeContext) error {
opts, err := taskCreateOptionsFromContext(&common.RuntimeContext{Args: map[string]string{
"app-id": ctx.Arg("app-id"),
"app-secret": ctx.Arg("app-secret"),
"task-project-id": ctx.Arg("task-project-id"),
"task-section-id": ctx.Arg("task-section-id"),
}})
if err != nil {
return err
}
output := DiagnosticOutput{
Mode: "check",
Remote: parseBool(ctx.Arg("remote")),
Layer: "task",
Warnings: []string{
"Task check does not create tasks.",
"Current +task-create only sends basic summary and description.",
},
}
output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for Task OpenAPI"))
output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token"))
if opts.TaskProjectID == "" {
output.addCheck(warnCheck("task project id", "FEISHU_TASK_PROJECT_ID", "missing", "currently collected for future placement; not mapped into +task-create request body"))
} else {
output.addCheck(passCheck("task project id", "FEISHU_TASK_PROJECT_ID", redactToken(opts.TaskProjectID), "configured but not yet mapped into +task-create request body"))
}
if opts.TaskSectionID == "" {
output.addCheck(warnCheck("task section id", "FEISHU_TASK_SECTION_ID", "missing", "currently collected for future placement; not mapped into +task-create request body"))
} else {
output.addCheck(passCheck("task section id", "FEISHU_TASK_SECTION_ID", redactToken(opts.TaskSectionID), "configured but not yet mapped into +task-create request body"))
}
output.addCheck(warnCheck("task dedupe", "Feishu Task", "not implemented", "dedupe is local unique_key only; no Feishu-side task search/linking"))
if output.Remote && output.Summary.Failed == 0 {
if _, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret); ok {
output.addCheck(skipCheck("remote task create permission", "Feishu Task", "not checked without creating a task"))
} else {
output.addCheck(skipCheck("remote task create permission", "Feishu Task", "not checked because tenant_access_token acquisition failed"))
}
} else if output.Remote {
output.addCheck(skipCheck("remote task check", "Feishu Task", "skipped because app credentials are incomplete"))
}
return finishDiagnostic(ctx, output)
}
func finishDiagnostic(ctx *common.RuntimeContext, output DiagnosticOutput) error {
if err := renderDiagnosticOutput(os.Stdout, output, formatOrDefault(ctx, "table")); err != nil {
return err
}
if output.Summary.Failed > 0 {
return fmt.Errorf("Feishu %s check failed: %d failed check(s)", output.Layer, output.Summary.Failed)
}
return nil
}
func (o *DiagnosticOutput) remoteTenantToken(appID, appSecret string) (TenantToken, bool) {
client := NewOpenAPIClient(http.DefaultClient)
checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
token, err := client.TenantAccessToken(checkCtx, appID, appSecret)
if err != nil {
o.addCheck(failCheck("tenant_access_token", "Feishu OpenAPI", "failed", sanitizeDiagnostic(diagnoseOpenAPIError(err, "tenant token", "self-built app"), appID, appSecret), "verify app_id/app_secret and app availability"))
return TenantToken{}, false
}
o.addCheck(passCheck("tenant_access_token", "Feishu OpenAPI", fmt.Sprintf("expire=%d", token.Expire), "acquired"))
return token, true
}
func (o *DiagnosticOutput) remoteWikiNode(token string, wikiNodeToken string) {
client := NewOpenAPIClient(http.DefaultClient)
checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
node, err := client.GetWikiNode(checkCtx, token, wikiNodeToken)
if err != nil {
o.addCheck(failCheck("wiki node read", "Feishu Wiki", redactToken(wikiNodeToken), sanitizeDiagnostic(diagnoseOpenAPIError(err, "docx", "wiki node"), wikiNodeToken, token), "grant Wiki/DocX scopes and make the target Wiki node visible to the app"))
return
}
detail := "obj_type=" + firstNonEmpty(node.ObjType, "unknown")
if node.Title != "" {
detail += "; title=" + node.Title
}
o.addCheck(passCheck("wiki node read", "Feishu Wiki", redactToken(wikiNodeToken), detail))
}
func (o *DiagnosticOutput) addCheck(check DiagnosticCheck) {
o.Checks = append(o.Checks, check)
switch check.Status {
case "pass":
o.Summary.Passed++
case "warn":
o.Summary.Warned++
case "fail":
o.Summary.Failed++
case "skip":
o.Summary.Skipped++
}
}
func requiredSecretCheck(name, target, value, hint string) DiagnosticCheck {
if strings.TrimSpace(value) == "" {
return failCheck(name, target, "missing", "required value is not configured", hint)
}
return passCheck(name, target, redactToken(value), "configured")
}
func passCheck(name, target, value, detail string) DiagnosticCheck {
return DiagnosticCheck{Name: name, Status: "pass", Target: target, Value: value, Detail: detail}
}
func warnCheck(name, target, value, hint string) DiagnosticCheck {
return DiagnosticCheck{Name: name, Status: "warn", Target: target, Value: value, Hint: hint}
}
func failCheck(name, target, value, detail, hint string) DiagnosticCheck {
return DiagnosticCheck{Name: name, Status: "fail", Required: true, Target: target, Value: value, Detail: detail, Hint: hint}
}
func skipCheck(name, target, detail string) DiagnosticCheck {
return DiagnosticCheck{Name: name, Status: "skip", Target: target, Detail: detail}
}
func renderDiagnosticOutput(w io.Writer, output DiagnosticOutput, format string) error {
switch normalizeFormat(format) {
case "markdown":
return writeDiagnosticMarkdown(w, output)
case "json":
return writeJSON(w, output)
default:
return writeDiagnosticTable(w, output)
}
}
func writeDiagnosticTable(w io.Writer, output DiagnosticOutput) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintf(tw, "LAYER\tREMOTE\tPASS\tWARN\tFAIL\tSKIP\n%s\t%t\t%d\t%d\t%d\t%d\n\n", output.Layer, output.Remote, output.Summary.Passed, output.Summary.Warned, output.Summary.Failed, output.Summary.Skipped); err != nil {
return err
}
if _, err := fmt.Fprintln(tw, "CHECK\tSTATUS\tTARGET\tVALUE\tDETAIL\tHINT"); err != nil {
return err
}
for _, check := range output.Checks {
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", check.Name, check.Status, check.Target, check.Value, oneLine(check.Detail), oneLine(check.Hint)); err != nil {
return err
}
}
return tw.Flush()
}
func writeDiagnosticMarkdown(w io.Writer, output DiagnosticOutput) error {
if _, err := fmt.Fprintf(w, "# Feishu %s Check\n\n", titleWord(output.Layer)); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "- Remote: `%t`\n- Passed: `%d`\n- Warned: `%d`\n- Failed: `%d`\n- Skipped: `%d`\n\n", output.Remote, output.Summary.Passed, output.Summary.Warned, output.Summary.Failed, output.Summary.Skipped); err != nil {
return err
}
if _, err := fmt.Fprintln(w, "| Check | Status | Target | Value | Detail | Hint |\n| --- | --- | --- | --- | --- | --- |"); err != nil {
return err
}
for _, check := range output.Checks {
if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s | %s | %s |\n", check.Name, check.Status, check.Target, check.Value, oneLine(check.Detail), oneLine(check.Hint)); err != nil {
return err
}
}
return nil
}
func tableEnvName(table string) string {
switch table {
case "reports":
return "FEISHU_REPORT_TABLE_ID"
case "issues":
return "FEISHU_ISSUE_TABLE_ID"
case "prs":
return "FEISHU_PR_TABLE_ID"
case "contributors":
return "FEISHU_CONTRIBUTOR_TABLE_ID"
case "tasks":
return "FEISHU_TASK_TABLE_ID"
default:
return "FEISHU_TABLE_ID"
}
}
func fieldNames(fields []BitableField) []string {
names := make([]string, 0, len(fields))
for _, field := range fields {
names = append(names, field.Name)
}
return names
}
func sanitizeDiagnostic(message string, values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
message = strings.ReplaceAll(message, value, redactToken(value))
}
return message
}
func oneLine(value string) string {
return strings.Join(strings.Fields(value), " ")
}

474
shortcuts/feishu/digest.go Normal file
View File

@ -0,0 +1,474 @@
package feishu
import (
"fmt"
"io"
"sort"
"strings"
"text/tabwriter"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
type RoleDigest struct {
Role string `json:"role"`
Repository string `json:"repository"`
RepositoryURL string `json:"repository_url,omitempty"`
DocURL string `json:"doc_url,omitempty"`
HealthScore *int `json:"health_score,omitempty"`
HealthRisk string `json:"health_risk,omitempty"`
RiskLevel string `json:"risk_level"`
ReportScore int `json:"report_score"`
IssueTotal int `json:"issue_total"`
IssueHighRisk int `json:"issue_high_risk"`
IssueMissingInfo int `json:"issue_missing_info"`
PRTotal int `json:"pr_total"`
PRHighRisk int `json:"pr_high_risk"`
PRRiskSources map[string]int `json:"pr_risk_sources,omitempty"`
PRLifecycle *workflow.RepoPRLifecycle `json:"pr_lifecycle,omitempty"`
PRReviewAudit *workflow.RepoPRReviewAudit `json:"pr_review_audit,omitempty"`
ReviewFocus []string `json:"review_focus,omitempty"`
Recommendations []string `json:"recommendations,omitempty"`
AttentionItems []string `json:"attention_items,omitempty"`
NextSteps []string `json:"next_steps,omitempty"`
BoundaryDescription string `json:"boundary_description"`
}
func BuildOwnerDigest(report workflow.RepoReportResult, docURL string) RoleDigest {
healthScore, healthRisk := digestHealth(report)
attention := []string{}
if report.IssueSummary.HighRisk > 0 {
attention = append(attention, fmt.Sprintf("%d high-risk issues need maintainer triage", report.IssueSummary.HighRisk))
}
if report.IssueSummary.MissingInfo > 0 {
attention = append(attention, fmt.Sprintf("%d issues are missing required information", report.IssueSummary.MissingInfo))
}
if report.PRSummary.HighRisk > 0 {
attention = append(attention, fmt.Sprintf("%d high-risk pull requests need owner review", report.PRSummary.HighRisk))
}
if healthScore != nil && *healthScore < 65 {
attention = append(attention, fmt.Sprintf("repository health score is %d", *healthScore))
}
if len(attention) == 0 {
attention = append(attention, "No critical owner action was detected in the workflow report.")
}
nextSteps := []string{
"Review high-risk issues and PRs first.",
"Use the Feishu report document for full context when available.",
"Keep GitLink write actions outside this digest; card buttons are navigation-only.",
}
if len(report.Recommendations) > 0 {
nextSteps = append(report.Recommendations, nextSteps...)
}
return RoleDigest{
Role: "owner",
Repository: report.Repository,
RepositoryURL: gitlinkRepoURL(report.Repository),
DocURL: strings.TrimSpace(docURL),
HealthScore: healthScore,
HealthRisk: healthRisk,
RiskLevel: report.RiskLevel,
ReportScore: report.ReportScore,
IssueTotal: report.IssueSummary.Total,
IssueHighRisk: report.IssueSummary.HighRisk,
IssueMissingInfo: report.IssueSummary.MissingInfo,
PRTotal: report.PRSummary.Total,
PRHighRisk: report.PRSummary.HighRisk,
PRRiskSources: report.PRSummary.RiskSources,
PRLifecycle: report.PRLifecycle,
PRReviewAudit: report.PRReviewAudit,
ReviewFocus: report.PRSummary.ReviewFocus,
Recommendations: report.Recommendations,
AttentionItems: uniqueDigestStrings(attention),
NextSteps: limitStrings(uniqueDigestStrings(nextSteps), 8),
BoundaryDescription: "Owner digest is a read-only summary. It does not modify GitLink or Feishu resources.",
}
}
func BuildContributorDigest(report workflow.RepoReportResult, docURL string) RoleDigest {
healthScore, healthRisk := digestHealth(report)
attention := []string{}
if len(report.PRSummary.ReviewFocus) > 0 {
attention = append(attention, report.PRSummary.ReviewFocus...)
}
if report.PRSummary.HighRisk > 0 {
attention = append(attention, fmt.Sprintf("%d high-risk pull requests may need contributor updates", report.PRSummary.HighRisk))
}
if report.IssueSummary.MissingInfo > 0 {
attention = append(attention, fmt.Sprintf("%d issues need clearer reproduction details or missing information", report.IssueSummary.MissingInfo))
}
if report.IssueSummary.HighRisk > 0 {
attention = append(attention, fmt.Sprintf("%d high-risk issues may need focused follow-up", report.IssueSummary.HighRisk))
}
if len(attention) == 0 {
attention = append(attention, "No contributor-specific blocker was detected in the workflow report.")
}
nextSteps := []string{
"Check PR review focus and update the related branch or description.",
"Add missing reproduction steps, logs, or screenshots when requested.",
"Open the GitLink repository or Feishu report link for details.",
}
if report.PRSummary.HighRisk > 0 {
nextSteps = append([]string{"Prioritize high-risk pull request feedback before new work."}, nextSteps...)
}
return RoleDigest{
Role: "contributor",
Repository: report.Repository,
RepositoryURL: gitlinkRepoURL(report.Repository),
DocURL: strings.TrimSpace(docURL),
HealthScore: healthScore,
HealthRisk: healthRisk,
RiskLevel: report.RiskLevel,
ReportScore: report.ReportScore,
IssueTotal: report.IssueSummary.Total,
IssueHighRisk: report.IssueSummary.HighRisk,
IssueMissingInfo: report.IssueSummary.MissingInfo,
PRTotal: report.PRSummary.Total,
PRHighRisk: report.PRSummary.HighRisk,
PRRiskSources: report.PRSummary.RiskSources,
PRLifecycle: report.PRLifecycle,
PRReviewAudit: report.PRReviewAudit,
ReviewFocus: report.PRSummary.ReviewFocus,
Recommendations: report.Recommendations,
AttentionItems: limitStrings(uniqueDigestStrings(attention), 8),
NextSteps: limitStrings(uniqueDigestStrings(nextSteps), 8),
BoundaryDescription: "Contributor digest is role-oriented, not personalized. It does not use Feishu open_id or union_id routing.",
}
}
func BuildOwnerDigestCard(digest RoleDigest, title string, lang string) Card {
return buildDigestCard(digest, firstNonEmpty(title, fmt.Sprintf(feishuLabel(lang, "owner_digest_title"), digest.Repository)), "owner", lang)
}
func BuildContributorDigestCard(digest RoleDigest, title string, lang string) Card {
return buildDigestCard(digest, firstNonEmpty(title, fmt.Sprintf(feishuLabel(lang, "contributor_digest_title"), digest.Repository)), "contributor", lang)
}
func buildDigestCard(digest RoleDigest, title string, role string, lang string) Card {
elements := []interface{}{
div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "repository"), escapeMD(digest.Repository))),
fields([]fieldValue{
{Label: feishuLabel(lang, "report_score"), Value: fmt.Sprintf("%d", digest.ReportScore)},
{Label: feishuLabel(lang, "risk_level"), Value: digest.RiskLevel},
{Label: feishuLabel(lang, "issues_analyzed"), Value: fmt.Sprintf("%d", digest.IssueTotal)},
{Label: feishuLabel(lang, "prs_analyzed"), Value: fmt.Sprintf("%d", digest.PRTotal)},
}),
fields([]fieldValue{
{Label: feishuLabel(lang, "high_risk_issues"), Value: fmt.Sprintf("%d", digest.IssueHighRisk)},
{Label: feishuLabel(lang, "missing_info_issues"), Value: fmt.Sprintf("%d", digest.IssueMissingInfo)},
{Label: feishuLabel(lang, "high_risk_prs"), Value: fmt.Sprintf("%d", digest.PRHighRisk)},
{Label: feishuLabel(lang, "review_focus"), Value: fmt.Sprintf("%d", len(digest.ReviewFocus))},
}),
}
if digest.HealthScore != nil {
elements = append(elements, fields([]fieldValue{
{Label: feishuLabel(lang, "health_score"), Value: fmt.Sprintf("%d", *digest.HealthScore)},
{Label: feishuLabel(lang, "health_risk"), Value: digest.HealthRisk},
}))
}
if digest.PRLifecycle != nil {
elements = append(elements, fields([]fieldValue{
{Label: feishuLabel(lang, "open_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.Open)},
{Label: feishuLabel(lang, "merged_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.Merged)},
{Label: feishuLabel(lang, "closed_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.ClosedOrRejected)},
}))
}
if digest.PRReviewAudit != nil {
elements = append(elements, fields([]fieldValue{
{Label: feishuLabel(lang, "review_audited"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Audited)},
{Label: feishuLabel(lang, "reviewed_prs"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Reviewed)},
{Label: feishuLabel(lang, "unreviewed_prs"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Unreviewed)},
{Label: feishuLabel(lang, "needs_re_review"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.NeedsReReview)},
{Label: feishuLabel(lang, "formal_reviews"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.FormalReviews)},
}))
elements = append(elements, div(fmt.Sprintf("**%s**\n%s",
feishuLabel(lang, "review_actor_attribution"),
bulletList(reviewAuditActorLines(digest.PRReviewAudit, lang), 6))))
}
if len(digest.AttentionItems) > 0 {
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "attention"), bulletList(localizeFeishuLines(digest.AttentionItems, lang), 5))))
}
if lines := riskSourceLines(digest.PRRiskSources); len(lines) > 0 {
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "risk_sources"), bulletList(lines, 8))))
}
if len(digest.NextSteps) > 0 {
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "suggested_next_steps"), bulletList(localizeFeishuLines(digest.NextSteps, lang), 5))))
}
if digest.RepositoryURL != "" {
elements = append(elements, actionButton(feishuLabel(lang, "open_gitlink_repository"), digest.RepositoryURL))
}
if digest.DocURL != "" {
elements = append(elements, actionButton(feishuLabel(lang, "open_feishu_report"), digest.DocURL))
}
elements = append(elements, note(feishuLabel(lang, "analysis_scope")))
elements = append(elements, note(localizedBoundary(digest, lang)))
template := templateForRisk(digest.RiskLevel)
if role == "contributor" && digest.PRSummaryNeedsAttention() {
template = "yellow"
}
return baseCard(title, template, elements)
}
func localizedBoundary(digest RoleDigest, lang string) string {
if !isChineseLang(lang) {
return digest.BoundaryDescription
}
switch digest.Role {
case "owner":
return feishuLabel(lang, "boundary_owner")
case "contributor":
return feishuLabel(lang, "boundary_contributor")
default:
return localizeFeishuText(digest.BoundaryDescription, lang)
}
}
func (d RoleDigest) PRSummaryNeedsAttention() bool {
return d.PRHighRisk > 0 || len(d.ReviewFocus) > 0
}
func renderDigest(w io.Writer, digest RoleDigest, format string, lang string) error {
switch normalizeFormat(format) {
case "markdown":
return writeDigestMarkdown(w, digest, lang)
case "table":
return writeDigestTable(w, digest, lang)
default:
return writeJSON(w, digest)
}
}
func writeDigestMarkdown(w io.Writer, digest RoleDigest, lang string) error {
title := fmt.Sprintf("# GitLink %s digest: %s\n\n", digest.Role, digest.Repository)
if isChineseLang(lang) {
role := "角色"
if digest.Role == "owner" {
role = "Owner"
}
if digest.Role == "contributor" {
role = "贡献者"
}
title = fmt.Sprintf("# GitLink %s摘要%s\n\n", role, digest.Repository)
}
if _, err := fmt.Fprint(w, title); err != nil {
return err
}
lines := digestMarkdownLines(digest, lang)
if digest.HealthScore != nil {
if isChineseLang(lang) {
lines = append(lines, fmt.Sprintf("- 健康分:`%d`;健康风险:`%s`", *digest.HealthScore, firstNonEmpty(digest.HealthRisk, "unknown")))
} else {
lines = append(lines, fmt.Sprintf("- Health score: `%d`; health risk: `%s`", *digest.HealthScore, firstNonEmpty(digest.HealthRisk, "unknown")))
}
}
if digest.PRLifecycle != nil {
if isChineseLang(lang) {
lines = append(lines, fmt.Sprintf("- PR 生命周期:开放 `%d`,已合并 `%d`,已关闭/拒绝 `%d`",
digest.PRLifecycle.Open,
digest.PRLifecycle.Merged,
digest.PRLifecycle.ClosedOrRejected,
))
} else {
lines = append(lines, fmt.Sprintf("- PR lifecycle: open `%d`, merged `%d`, closed/rejected `%d`",
digest.PRLifecycle.Open,
digest.PRLifecycle.Merged,
digest.PRLifecycle.ClosedOrRejected,
))
}
}
if digest.PRReviewAudit != nil {
if isChineseLang(lang) {
lines = append(lines, fmt.Sprintf("- Review 判别:已归因 `%d`,已被 review `%d`,未被 review `%d`,待重新 review `%d`,正式 Review `%d`",
digest.PRReviewAudit.Audited,
digest.PRReviewAudit.Reviewed,
digest.PRReviewAudit.Unreviewed,
digest.PRReviewAudit.NeedsReReview,
digest.PRReviewAudit.FormalReviews,
))
} else {
lines = append(lines, fmt.Sprintf("- Review audit: audited `%d`, reviewed `%d`, unreviewed `%d`, needs re-review `%d`, formal reviews `%d`",
digest.PRReviewAudit.Audited,
digest.PRReviewAudit.Reviewed,
digest.PRReviewAudit.Unreviewed,
digest.PRReviewAudit.NeedsReReview,
digest.PRReviewAudit.FormalReviews,
))
}
}
if digest.RepositoryURL != "" {
if isChineseLang(lang) {
lines = append(lines, "- GitLink 仓库:"+digest.RepositoryURL)
} else {
lines = append(lines, "- GitLink repository: "+digest.RepositoryURL)
}
}
if digest.DocURL != "" {
if isChineseLang(lang) {
lines = append(lines, "- 飞书报告:"+digest.DocURL)
} else {
lines = append(lines, "- Feishu report: "+digest.DocURL)
}
}
if _, err := fmt.Fprintln(w, strings.Join(lines, "\n")); err != nil {
return err
}
if len(digest.AttentionItems) > 0 {
heading := "Attention"
if isChineseLang(lang) {
heading = "需要关注"
}
if _, err := fmt.Fprintf(w, "\n## %s\n\n%s\n", heading, bulletList(localizeFeishuLines(digest.AttentionItems, lang), 8)); err != nil {
return err
}
}
if lines := riskSourceLines(digest.PRRiskSources); len(lines) > 0 {
heading := feishuLabel(lang, "risk_sources")
if _, err := fmt.Fprintf(w, "\n## %s\n\n%s\n", heading, bulletList(lines, 8)); err != nil {
return err
}
}
if len(digest.NextSteps) > 0 {
heading := "Suggested next steps"
if isChineseLang(lang) {
heading = "建议下一步"
}
if _, err := fmt.Fprintf(w, "\n## %s\n\n%s\n", heading, bulletList(localizeFeishuLines(digest.NextSteps, lang), 8)); err != nil {
return err
}
}
_, err := fmt.Fprintf(w, "\n> %s\n", localizedBoundary(digest, lang))
return err
}
func digestMarkdownLines(digest RoleDigest, lang string) []string {
if isChineseLang(lang) {
return []string{
fmt.Sprintf("- 报告分数:`%d`", digest.ReportScore),
fmt.Sprintf("- 风险等级:`%s`", firstNonEmpty(digest.RiskLevel, "unknown")),
fmt.Sprintf("- 已分析 Issue`%d`,其中高风险 `%d`,信息缺失 `%d`", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo),
fmt.Sprintf("- 已分析 PR`%d`,其中高风险 `%d`", digest.PRTotal, digest.PRHighRisk),
"- " + feishuLabel(lang, "analysis_scope"),
}
}
return []string{
fmt.Sprintf("- Report score: `%d`", digest.ReportScore),
fmt.Sprintf("- Risk level: `%s`", firstNonEmpty(digest.RiskLevel, "unknown")),
fmt.Sprintf("- Issues analyzed: `%d`, including `%d` high risk and `%d` missing info", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo),
fmt.Sprintf("- Pull requests analyzed: `%d`, including `%d` high risk", digest.PRTotal, digest.PRHighRisk),
"- " + feishuLabel(lang, "analysis_scope"),
}
}
func writeDigestTable(w io.Writer, digest RoleDigest, lang string) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
header := "ROLE\tREPOSITORY\tRISK\tSCORE\tISSUES_ANALYZED\tHIGH_RISK_ISSUES\tPRS_ANALYZED\tHIGH_RISK_PRS\tOPEN_PRS\tMERGED_PRS\tCLOSED_PRS\tREVIEWED_PRS\tUNREVIEWED_PRS\tNEEDS_RE_REVIEW\tATTENTION"
if isChineseLang(lang) {
header = "角色\t仓库\t风险\t分数\t已分析Issue\t高风险Issue\t已分析PR\t高风险PR\t开放PR\t已合并PR\t已关闭PR\t已Review PR\t未Review PR\t待重新Review\t关注项"
}
if _, err := fmt.Fprintln(tw, header); err != nil {
return err
}
openPRs, mergedPRs, closedPRs := 0, 0, 0
if digest.PRLifecycle != nil {
openPRs = digest.PRLifecycle.Open
mergedPRs = digest.PRLifecycle.Merged
closedPRs = digest.PRLifecycle.ClosedOrRejected
}
reviewedPRs, unreviewedPRs, needsReReviewPRs := 0, 0, 0
if digest.PRReviewAudit != nil {
reviewedPRs = digest.PRReviewAudit.Reviewed
unreviewedPRs = digest.PRReviewAudit.Unreviewed
needsReReviewPRs = digest.PRReviewAudit.NeedsReReview
}
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n",
digest.Role,
digest.Repository,
digest.RiskLevel,
digest.ReportScore,
digest.IssueTotal,
digest.IssueHighRisk,
digest.PRTotal,
digest.PRHighRisk,
openPRs,
mergedPRs,
closedPRs,
reviewedPRs,
unreviewedPRs,
needsReReviewPRs,
len(digest.AttentionItems),
); err != nil {
return err
}
return tw.Flush()
}
func riskSourceLines(sources map[string]int) []string {
if len(sources) == 0 {
return nil
}
keys := make([]string, 0, len(sources))
for key := range sources {
keys = append(keys, key)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
lines = append(lines, fmt.Sprintf("%s: %d", key, sources[key]))
}
return lines
}
func reviewAuditActorLines(audit *workflow.RepoPRReviewAudit, lang string) []string {
if audit == nil {
return nil
}
return []string{
fmt.Sprintf("%s: %d", feishuLabel(lang, "reviewer_comments"), audit.ReviewerComments),
fmt.Sprintf("%s: %d", feishuLabel(lang, "submitter_comments"), audit.SubmitterComments),
fmt.Sprintf("%s: %d", feishuLabel(lang, "participant_comments"), audit.ParticipantComments),
fmt.Sprintf("%s: %d", feishuLabel(lang, "system_events"), audit.SystemEvents),
}
}
func digestHealth(report workflow.RepoReportResult) (*int, string) {
if report.Health == nil {
return nil, ""
}
score := report.Health.HealthScore
return &score, report.Health.RiskLevel
}
func gitlinkRepoURL(repository string) string {
repository = strings.Trim(strings.TrimSpace(repository), "/")
if repository == "" || repository == "local" {
return ""
}
if strings.Contains(repository, "://") {
return repository
}
if !strings.Contains(repository, "/") {
return ""
}
return "https://www.gitlink.org.cn/" + repository
}
func limitStrings(values []string, limit int) []string {
if limit <= 0 || len(values) <= limit {
return values
}
return values[:limit]
}
func uniqueDigestStrings(values []string) []string {
seen := map[string]bool{}
result := []string{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}

View File

@ -0,0 +1,366 @@
package feishu
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
type DocExportOptions struct {
AppID string `json:"-"`
AppSecret string `json:"-"`
FolderToken string `json:"folder_token,omitempty"`
DocumentID string `json:"document_id,omitempty"`
WikiURL string `json:"wiki_url,omitempty"`
WikiNodeToken string `json:"wiki_node_token,omitempty"`
Title string `json:"title"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
}
type DocExportOutput struct {
Mode string `json:"mode"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
TargetType string `json:"target_type"`
Operation string `json:"operation"`
Title string `json:"title"`
DocumentID string `json:"document_id,omitempty"`
DocumentURL string `json:"document_url,omitempty"`
WikiNodeToken string `json:"wiki_node_token,omitempty"`
WikiNode *WikiNodeSummary `json:"wiki_node,omitempty"`
BlockCount int `json:"block_count"`
TokenExpire int `json:"token_expire,omitempty"`
RevisionID int `json:"revision_id,omitempty"`
Preview string `json:"preview,omitempty"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
type WikiNodeSummary struct {
NodeType string `json:"node_type,omitempty"`
ObjType string `json:"obj_type,omitempty"`
Title string `json:"title,omitempty"`
}
type DocBlock map[string]interface{}
func docExportOptionsFromContext(ctx *common.RuntimeContext) (DocExportOptions, error) {
opts := DocExportOptions{
AppID: firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")),
AppSecret: firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET")),
FolderToken: firstNonEmpty(ctx.Arg("folder-token"), os.Getenv("FEISHU_FOLDER_TOKEN"), os.Getenv("FEISHU_DOC_FOLDER_TOKEN")),
DocumentID: firstNonEmpty(ctx.Arg("document-id"), os.Getenv("FEISHU_DOCUMENT_ID")),
WikiURL: firstNonEmpty(ctx.Arg("wiki-url"), os.Getenv("FEISHU_WIKI_URL")),
WikiNodeToken: firstNonEmpty(ctx.Arg("wiki-node-token"), os.Getenv("FEISHU_WIKI_NODE_TOKEN")),
Title: strings.TrimSpace(ctx.Arg("title")),
Send: parseBool(ctx.Arg("send")),
DryRun: parseBool(ctx.Arg("dry-run")),
}
if opts.WikiNodeToken == "" && opts.WikiURL != "" {
opts.WikiNodeToken = wikiNodeTokenFromURL(opts.WikiURL)
}
if opts.DocumentID == "" && opts.WikiURL != "" {
opts.DocumentID = docxTokenFromURL(opts.WikiURL)
}
if opts.Send && opts.DryRun {
return DocExportOptions{}, fmt.Errorf("--send and --dry-run cannot be used together")
}
if opts.Send {
if strings.TrimSpace(opts.AppID) == "" {
return DocExportOptions{}, fmt.Errorf("--send requires --app-id or FEISHU_APP_ID")
}
if strings.TrimSpace(opts.AppSecret) == "" {
return DocExportOptions{}, fmt.Errorf("--send requires --app-secret or FEISHU_APP_SECRET")
}
if opts.FolderToken == "" && opts.DocumentID == "" && opts.WikiNodeToken == "" {
return DocExportOptions{}, fmt.Errorf("--send requires --folder-token, --document-id, --wiki-url, or --wiki-node-token")
}
}
return opts, nil
}
func exportDocOrPreview(ctx *common.RuntimeContext, opts DocExportOptions, report workflow.RepoReportResult, lang string) error {
title := firstNonEmpty(opts.Title, "GitLink workflow report: "+report.Repository)
markdown, err := workflow.RenderRepoReport(report, "markdown", lang)
if err != nil {
return err
}
blocks := BuildDocBlocks(report, lang)
output := DocExportOutput{
Mode: "preview",
Send: opts.Send,
DryRun: !opts.Send,
TargetType: docTargetType(opts),
Operation: docOperation(opts),
Title: title,
DocumentID: redactToken(opts.DocumentID),
DocumentURL: redactResourceURL(firstNonEmpty(opts.WikiURL)),
BlockCount: len(blocks),
Preview: markdown,
}
if opts.WikiNodeToken != "" {
output.WikiNodeToken = redactToken(opts.WikiNodeToken)
}
if !opts.Send {
return renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "markdown"))
}
client := NewOpenAPIClient(http.DefaultClient)
token, err := client.TenantAccessToken(context.Background(), opts.AppID, opts.AppSecret)
if err != nil {
output.Diagnostics = append(output.Diagnostics, diagnoseOpenAPIError(err, "docx", "tenant_access_token"))
_ = renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return err
}
output.TokenExpire = token.Expire
documentID := opts.DocumentID
if opts.WikiNodeToken != "" {
node, err := client.GetWikiNode(context.Background(), token.Value, opts.WikiNodeToken)
if err != nil {
output.Diagnostics = append(output.Diagnostics, diagnoseOpenAPIError(err, "docx", "wiki node"))
_ = renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return err
}
output.TargetType = "wiki"
if node.ObjType != "" && node.ObjType != "docx" {
output.Diagnostics = append(output.Diagnostics, "wiki node object type is not supported; expected docx")
_ = renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return fmt.Errorf("Feishu wiki node object type %q is not supported; expected docx", node.ObjType)
}
documentID = node.ObjToken
output.DocumentID = redactToken(documentID)
output.WikiNode = &WikiNodeSummary{
NodeType: node.NodeType,
ObjType: node.ObjType,
Title: node.Title,
}
if output.DocumentURL == "" {
output.DocumentURL = redactResourceURL(node.URL)
}
}
if documentID == "" {
created, err := client.CreateDocument(context.Background(), token.Value, opts.FolderToken, title)
if err != nil {
output.Diagnostics = append(output.Diagnostics, diagnoseOpenAPIError(err, "docx", "folder"))
_ = renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return err
}
documentID = created.DocumentID
output.DocumentID = redactToken(created.DocumentID)
output.DocumentURL = redactResourceURL(created.URL)
output.RevisionID = created.RevisionID
output.Operation = "create"
}
createdBlocks, err := client.CreateBlocks(context.Background(), token.Value, documentID, documentID, blocks)
if err != nil {
output.Diagnostics = append(output.Diagnostics, diagnoseOpenAPIError(err, "docx", output.TargetType))
output.Diagnostics = append(output.Diagnostics, "required permission: app can edit the target DocX/Wiki page, or create documents in the target folder")
_ = renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return fmt.Errorf("%w\nhint: grant the Feishu self-built app edit access to the target DocX/Wiki page, or export to a folder where the app has document creation permission", err)
}
if createdBlocks.RevisionID != 0 {
output.RevisionID = createdBlocks.RevisionID
}
output.Mode = "sent"
output.DryRun = false
output.Preview = ""
return renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
}
func BuildDocBlocks(report workflow.RepoReportResult, lang string) []DocBlock {
healthScore := "N/A"
healthRisk := "N/A"
if report.Health != nil {
healthScore = fmt.Sprintf("%d", report.Health.HealthScore)
healthRisk = report.Health.RiskLevel
}
blocks := []DocBlock{
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_title"), report.Repository)),
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_report_score"), report.ReportScore)),
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_risk"), firstNonEmpty(report.RiskLevel, "unknown"))),
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_health"), healthScore, healthRisk)),
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_issues"), report.IssueSummary.Total, report.IssueSummary.HighRisk, report.IssueSummary.MissingInfo)),
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_prs"), report.PRSummary.Total, report.PRSummary.HighRisk)),
textBlock(feishuLabel(lang, "analysis_scope")),
}
if report.PRLifecycle != nil {
blocks = append(blocks, textBlock(fmt.Sprintf(
"%s=%d; %s=%d; %s=%d",
feishuLabel(lang, "open_prs"), report.PRLifecycle.Open,
feishuLabel(lang, "merged_prs"), report.PRLifecycle.Merged,
feishuLabel(lang, "closed_prs"), report.PRLifecycle.ClosedOrRejected,
)))
}
if report.PRReviewAudit != nil {
blocks = append(blocks, textBlock(fmt.Sprintf(
"%s=%d; %s=%d; %s=%d; %s=%d; %s=%d",
feishuLabel(lang, "review_audited"), report.PRReviewAudit.Audited,
feishuLabel(lang, "reviewed_prs"), report.PRReviewAudit.Reviewed,
feishuLabel(lang, "unreviewed_prs"), report.PRReviewAudit.Unreviewed,
feishuLabel(lang, "needs_re_review"), report.PRReviewAudit.NeedsReReview,
feishuLabel(lang, "formal_reviews"), report.PRReviewAudit.FormalReviews,
)))
blocks = append(blocks, textBlock(feishuLabel(lang, "review_actor_attribution")+":\n"+joinLines(reviewAuditActorLines(report.PRReviewAudit, lang), 6)))
}
if len(report.PRSummary.ReviewFocus) > 0 {
blocks = append(blocks, textBlock(feishuLabel(lang, "doc_review_focus")+":\n"+joinLines(localizeFeishuLines(report.PRSummary.ReviewFocus, lang), 6)))
}
if len(report.Recommendations) > 0 {
blocks = append(blocks, textBlock(feishuLabel(lang, "doc_recommendations")+":\n"+joinLines(localizeFeishuLines(report.Recommendations, lang), 8)))
}
if len(report.Reasoning) > 0 {
blocks = append(blocks, textBlock(feishuLabel(lang, "doc_reasoning")+":\n"+joinLines(localizeFeishuLines(report.Reasoning, lang), 8)))
}
blocks = append(blocks, textBlock(fmt.Sprintf(feishuLabel(lang, "doc_source"), firstNonEmpty(report.Source, "workflow-json"))))
return blocks
}
func textBlock(content string) DocBlock {
return DocBlock{
"block_type": 2,
"text": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"text_run": map[string]interface{}{
"content": content,
},
},
},
},
}
}
func renderDocExportOutput(w io.Writer, output DocExportOutput, format string) error {
switch normalizeFormat(format) {
case "json":
return writeJSON(w, output)
case "table":
return writeDocExportTable(w, output)
default:
return writeDocExportMarkdown(w, output)
}
}
func writeDocExportMarkdown(w io.Writer, output DocExportOutput) error {
if _, err := fmt.Fprintf(w, "# Feishu Doc Export\n\n"); err != nil {
return err
}
lines := []string{
fmt.Sprintf("- Mode: `%s`", output.Mode),
fmt.Sprintf("- Send: `%t`", output.Send),
fmt.Sprintf("- Dry run: `%t`", output.DryRun),
fmt.Sprintf("- Target: `%s`", output.TargetType),
fmt.Sprintf("- Operation: `%s`", output.Operation),
fmt.Sprintf("- Title: `%s`", output.Title),
fmt.Sprintf("- Blocks: `%d`", output.BlockCount),
}
if output.DocumentID != "" {
lines = append(lines, fmt.Sprintf("- Document ID: `%s`", output.DocumentID))
}
if output.DocumentURL != "" {
lines = append(lines, fmt.Sprintf("- Document URL: %s", output.DocumentURL))
}
if _, err := fmt.Fprintln(w, strings.Join(lines, "\n")); err != nil {
return err
}
for _, diagnostic := range output.Diagnostics {
if _, err := fmt.Fprintf(w, "- Diagnostic: %s\n", diagnostic); err != nil {
return err
}
}
if output.Preview != "" {
if _, err := fmt.Fprint(w, "\n## Preview\n\n"); err != nil {
return err
}
_, err := fmt.Fprint(w, output.Preview)
return err
}
return nil
}
func writeDocExportTable(w io.Writer, output DocExportOutput) error {
_, err := fmt.Fprintf(w, "MODE\tSEND\tDRY_RUN\tTARGET\tOPERATION\tBLOCKS\tDOCUMENT\n%s\t%t\t%t\t%s\t%s\t%d\t%s\n",
output.Mode,
output.Send,
output.DryRun,
output.TargetType,
output.Operation,
output.BlockCount,
firstNonEmpty(output.DocumentURL, output.DocumentID),
)
return err
}
func docTargetType(opts DocExportOptions) string {
switch {
case opts.WikiNodeToken != "":
return "wiki"
case opts.DocumentID != "":
return "docx"
case opts.FolderToken != "":
return "folder"
default:
return "preview"
}
}
func docOperation(opts DocExportOptions) string {
if opts.FolderToken != "" && opts.DocumentID == "" && opts.WikiNodeToken == "" {
return "create"
}
if opts.DocumentID != "" || opts.WikiNodeToken != "" {
return "append"
}
return "preview"
}
func wikiNodeTokenFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for i, part := range parts {
if part == "wiki" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
func docxTokenFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for i, part := range parts {
if part == "docx" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
func joinLines(values []string, limit int) string {
if limit <= 0 || limit > len(values) {
limit = len(values)
}
lines := make([]string, 0, limit)
for _, value := range values[:limit] {
value = strings.TrimSpace(value)
if value == "" {
continue
}
lines = append(lines, "- "+value)
}
return strings.Join(lines, "\n")
}

464
shortcuts/feishu/feishu.go Normal file
View File

@ -0,0 +1,464 @@
package feishu
import (
"fmt"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
const (
defaultInclude = "issues,prs,contributors,health"
defaultTables = "reports,issues,prs,contributors,tasks"
defaultLang = "en"
)
// Shortcuts returns Feishu export shortcuts. Commands default to local preview;
// network delivery requires an explicit --send flag.
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return []*common.Shortcut{
newBotTestShortcut(),
newNotifyShortcut(),
newWeeklyReportShortcut(),
newOwnerDigestShortcut(),
newContributorDigestShortcut(),
newAppCheckShortcut(),
newDocCheckShortcut(),
newBitableCheckShortcut(),
newTaskCheckShortcut(),
newDocExportShortcut(),
newBitableSchemaShortcut(),
newBitableRecordsShortcut(),
newBitableSyncShortcut(),
newTaskPreviewShortcut(),
newTaskCreateShortcut(),
}
}
func newAppCheckShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "app-check",
Description: "Check Feishu custom bot and self-built app configuration without writing resources",
Flags: []common.Flag{
{Name: "webhook-url", Usage: "Feishu custom bot webhook URL. Defaults to FEISHU_WEBHOOK_URL"},
{Name: "secret", Usage: "Feishu custom bot signing secret. Defaults to FEISHU_WEBHOOK_SECRET"},
{Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"},
{Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"},
{Name: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"},
},
Run: runAppCheck,
}
}
func newDocCheckShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "doc-check",
Description: "Check Feishu DocX/Wiki export configuration without writing documents",
Flags: []common.Flag{
{Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"},
{Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"},
{Name: "folder-token", Usage: "Feishu folder token for creating a new DocX. Defaults to FEISHU_FOLDER_TOKEN"},
{Name: "document-id", Usage: "Existing Feishu DocX document ID. Defaults to FEISHU_DOCUMENT_ID"},
{Name: "wiki-url", Usage: "Existing Feishu Wiki URL. Defaults to FEISHU_WIKI_URL"},
{Name: "wiki-node-token", Usage: "Existing Feishu Wiki node token. Defaults to FEISHU_WIKI_NODE_TOKEN"},
{Name: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"},
},
Run: runDocCheck,
}
}
func newBitableCheckShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "bitable-check",
Description: "Check Feishu Base/Bitable sync configuration and table readiness without writing records",
Flags: []common.Flag{
{Name: "tables", Usage: "Comma-separated tables: reports,issues,prs,contributors,tasks", Default: defaultTables},
{Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"},
{Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"},
{Name: "base-app-token", Usage: "Feishu Base app token. Defaults to FEISHU_BASE_APP_TOKEN"},
{Name: "report-table-id", Usage: "Reports table ID. Defaults to FEISHU_REPORT_TABLE_ID"},
{Name: "issue-table-id", Usage: "Issues table ID. Defaults to FEISHU_ISSUE_TABLE_ID"},
{Name: "pr-table-id", Usage: "Pull requests table ID. Defaults to FEISHU_PR_TABLE_ID"},
{Name: "contributor-table-id", Usage: "Contributors table ID. Defaults to FEISHU_CONTRIBUTOR_TABLE_ID"},
{Name: "task-table-id", Usage: "Tasks table ID. Defaults to FEISHU_TASK_TABLE_ID"},
{Name: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"},
},
Run: runBitableCheck,
}
}
func newTaskCheckShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "task-check",
Description: "Check Feishu Task configuration without creating tasks",
Flags: []common.Flag{
{Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"},
{Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"},
{Name: "task-project-id", Usage: "Feishu task project ID. Defaults to FEISHU_TASK_PROJECT_ID"},
{Name: "task-section-id", Usage: "Feishu task section ID. Defaults to FEISHU_TASK_SECTION_ID"},
{Name: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"},
},
Run: runTaskCheck,
}
}
func newBotTestShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "bot-test",
Description: "Preview or send a Feishu custom bot test card",
Flags: append(deliveryFlags(),
common.Flag{Name: "title", Usage: "Card title", Default: "GitLink Feishu integration test"},
common.Flag{Name: "message", Usage: "Card message", Default: "gitlink-cli can build and send Feishu custom bot cards."},
common.Flag{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
),
Run: runBotTest,
}
}
func newNotifyShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "notify",
Description: "Preview or send a Feishu card from workflow JSON",
Flags: append(deliveryFlags(),
common.Flag{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true},
common.Flag{Name: "include", Usage: "Comma-separated sections: issues,prs,contributors,health", Default: defaultInclude},
common.Flag{Name: "title", Usage: "Override card title"},
common.Flag{Name: "doc-url", Usage: "Feishu DocX or Wiki URL to include in the card"},
common.Flag{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
),
Run: runNotify,
}
}
func newWeeklyReportShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "weekly-report",
Description: "Render a weekly report from workflow JSON and optionally send it to Feishu",
Flags: append(deliveryFlags(),
common.Flag{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true},
common.Flag{Name: "include", Usage: "Comma-separated sections: issues,prs,contributors,health", Default: defaultInclude},
common.Flag{Name: "title", Usage: "Override card title"},
common.Flag{Name: "doc-url", Usage: "Feishu DocX or Wiki URL to include in the card"},
common.Flag{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
),
Run: runWeeklyReport,
}
}
func newOwnerDigestShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "owner-digest",
Description: "Preview or send a role-aware owner digest from workflow JSON",
Flags: append(deliveryFlags(),
common.Flag{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true},
common.Flag{Name: "title", Usage: "Override card title"},
common.Flag{Name: "doc-url", Usage: "Feishu DocX or Wiki URL to include in the card"},
common.Flag{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
),
Run: runOwnerDigest,
}
}
func newContributorDigestShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "contributor-digest",
Description: "Preview or send a role-oriented contributor digest from workflow JSON",
Flags: append(deliveryFlags(),
common.Flag{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true},
common.Flag{Name: "title", Usage: "Override card title"},
common.Flag{Name: "doc-url", Usage: "Feishu DocX or Wiki URL to include in the card"},
common.Flag{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
),
Run: runContributorDigest,
}
}
func newDocExportShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "doc-export",
Description: "Experimental: preview or export a workflow report to Feishu DocX or Wiki",
Flags: []common.Flag{
{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true},
{Name: "title", Usage: "Document title"},
{Name: "folder-token", Usage: "Feishu folder token for creating a new DocX. Defaults to FEISHU_DOC_FOLDER_TOKEN"},
{Name: "document-id", Usage: "Existing Feishu DocX document ID. Defaults to FEISHU_DOCUMENT_ID"},
{Name: "wiki-url", Usage: "Existing Feishu Wiki URL. Defaults to FEISHU_WIKI_URL"},
{Name: "wiki-node-token", Usage: "Existing Feishu Wiki node token. Defaults to FEISHU_WIKI_NODE_TOKEN"},
{Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"},
{Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"},
{Name: "send", Usage: "Create or update a Feishu document. Without --send, preview locally", Bool: true, Default: "false"},
{Name: "dry-run", Usage: "Force local preview. Cannot be combined with --send", Bool: true, Default: "false"},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
},
Run: runDocExport,
}
}
func newBitableSchemaShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "bitable-schema",
Description: "Generate a dry-run Feishu Bitable schema",
Flags: []common.Flag{
{Name: "tables", Usage: "Comma-separated tables: reports,issues,prs,contributors,tasks", Default: defaultTables},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
},
Run: runBitableSchema,
}
}
func newBitableRecordsShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "bitable-records",
Description: "Generate dry-run Feishu Bitable-ready records from workflow JSON",
Flags: []common.Flag{
{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true},
{Name: "tables", Usage: "Comma-separated tables: reports,issues,prs,contributors,tasks", Default: defaultTables},
{Name: "doc-url", Usage: "Feishu DocX or Wiki URL to include in generated records"},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
},
Run: runBitableRecords,
}
}
func newBitableSyncShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "bitable-sync",
Description: "Experimental: preview or sync workflow records to Feishu Bitable",
Flags: []common.Flag{
{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true},
{Name: "tables", Usage: "Comma-separated tables: reports,issues,prs,contributors,tasks", Default: defaultTables},
{Name: "doc-url", Usage: "Feishu DocX or Wiki URL to include in generated records"},
{Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"},
{Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"},
{Name: "base-app-token", Usage: "Feishu Base app token. Defaults to FEISHU_BASE_APP_TOKEN"},
{Name: "report-table-id", Usage: "Reports table ID. Defaults to FEISHU_REPORT_TABLE_ID"},
{Name: "issue-table-id", Usage: "Issues table ID. Defaults to FEISHU_ISSUE_TABLE_ID"},
{Name: "pr-table-id", Usage: "Pull requests table ID. Defaults to FEISHU_PR_TABLE_ID"},
{Name: "contributor-table-id", Usage: "Contributors table ID. Defaults to FEISHU_CONTRIBUTOR_TABLE_ID"},
{Name: "task-table-id", Usage: "Tasks table ID. Defaults to FEISHU_TASK_TABLE_ID"},
{Name: "send", Usage: "Write to Feishu Bitable. Without --send, preview locally", Bool: true, Default: "false"},
{Name: "dry-run", Usage: "Force local preview. Cannot be combined with --send", Bool: true, Default: "false"},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
},
Run: runBitableSync,
}
}
func newTaskPreviewShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "task-preview",
Description: "Preview Feishu task candidates from workflow JSON",
Flags: []common.Flag{
{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true},
{Name: "doc-url", Usage: "Feishu DocX or Wiki URL to include in generated tasks"},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
},
Run: runTaskPreview,
}
}
func newTaskCreateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "task-create",
Description: "Experimental: preview or create Feishu tasks from workflow JSON",
Flags: []common.Flag{
{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true},
{Name: "doc-url", Usage: "Feishu DocX or Wiki URL to include in generated tasks"},
{Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"},
{Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"},
{Name: "task-project-id", Usage: "Feishu task project ID. Defaults to FEISHU_TASK_PROJECT_ID"},
{Name: "task-section-id", Usage: "Feishu task section ID. Defaults to FEISHU_TASK_SECTION_ID"},
{Name: "send", Usage: "Create Feishu tasks. Without --send, preview locally", Bool: true, Default: "false"},
{Name: "dry-run", Usage: "Force local preview. Cannot be combined with --send", Bool: true, Default: "false"},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
},
Run: runTaskCreate,
}
}
func deliveryFlags() []common.Flag {
return []common.Flag{
{Name: "webhook-url", Usage: "Feishu custom bot webhook URL. Defaults to FEISHU_WEBHOOK_URL"},
{Name: "secret", Usage: "Feishu custom bot signing secret. Defaults to FEISHU_WEBHOOK_SECRET"},
{Name: "send", Usage: "Send to Feishu. Without --send, commands only preview locally", Bool: true, Default: "false"},
{Name: "dry-run", Usage: "Force local preview. Cannot be combined with --send", Bool: true, Default: "false"},
}
}
func runBotTest(ctx *common.RuntimeContext) error {
opts, err := deliveryOptionsFromContext(ctx)
if err != nil {
return err
}
card := BuildBotTestCard(ctx.Arg("title"), ctx.Arg("message"), normalizeLang(ctx.Arg("lang")))
payload := NewInteractivePayload(card)
return deliverOrPreview(ctx, opts, payload, "")
}
func runNotify(ctx *common.RuntimeContext) error {
opts, err := deliveryOptionsFromContext(ctx)
if err != nil {
return err
}
report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang")))
if err != nil {
return err
}
include := parseList(firstNonEmpty(ctx.Arg("include"), defaultInclude))
card := BuildWorkflowCard(report, include, firstNonEmpty(ctx.Arg("title"), ""), normalizeLang(ctx.Arg("lang")), ctx.Arg("doc-url"))
payload := NewInteractivePayload(card)
return deliverOrPreview(ctx, opts, payload, "")
}
func runWeeklyReport(ctx *common.RuntimeContext) error {
opts, err := deliveryOptionsFromContext(ctx)
if err != nil {
return err
}
report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang")))
if err != nil {
return err
}
if opts.Send {
include := parseList(firstNonEmpty(ctx.Arg("include"), defaultInclude))
card := BuildWorkflowCard(report, include, firstNonEmpty(ctx.Arg("title"), "GitLink weekly workflow report"), normalizeLang(ctx.Arg("lang")), ctx.Arg("doc-url"))
return deliverOrPreview(ctx, opts, NewInteractivePayload(card), "")
}
rendered, err := workflow.RenderRepoReport(report, formatOrDefault(ctx, "markdown"), normalizeLang(ctx.Arg("lang")))
if err != nil {
return err
}
_, err = fmt.Fprint(os.Stdout, rendered)
return err
}
func runOwnerDigest(ctx *common.RuntimeContext) error {
opts, err := deliveryOptionsFromContext(ctx)
if err != nil {
return err
}
report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang")))
if err != nil {
return err
}
digest := BuildOwnerDigest(report, ctx.Arg("doc-url"))
if opts.Send {
title := firstNonEmpty(ctx.Arg("title"), "GitLink owner digest: "+report.Repository)
return deliverOrPreview(ctx, opts, NewInteractivePayload(BuildOwnerDigestCard(digest, title, normalizeLang(ctx.Arg("lang")))), "")
}
return renderDigest(os.Stdout, digest, formatOrDefault(ctx, "markdown"), normalizeLang(ctx.Arg("lang")))
}
func runContributorDigest(ctx *common.RuntimeContext) error {
opts, err := deliveryOptionsFromContext(ctx)
if err != nil {
return err
}
report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang")))
if err != nil {
return err
}
digest := BuildContributorDigest(report, ctx.Arg("doc-url"))
if opts.Send {
title := firstNonEmpty(ctx.Arg("title"), "GitLink contributor digest: "+report.Repository)
return deliverOrPreview(ctx, opts, NewInteractivePayload(BuildContributorDigestCard(digest, title, normalizeLang(ctx.Arg("lang")))), "")
}
return renderDigest(os.Stdout, digest, formatOrDefault(ctx, "markdown"), normalizeLang(ctx.Arg("lang")))
}
func runAppCheck(ctx *common.RuntimeContext) error {
return runFeishuAppCheck(ctx)
}
func runDocCheck(ctx *common.RuntimeContext) error {
return runFeishuDocCheck(ctx)
}
func runBitableCheck(ctx *common.RuntimeContext) error {
return runFeishuBitableCheck(ctx)
}
func runTaskCheck(ctx *common.RuntimeContext) error {
return runFeishuTaskCheck(ctx)
}
func runDocExport(ctx *common.RuntimeContext) error {
opts, err := docExportOptionsFromContext(ctx)
if err != nil {
return err
}
report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang")))
if err != nil {
return err
}
return exportDocOrPreview(ctx, opts, report, normalizeLang(ctx.Arg("lang")))
}
func runBitableSchema(ctx *common.RuntimeContext) error {
schema := BuildBitableSchema(parseList(firstNonEmpty(ctx.Arg("tables"), defaultTables)))
return renderBitableSchema(os.Stdout, schema, formatOrDefault(ctx, "markdown"))
}
func runBitableRecords(ctx *common.RuntimeContext) error {
report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang")))
if err != nil {
return err
}
records := BuildBitableRecords(report, parseList(firstNonEmpty(ctx.Arg("tables"), defaultTables)), ctx.Arg("doc-url"))
return renderBitableRecords(os.Stdout, records, formatOrDefault(ctx, "json"))
}
func runBitableSync(ctx *common.RuntimeContext) error {
opts, err := bitableSyncOptionsFromContext(ctx)
if err != nil {
return err
}
report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang")))
if err != nil {
return err
}
records := BuildBitableRecords(report, opts.Tables, ctx.Arg("doc-url"))
return syncBitableOrPreview(ctx, opts, records)
}
func runTaskPreview(ctx *common.RuntimeContext) error {
report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang")))
if err != nil {
return err
}
tasks := BuildTaskCandidatesLocalized(report, ctx.Arg("doc-url"), normalizeLang(ctx.Arg("lang")))
return renderTaskOutput(os.Stdout, taskPreviewOutput(tasks), formatOrDefault(ctx, "markdown"))
}
func runTaskCreate(ctx *common.RuntimeContext) error {
opts, err := taskCreateOptionsFromContext(ctx)
if err != nil {
return err
}
report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang")))
if err != nil {
return err
}
tasks := BuildTaskCandidatesLocalized(report, ctx.Arg("doc-url"), normalizeLang(ctx.Arg("lang")))
return createTasksOrPreview(ctx, opts, tasks)
}
func normalizeLang(lang string) string {
switch strings.TrimSpace(lang) {
case "zh-CN":
return "zh-CN"
default:
return defaultLang
}
}
func formatOrDefault(ctx *common.RuntimeContext, defaultFormat string) string {
if strings.TrimSpace(cmdutil.Format) == "" {
return defaultFormat
}
return ctx.Format
}

View File

@ -0,0 +1,602 @@
package feishu
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestShortcutsExposeExpectedCommands(t *testing.T) {
got := map[string]bool{}
for _, shortcut := range Shortcuts() {
got[shortcut.Name] = true
}
for _, name := range []string{"bot-test", "notify", "weekly-report", "owner-digest", "contributor-digest", "app-check", "doc-check", "bitable-check", "task-check", "doc-export", "bitable-schema", "bitable-records", "bitable-sync", "task-preview", "task-create"} {
if !got[name] {
t.Fatalf("Shortcuts missing %s", name)
}
}
}
func TestDeliveryOptionsRejectSendDryRun(t *testing.T) {
ctx := &common.RuntimeContext{Args: map[string]string{
"send": "true",
"dry-run": "true",
"webhook-url": "https://open.feishu.cn/open-apis/bot/v2/hook/test",
}}
_, err := deliveryOptionsFromContext(ctx)
if err == nil {
t.Fatal("expected --send --dry-run error")
}
}
func TestDeliveryOptionsRequireWebhookForSend(t *testing.T) {
ctx := &common.RuntimeContext{Args: map[string]string{"send": "true"}}
_, err := deliveryOptionsFromContext(ctx)
if err == nil {
t.Fatal("expected missing webhook error")
}
}
func TestRedactWebhookURL(t *testing.T) {
got := redactWebhookURL("https://open.feishu.cn/open-apis/bot/v2/hook/12345678-1234-1234-1234-123456789abc")
if strings.Contains(got, "1234-1234") || !strings.Contains(got, "https://open.feishu.cn/.../") {
t.Fatalf("redacted webhook URL leaked too much: %s", got)
}
}
func TestRedactTokenAndResourceURL(t *testing.T) {
if got := redactToken("abcdef1234567890"); got != "abcd...7890" {
t.Fatalf("redactToken = %q", got)
}
got := redactResourceURL("https://tenant.feishu.cn/wiki/NodeToken123456789?from=copy")
if strings.Contains(got, "Token123") || strings.Contains(got, "from=copy") {
t.Fatalf("redactResourceURL leaked token or query: %s", got)
}
}
func TestSignCustomBotRequestIsDeterministic(t *testing.T) {
first := SignCustomBotRequest(1710000000, "secret")
second := SignCustomBotRequest(1710000000, "secret")
if first == "" || first != second {
t.Fatalf("signature not deterministic: first=%q second=%q", first, second)
}
}
func TestBuildWorkflowCardIncludesDocButton(t *testing.T) {
report, err := readWorkflowReport(filepath.Join("..", "workflow", "testdata", "repo_report.json"), "en")
if err != nil {
t.Fatalf("readWorkflowReport returned error: %v", err)
}
report.PRSummary.RiskSources = map[string]int{"security-sensitive keyword": 2}
report.PRReviewAudit = &workflow.RepoPRReviewAudit{
Audited: 3,
Reviewed: 2,
Unreviewed: 1,
NeedsReReview: 1,
FormalReviews: 2,
ReviewerComments: 4,
SubmitterComments: 3,
ParticipantComments: 1,
SystemEvents: 2,
}
card := BuildWorkflowCard(report, parseList(defaultInclude), "", "en", "https://example.feishu.cn/wiki/node")
encoded, err := json.Marshal(card)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
if !strings.Contains(string(encoded), "Open Feishu report") {
t.Fatalf("card missing doc button: %s", string(encoded))
}
if !strings.Contains(string(encoded), "Issues analyzed") || !strings.Contains(string(encoded), "not repository totals") {
t.Fatalf("card missing analyzed-count boundary: %s", string(encoded))
}
if !strings.Contains(string(encoded), "PR risk rule sources") || !strings.Contains(string(encoded), "security-sensitive keyword: 2") {
t.Fatalf("card missing PR risk sources: %s", string(encoded))
}
if !strings.Contains(string(encoded), "Reviewed PRs") || !strings.Contains(string(encoded), "Needs re-review") || !strings.Contains(string(encoded), "Reviewer comments: 4") {
t.Fatalf("card missing PR review audit: %s", string(encoded))
}
}
func TestWebhookClientSendsPayload(t *testing.T) {
var sawTimestamp bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
var payload WebhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
sawTimestamp = payload.Timestamp != "" && payload.Sign != ""
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"msg":"success"}`))
}))
defer server.Close()
client := WebhookClient{
URL: server.URL,
Secret: "secret",
HTTP: server.Client(),
Now: func() time.Time { return time.Unix(1710000000, 0) },
}
resp, err := client.Send(context.Background(), NewInteractivePayload(BuildBotTestCard("", "", "en")))
if err != nil {
t.Fatalf("Send returned error: %v", err)
}
if resp.StatusCode != http.StatusOK || resp.Code != 0 {
t.Fatalf("response = %+v", resp)
}
if !sawTimestamp {
t.Fatal("signed payload missing timestamp/sign")
}
}
func TestReadWorkflowReportSupportsInputFixture(t *testing.T) {
report, err := readWorkflowReport(filepath.Join("..", "workflow", "testdata", "repo_report.json"), "en")
if err != nil {
t.Fatalf("readWorkflowReport returned error: %v", err)
}
if report.Repository != "Gitlink/gitlink-cli" || report.ReportScore == 0 {
t.Fatalf("report = %+v", report)
}
}
func TestReadWorkflowReportSupportsPowerShellUTF16Redirect(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "workflow", "testdata", "repo_report.json"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
utf16Data := []byte{0xFF, 0xFE}
for _, r := range string(raw) {
if r > 0xFFFF {
t.Fatalf("fixture contains non-BMP rune %q", r)
}
utf16Data = append(utf16Data, byte(r), byte(r>>8))
}
path := filepath.Join(t.TempDir(), "report.json")
if err := os.WriteFile(path, utf16Data, 0600); err != nil {
t.Fatalf("write UTF-16 fixture: %v", err)
}
report, err := readWorkflowReport(path, "en")
if err != nil {
t.Fatalf("readWorkflowReport returned error: %v", err)
}
if report.Repository != "Gitlink/gitlink-cli" {
t.Fatalf("Repository = %q", report.Repository)
}
}
func TestBitableSchemaAndRecords(t *testing.T) {
report, err := readWorkflowReport(filepath.Join("..", "workflow", "testdata", "repo_report.json"), "en")
if err != nil {
t.Fatalf("readWorkflowReport returned error: %v", err)
}
schema := BuildBitableSchema(parseList("issues,prs,reports"))
if len(schema.Tables) != 3 {
t.Fatalf("schema table count = %d", len(schema.Tables))
}
records := BuildBitableRecords(report, parseList("issues,prs,reports,tasks"), "https://example.feishu.cn/wiki/node")
if !records.DryRun {
t.Fatal("records must be dry-run")
}
if len(records.Tables["reports"]) != 1 {
t.Fatalf("reports records = %d, want 1", len(records.Tables["reports"]))
}
if len(records.Tables["tasks"]) == 0 {
t.Fatal("tasks records should be generated")
}
}
func TestOwnerAndContributorDigestMapping(t *testing.T) {
report := workflowReportFixture(t)
owner := BuildOwnerDigest(report, "https://tenant.feishu.cn/wiki/node")
if owner.Role != "owner" || owner.Repository != report.Repository {
t.Fatalf("owner digest = %+v", owner)
}
if owner.IssueTotal != report.IssueSummary.Total || owner.PRTotal != report.PRSummary.Total {
t.Fatalf("owner digest counts = %+v", owner)
}
report.PRReviewAudit = &workflow.RepoPRReviewAudit{Audited: 2, Reviewed: 1, Unreviewed: 1, NeedsReReview: 1, FormalReviews: 1}
owner = BuildOwnerDigest(report, "https://tenant.feishu.cn/wiki/node")
if owner.PRReviewAudit == nil || owner.PRReviewAudit.Reviewed != 1 {
t.Fatalf("owner digest missing review audit: %+v", owner)
}
contributor := BuildContributorDigest(report, "")
if contributor.Role != "contributor" {
t.Fatalf("contributor digest role = %q", contributor.Role)
}
if !strings.Contains(contributor.BoundaryDescription, "not personalized") {
t.Fatalf("contributor boundary missing personalization warning: %s", contributor.BoundaryDescription)
}
card := BuildOwnerDigestCard(owner, "", "en")
encoded, err := json.Marshal(card)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
if !strings.Contains(string(encoded), "Open GitLink repository") {
t.Fatalf("owner card missing repository button: %s", string(encoded))
}
if !strings.Contains(string(encoded), "Issues analyzed") || !strings.Contains(string(encoded), "not repository totals") {
t.Fatalf("owner card missing analyzed-count boundary: %s", string(encoded))
}
}
func TestTaskCandidatesAreStable(t *testing.T) {
report := workflowReportFixture(t)
tasks := BuildTaskCandidates(report, "https://tenant.feishu.cn/wiki/node")
if len(tasks) == 0 {
t.Fatal("expected task candidates")
}
seen := map[string]bool{}
for _, task := range tasks {
if task.UniqueKey == "" || seen[task.UniqueKey] {
t.Fatalf("unstable or duplicate task key: %+v", task)
}
seen[task.UniqueKey] = true
if task.Repository != report.Repository {
t.Fatalf("task repository = %q, want %q", task.Repository, report.Repository)
}
}
}
func TestTaskPreviewOutputCountsTasks(t *testing.T) {
report := workflowReportFixture(t)
tasks := BuildTaskCandidates(report, "")
output := taskPreviewOutput(tasks)
if output.TaskCount != len(tasks) {
t.Fatalf("TaskCount = %d, want %d", output.TaskCount, len(tasks))
}
if output.Send {
t.Fatal("preview output must not be marked as send")
}
if !output.DryRun {
t.Fatal("preview output must be dry-run")
}
}
func TestBitableSyncOptionsRejectSendDryRun(t *testing.T) {
ctx := &common.RuntimeContext{Args: map[string]string{
"send": "true",
"dry-run": "true",
"app-id": "cli_xxx",
"app-secret": "secret",
"base-app-token": "base",
}}
if _, err := bitableSyncOptionsFromContext(ctx); err == nil {
t.Fatal("expected --send --dry-run error")
}
}
func TestNormalizeBitableWriteFieldsFlattensStringSlices(t *testing.T) {
fields := normalizeBitableWriteFields(map[string]interface{}{
"unique_key": "issue:test",
"recommended_action": []string{"first", "second"},
"review_focus": []interface{}{"focus-a", "focus-b"},
"count": 2,
})
if fields["recommended_action"] != "first\nsecond" {
t.Fatalf("recommended_action = %#v", fields["recommended_action"])
}
if fields["review_focus"] != "focus-a\nfocus-b" {
t.Fatalf("review_focus = %#v", fields["review_focus"])
}
if fields["count"] != 2 {
t.Fatalf("count changed: %#v", fields["count"])
}
}
func TestBitableSyncMockHTTP(t *testing.T) {
report := workflowReportFixture(t)
records := BuildBitableRecords(report, []string{"reports"}, "")
var sawCreate bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal":
_, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`))
case r.Method == http.MethodPost && r.URL.Path == "/bitable/v1/apps/base_token/tables/tbl_report/records/search":
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"items":[]}}`))
case r.Method == http.MethodPost && r.URL.Path == "/bitable/v1/apps/base_token/tables/tbl_report/records":
sawCreate = true
var payload struct {
Fields map[string]interface{} `json:"fields"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode bitable payload: %v", err)
}
if payload.Fields["unique_key"] == "" {
t.Fatalf("payload missing unique_key: %+v", payload.Fields)
}
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"record":{"record_id":"rec_1234567890"}}}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
oldBaseURL := openAPIBaseURL
openAPIBaseURL = server.URL
defer func() { openAPIBaseURL = oldBaseURL }()
opts := BitableSyncOptions{
AppID: "cli_xxx",
AppSecret: "secret",
BaseAppToken: "base_token",
TableIDs: map[string]string{"reports": "tbl_report"},
Tables: []string{"reports"},
Send: true,
}
if err := syncBitableOrPreview(&common.RuntimeContext{}, opts, records); err != nil {
t.Fatalf("syncBitableOrPreview returned error: %v", err)
}
if !sawCreate {
t.Fatal("expected create request")
}
}
func TestAppCheckRemoteMockHTTP(t *testing.T) {
var sawToken bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal" {
sawToken = true
_, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`))
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
oldBaseURL := openAPIBaseURL
openAPIBaseURL = server.URL
defer func() { openAPIBaseURL = oldBaseURL }()
ctx := &common.RuntimeContext{Args: map[string]string{
"app-id": "cli_xxx",
"app-secret": "secret",
"webhook-url": "https://open.feishu.cn/open-apis/bot/v2/hook/test",
"remote": "true",
}}
if err := runFeishuAppCheck(ctx); err != nil {
t.Fatalf("runFeishuAppCheck returned error: %v", err)
}
if !sawToken {
t.Fatal("expected tenant token request")
}
}
func TestBitableCheckRemoteUsesSearchOnly(t *testing.T) {
var sawSearch bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal":
_, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`))
case r.Method == http.MethodPost && r.URL.Path == "/bitable/v1/apps/base_token/tables/tbl_report/records/search":
sawSearch = true
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"items":[]}}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
oldBaseURL := openAPIBaseURL
openAPIBaseURL = server.URL
defer func() { openAPIBaseURL = oldBaseURL }()
ctx := &common.RuntimeContext{Args: map[string]string{
"app-id": "cli_xxx",
"app-secret": "secret",
"base-app-token": "base_token",
"tables": "reports",
"report-table-id": "tbl_report",
"remote": "true",
}}
if err := runFeishuBitableCheck(ctx); err != nil {
t.Fatalf("runFeishuBitableCheck returned error: %v", err)
}
if !sawSearch {
t.Fatal("expected bitable search request")
}
}
func TestTaskCheckRemoteDoesNotCreateTask(t *testing.T) {
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal" {
_, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`))
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
oldBaseURL := openAPIBaseURL
openAPIBaseURL = server.URL
defer func() { openAPIBaseURL = oldBaseURL }()
ctx := &common.RuntimeContext{Args: map[string]string{
"app-id": "cli_xxx",
"app-secret": "secret",
"remote": "true",
}}
if err := runFeishuTaskCheck(ctx); err != nil {
t.Fatalf("runFeishuTaskCheck returned error: %v", err)
}
if requestCount != 1 {
t.Fatalf("expected only tenant token request, got %d requests", requestCount)
}
}
func TestTaskCreateOptionsRejectSendDryRun(t *testing.T) {
ctx := &common.RuntimeContext{Args: map[string]string{
"send": "true",
"dry-run": "true",
"app-id": "cli_xxx",
"app-secret": "secret",
}}
if _, err := taskCreateOptionsFromContext(ctx); err == nil {
t.Fatal("expected --send --dry-run error")
}
}
func TestTaskCreateMockHTTP(t *testing.T) {
var sawTask bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal":
_, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`))
case r.Method == http.MethodPost && r.URL.Path == "/task/v2/tasks":
sawTask = true
var payload struct {
Summary string `json:"summary"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode task payload: %v", err)
}
if payload.Summary == "" {
t.Fatal("task summary is empty")
}
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"task":{"guid":"task_guid_123456"}}}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
oldBaseURL := openAPIBaseURL
openAPIBaseURL = server.URL
defer func() { openAPIBaseURL = oldBaseURL }()
tasks := []TaskCandidate{{
UniqueKey: "task:test",
Title: "Review GitLink workflow report",
Description: "Workflow report task",
SourceType: "report",
SourceKey: "report-review",
Repository: "Gitlink/gitlink-cli",
Priority: "low",
TaskType: "report_review",
Status: "todo",
}}
opts := TaskCreateOptions{AppID: "cli_xxx", AppSecret: "secret", Send: true}
if err := createTasksOrPreview(&common.RuntimeContext{}, opts, tasks); err != nil {
t.Fatalf("createTasksOrPreview returned error: %v", err)
}
if !sawTask {
t.Fatal("expected task create request")
}
}
func TestTaskCreateTableShowsResults(t *testing.T) {
var out strings.Builder
output := TaskOutput{Results: []TaskCreateResult{{
UniqueKey: "task:test",
Title: "Review report",
TaskID: "task_guid_123456",
Created: true,
}}}
if err := renderTaskOutput(&out, output, "table"); err != nil {
t.Fatalf("renderTaskOutput returned error: %v", err)
}
rendered := out.String()
if !strings.Contains(rendered, "CREATED") || !strings.Contains(rendered, "task...3456") {
t.Fatalf("task table did not show result details: %s", rendered)
}
}
func TestWikiNodeTokenFromURL(t *testing.T) {
got := wikiNodeTokenFromURL("https://tenant.feishu.cn/wiki/NodeToken123?from=from_copylink")
if got != "NodeToken123" {
t.Fatalf("wikiNodeTokenFromURL = %q", got)
}
}
func TestDocExportOptionsRequireAppCredentialsForSend(t *testing.T) {
ctx := &common.RuntimeContext{Args: map[string]string{
"send": "true",
"wiki-url": "https://tenant.feishu.cn/wiki/NodeToken123",
"app-id": "",
"app-secret": "",
}}
_, err := docExportOptionsFromContext(ctx)
if err == nil {
t.Fatal("expected missing app credential error")
}
}
func TestOpenAPIClientDocExportFlow(t *testing.T) {
var sawBlocks bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal":
_, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`))
case r.Method == http.MethodGet && r.URL.Path == "/wiki/v2/spaces/get_node":
if r.URL.Query().Get("token") != "NodeToken123" {
t.Fatalf("wiki token = %q", r.URL.Query().Get("token"))
}
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"node":{"space_id":"space","node_token":"NodeToken123","obj_token":"doc_token","obj_type":"docx","node_type":"origin","title":"Report"}}}`))
case r.Method == http.MethodPost && r.URL.Path == "/docx/v1/documents/doc_token/blocks/doc_token/children":
sawBlocks = true
var payload struct {
Children []DocBlock `json:"children"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode blocks: %v", err)
}
if len(payload.Children) == 0 {
t.Fatal("no blocks in payload")
}
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"revision_id":9}}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
client := OpenAPIClient{BaseURL: server.URL, HTTP: server.Client()}
token, err := client.TenantAccessToken(context.Background(), "cli_xxx", "secret")
if err != nil {
t.Fatalf("TenantAccessToken returned error: %v", err)
}
node, err := client.GetWikiNode(context.Background(), token.Value, "NodeToken123")
if err != nil {
t.Fatalf("GetWikiNode returned error: %v", err)
}
if node.ObjToken != "doc_token" || node.ObjType != "docx" {
t.Fatalf("node = %+v", node)
}
blocks := BuildDocBlocks(workflowReportFixture(t), "en")
created, err := client.CreateBlocks(context.Background(), token.Value, node.ObjToken, node.ObjToken, blocks)
if err != nil {
t.Fatalf("CreateBlocks returned error: %v", err)
}
if created.RevisionID != 9 || !sawBlocks {
t.Fatalf("created = %+v sawBlocks=%t", created, sawBlocks)
}
}
func workflowReportFixture(t *testing.T) workflow.RepoReportResult {
t.Helper()
report, err := readWorkflowReport(filepath.Join("..", "workflow", "testdata", "repo_report.json"), "en")
if err != nil {
t.Fatalf("readWorkflowReport returned error: %v", err)
}
return report
}

215
shortcuts/feishu/l10n.go Normal file
View File

@ -0,0 +1,215 @@
package feishu
import (
"fmt"
"regexp"
"strings"
)
func isChineseLang(lang string) bool {
return strings.EqualFold(strings.TrimSpace(lang), "zh-CN")
}
func feishuLabel(lang string, key string) string {
if !isChineseLang(lang) {
return feishuLabelsEN[key]
}
if value := feishuLabelsZH[key]; value != "" {
return value
}
return feishuLabelsEN[key]
}
func localizeFeishuText(value string, lang string) string {
value = strings.TrimSpace(value)
if value == "" || !isChineseLang(lang) {
return value
}
if translated := knownFeishuTranslations[value]; translated != "" {
return translated
}
for _, pattern := range knownFeishuPatterns {
if match := pattern.re.FindStringSubmatch(value); len(match) > 1 {
return fmt.Sprintf(pattern.format, match[1])
}
}
return value
}
func localizeFeishuLines(values []string, lang string) []string {
if !isChineseLang(lang) {
return values
}
result := make([]string, 0, len(values))
for _, value := range values {
result = append(result, localizeFeishuText(value, lang))
}
return result
}
var feishuLabelsEN = map[string]string{
"attention": "Attention",
"analysis_scope": "Counts are items analyzed from workflow JSON and may be limited by --issue-limit/--pr-limit; they are not repository totals.",
"boundary_contributor": "Contributor digest is role-oriented, not personalized. It does not use Feishu open_id or union_id routing.",
"boundary_owner": "Owner digest is a read-only summary. It does not modify GitLink or Feishu resources.",
"bot_generated": "Generated by gitlink-cli feishu +bot-test.",
"bot_message": "gitlink-cli can build and send Feishu custom bot cards.",
"bot_status": "Status",
"bot_title": "GitLink Feishu integration test",
"doc_health": "Health score: %s; health risk: %s",
"doc_issues": "Issues analyzed: %d; high_risk=%d, missing_info=%d",
"doc_prs": "Pull Requests analyzed: %d; high_risk=%d",
"doc_reasoning": "Reasoning",
"doc_recommendations": "Recommendations",
"doc_report_score": "Report score: %d",
"doc_review_focus": "Review focus",
"doc_risk": "Risk level: %s",
"doc_source": "Source: %s",
"doc_title": "GitLink workflow report: %s",
"health_risk": "Health risk",
"health_score": "Health score",
"high_risk_issues": "High-risk issues",
"high_risk_prs": "High-risk PRs",
"issues": "Issues",
"issues_analyzed": "Issues analyzed",
"missing_info": "Missing info",
"missing_info_issues": "Missing-info issues",
"merged_prs": "Merged PRs",
"closed_prs": "Closed/rejected PRs",
"open_prs": "Open PRs",
"open_feishu_report": "Open Feishu report",
"open_gitlink_repository": "Open GitLink repository",
"owner_digest_title": "GitLink owner digest: %s",
"contributor_digest_title": "GitLink contributor digest: %s",
"preview_note": "Preview is read-only. Bitable records are generated locally by +bitable-records.",
"pull_requests": "Pull requests",
"prs_analyzed": "PRs analyzed",
"ready": "Ready",
"recommendations": "Recommendations",
"report_score": "Report score",
"repository": "Repository",
"review_focus": "Review focus",
"review_audited": "PRs review-audited",
"reviewed_prs": "Reviewed PRs",
"unreviewed_prs": "Unreviewed PRs",
"needs_re_review": "Needs re-review",
"formal_reviews": "Formal reviews",
"review_actor_attribution": "Review actor attribution",
"reviewer_comments": "Reviewer comments",
"submitter_comments": "Submitter comments",
"participant_comments": "Participant comments",
"system_events": "System events",
"risk_level": "Risk level",
"risk_sources": "PR risk rule sources",
"source": "Source",
"suggested_next_steps": "Suggested next steps",
"task_description_default": "Workflow recommendation from gitlink-cli repo report.",
"task_missing_info_desc": "Some issues need reproduction steps, logs, version details, or command output.",
"task_pr_high_risk_desc": "High-risk PR bucket from workflow report. Check review focus and merge readiness.",
"task_review_focus_desc": "Review focus items from the workflow report.",
"task_review_report_desc": "No high-risk task candidates were detected. Keep a regular owner review cadence.",
"workflow_report_title": "GitLink workflow report: %s",
}
var feishuLabelsZH = map[string]string{
"attention": "需要关注",
"analysis_scope": "数量表示 workflow JSON 中实际分析的条目,可能受 --issue-limit/--pr-limit 限制,不代表仓库总量。",
"boundary_contributor": "贡献者摘要是按角色生成的汇总,不是基于飞书 open_id 或 union_id 的个人定向推送。",
"boundary_owner": "Owner 摘要是只读汇总,不会修改 GitLink 或飞书资源。",
"bot_generated": "由 gitlink-cli feishu +bot-test 生成。",
"bot_message": "gitlink-cli 可以构建并发送飞书自定义机器人卡片。",
"bot_status": "状态",
"bot_title": "GitLink 飞书集成测试",
"doc_health": "健康分:%s健康风险%s",
"doc_issues": "已分析 Issue%d高风险=%d信息缺失=%d",
"doc_prs": "已分析 PR%d高风险=%d",
"doc_reasoning": "判断依据",
"doc_recommendations": "建议操作",
"doc_report_score": "报告分数:%d",
"doc_review_focus": "审查重点",
"doc_risk": "风险等级:%s",
"doc_source": "来源:%s",
"doc_title": "GitLink 工作流报告:%s",
"health_risk": "健康风险",
"health_score": "健康分",
"high_risk_issues": "高风险 Issue",
"high_risk_prs": "高风险 PR",
"issues": "Issue",
"issues_analyzed": "已分析 Issue",
"missing_info": "信息缺失",
"missing_info_issues": "信息缺失 Issue",
"merged_prs": "已合并 PR",
"closed_prs": "已关闭/拒绝 PR",
"open_prs": "开放 PR",
"open_feishu_report": "打开飞书报告",
"open_gitlink_repository": "打开 GitLink 仓库",
"owner_digest_title": "GitLink Owner 摘要:%s",
"contributor_digest_title": "GitLink 贡献者摘要:%s",
"preview_note": "当前为只读预览。多维表格记录由 +bitable-records 在本地生成。",
"pull_requests": "PR",
"prs_analyzed": "已分析 PR",
"ready": "就绪",
"recommendations": "建议操作",
"report_score": "报告分数",
"repository": "仓库",
"review_focus": "审查重点",
"review_audited": "已审查归因 PR",
"reviewed_prs": "已被 review 的 PR",
"unreviewed_prs": "未被 review 的 PR",
"needs_re_review": "待重新 review",
"formal_reviews": "正式 Review",
"review_actor_attribution": "Review 评论来源归因",
"reviewer_comments": "Reviewer 评论",
"submitter_comments": "提交者评论",
"participant_comments": "参与者评论",
"system_events": "系统事件",
"risk_level": "风险等级",
"risk_sources": "PR 风险规则来源",
"source": "来源",
"suggested_next_steps": "建议下一步",
"task_description_default": "来自 gitlink-cli 仓库报告的工作流建议。",
"task_missing_info_desc": "部分 Issue 需要补充复现步骤、日志、版本信息或命令输出。",
"task_pr_high_risk_desc": "工作流报告识别到高风险 PR请检查审查重点和合并准备状态。",
"task_review_focus_desc": "来自工作流报告的 PR 审查重点。",
"task_review_report_desc": "未识别到高风险任务候选,建议保持定期 owner 复查节奏。",
"workflow_report_title": "GitLink 工作流报告:%s",
}
var knownFeishuTranslations = map[string]string{
"Add LICENSE and CONTRIBUTING files for contributor clarity.": "补充 LICENSE 和 CONTRIBUTING降低贡献者理解成本。",
"Add missing reproduction steps, logs, or screenshots when requested.": "按需补充复现步骤、日志或截图。",
"Add or improve README and contribution guidance.": "补充或改进 README 与贡献指南。",
"Check PR review focus and update the related branch or description.": "检查 PR 审查重点,并更新相关分支或描述。",
"Keep GitLink write actions outside this digest; card buttons are navigation-only.": "此摘要不执行 GitLink 写操作;卡片按钮仅用于跳转。",
"Maintain the current workflow and review the repository report regularly.": "保持当前维护节奏,并定期复查仓库报告。",
"No contributor-specific blocker was detected in the workflow report.": "工作流报告未识别到明确的贡献者阻塞项。",
"No critical owner action was detected in the workflow report.": "工作流报告未识别到紧急 owner 动作。",
"Open the GitLink repository or Feishu report link for details.": "打开 GitLink 仓库或飞书报告查看详情。",
"Prioritize high or critical risk pull requests.": "优先审阅 high / critical 风险 PR。",
"Prioritize high-risk pull request feedback before new work.": "先处理高风险 PR 反馈,再开始新工作。",
"Reduce stale issues and add response labels or next actions.": "减少长期未处理的 Issue并补充响应标签或下一步动作。",
"Request missing information for 3 issues": "为 3 个 Issue 补充缺失信息",
"Request missing reproduction steps, version, command output, or logs.": "要求补充复现步骤、版本、命令输出或日志。",
"Review PR focus areas": "审查 PR 重点区域",
"Review high-risk issues and PRs first.": "优先处理高风险 Issue 和 PR。",
"Review report risks and schedule the next maintenance actions.": "复查报告中的风险项,并安排下一步维护动作。",
"Review stale pull requests and clarify merge blockers.": "审查长期未处理的 PR并明确合并阻塞点。",
"Use the Feishu report document for full context when available.": "如有飞书报告文档,优先查看完整上下文。",
"Use the health recommendations to reduce repository governance risk.": "根据健康度建议降低仓库治理风险。",
"Workflow recommendation from gitlink-cli repo report.": "来自 gitlink-cli 仓库报告的工作流建议。",
}
var knownFeishuPatterns = []struct {
re *regexp.Regexp
format string
}{
{regexp.MustCompile(`^(\d+) high-risk issues need maintainer triage$`), "%s 个高风险 Issue 需要维护者分诊"},
{regexp.MustCompile(`^(\d+) issues are missing required information$`), "%s 个 Issue 缺少必要信息"},
{regexp.MustCompile(`^(\d+) high-risk pull requests need owner review$`), "%s 个高风险 PR 需要 owner 审阅"},
{regexp.MustCompile(`^repository health score is (\d+)$`), "仓库健康分为 %s"},
{regexp.MustCompile(`^(\d+) high-risk pull requests may need contributor updates$`), "%s 个高风险 PR 可能需要贡献者更新"},
{regexp.MustCompile(`^(\d+) issues need clearer reproduction details or missing information$`), "%s 个 Issue 需要更清晰的复现信息或缺失信息"},
{regexp.MustCompile(`^(\d+) high-risk issues may need focused follow-up$`), "%s 个高风险 Issue 需要重点跟进"},
{regexp.MustCompile(`^Request missing information for (\d+) issues$`), "为 %s 个 Issue 补充缺失信息"},
{regexp.MustCompile(`^Review (\d+) high-risk pull requests$`), "审查 %s 个高风险 PR"},
}

442
shortcuts/feishu/openapi.go Normal file
View File

@ -0,0 +1,442 @@
package feishu
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
var openAPIBaseURL = "https://open.feishu.cn/open-apis"
type OpenAPIClient struct {
BaseURL string
HTTP *http.Client
}
type TenantToken struct {
Value string
Expire int
}
type WikiNode struct {
SpaceID string `json:"space_id"`
NodeToken string `json:"node_token"`
ObjToken string `json:"obj_token"`
ObjType string `json:"obj_type"`
NodeType string `json:"node_type"`
Title string `json:"title"`
URL string `json:"url"`
}
type CreatedDocument struct {
DocumentID string `json:"document_id"`
RevisionID int `json:"revision_id"`
Title string `json:"title"`
URL string `json:"url"`
}
type CreatedBlocks struct {
RevisionID int `json:"revision_id"`
}
type BitableSearchResult struct {
RecordID string `json:"record_id,omitempty"`
Found bool `json:"found"`
}
type BitableWriteResult struct {
RecordID string `json:"record_id,omitempty"`
Created bool `json:"created,omitempty"`
Updated bool `json:"updated,omitempty"`
}
type CreatedTask struct {
TaskID string `json:"task_id,omitempty"`
}
func NewOpenAPIClient(httpClient *http.Client) OpenAPIClient {
if httpClient == nil {
httpClient = http.DefaultClient
}
return OpenAPIClient{
BaseURL: openAPIBaseURL,
HTTP: httpClient,
}
}
func (c OpenAPIClient) TenantAccessToken(ctx context.Context, appID, appSecret string) (TenantToken, error) {
body := map[string]string{
"app_id": appID,
"app_secret": appSecret,
}
reqBody, err := json.Marshal(body)
if err != nil {
return TenantToken{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/auth/v3/tenant_access_token/internal"), bytes.NewReader(reqBody))
if err != nil {
return TenantToken{}, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Expire int `json:"expire"`
}
if err := c.doJSON(req, &resp); err != nil {
return TenantToken{}, err
}
if resp.Code != 0 {
return TenantToken{}, fmt.Errorf("Feishu tenant token returned code %d: %s", resp.Code, resp.Msg)
}
if strings.TrimSpace(resp.TenantAccessToken) == "" {
return TenantToken{}, fmt.Errorf("Feishu tenant token response missing tenant_access_token")
}
return TenantToken{Value: resp.TenantAccessToken, Expire: resp.Expire}, nil
}
func (c OpenAPIClient) GetWikiNode(ctx context.Context, tenantToken string, wikiNodeToken string) (WikiNode, error) {
query := url.Values{}
query.Set("token", wikiNodeToken)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("/wiki/v2/spaces/get_node")+"?"+query.Encode(), nil)
if err != nil {
return WikiNode{}, err
}
req.Header.Set("Authorization", "Bearer "+tenantToken)
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Node WikiNode `json:"node"`
} `json:"data"`
}
if err := c.doJSON(req, &resp); err != nil {
return WikiNode{}, err
}
if resp.Code != 0 {
return WikiNode{}, fmt.Errorf("Feishu wiki get_node returned code %d: %s", resp.Code, resp.Msg)
}
if strings.TrimSpace(resp.Data.Node.ObjToken) == "" {
return WikiNode{}, fmt.Errorf("Feishu wiki node response missing obj_token")
}
return resp.Data.Node, nil
}
func (c OpenAPIClient) CreateDocument(ctx context.Context, tenantToken string, folderToken string, title string) (CreatedDocument, error) {
body := map[string]string{"title": title}
if strings.TrimSpace(folderToken) != "" {
body["folder_token"] = strings.TrimSpace(folderToken)
}
reqBody, err := json.Marshal(body)
if err != nil {
return CreatedDocument{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/docx/v1/documents"), bytes.NewReader(reqBody))
if err != nil {
return CreatedDocument{}, err
}
req.Header.Set("Authorization", "Bearer "+tenantToken)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Document CreatedDocument `json:"document"`
} `json:"data"`
}
if err := c.doJSON(req, &resp); err != nil {
return CreatedDocument{}, err
}
if resp.Code != 0 {
return CreatedDocument{}, fmt.Errorf("Feishu docx create returned code %d: %s", resp.Code, resp.Msg)
}
if strings.TrimSpace(resp.Data.Document.DocumentID) == "" {
return CreatedDocument{}, fmt.Errorf("Feishu docx create response missing document_id")
}
return resp.Data.Document, nil
}
func (c OpenAPIClient) CreateBlocks(ctx context.Context, tenantToken string, documentID string, parentBlockID string, blocks []DocBlock) (CreatedBlocks, error) {
if strings.TrimSpace(parentBlockID) == "" {
parentBlockID = documentID
}
body := map[string]interface{}{"children": blocks}
reqBody, err := json.Marshal(body)
if err != nil {
return CreatedBlocks{}, err
}
path := fmt.Sprintf("/docx/v1/documents/%s/blocks/%s/children", url.PathEscape(documentID), url.PathEscape(parentBlockID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(path), bytes.NewReader(reqBody))
if err != nil {
return CreatedBlocks{}, err
}
req.Header.Set("Authorization", "Bearer "+tenantToken)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
RevisionID int `json:"revision_id"`
} `json:"data"`
}
if err := c.doJSON(req, &resp); err != nil {
return CreatedBlocks{}, err
}
if resp.Code != 0 {
return CreatedBlocks{}, fmt.Errorf("Feishu docx create blocks returned code %d: %s", resp.Code, resp.Msg)
}
return CreatedBlocks{RevisionID: resp.Data.RevisionID}, nil
}
func (c OpenAPIClient) SearchBitableRecord(ctx context.Context, tenantToken string, appToken string, tableID string, uniqueKey string) (BitableSearchResult, error) {
body := map[string]interface{}{
"filter": map[string]interface{}{
"conjunction": "and",
"conditions": []map[string]interface{}{
{
"field_name": "unique_key",
"operator": "is",
"value": []string{uniqueKey},
},
},
},
}
reqBody, err := json.Marshal(body)
if err != nil {
return BitableSearchResult{}, err
}
path := fmt.Sprintf("/bitable/v1/apps/%s/tables/%s/records/search?page_size=1", url.PathEscape(appToken), url.PathEscape(tableID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(path), bytes.NewReader(reqBody))
if err != nil {
return BitableSearchResult{}, err
}
req.Header.Set("Authorization", "Bearer "+tenantToken)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Items []struct {
RecordID string `json:"record_id"`
} `json:"items"`
} `json:"data"`
}
if err := c.doJSON(req, &resp); err != nil {
return BitableSearchResult{}, err
}
if resp.Code != 0 {
return BitableSearchResult{}, fmt.Errorf("Feishu bitable search returned code %d: %s", resp.Code, resp.Msg)
}
if len(resp.Data.Items) == 0 || strings.TrimSpace(resp.Data.Items[0].RecordID) == "" {
return BitableSearchResult{Found: false}, nil
}
return BitableSearchResult{RecordID: resp.Data.Items[0].RecordID, Found: true}, nil
}
func (c OpenAPIClient) CreateBitableRecord(ctx context.Context, tenantToken string, appToken string, tableID string, fields map[string]interface{}) (BitableWriteResult, error) {
body := map[string]interface{}{"fields": fields}
reqBody, err := json.Marshal(body)
if err != nil {
return BitableWriteResult{}, err
}
path := fmt.Sprintf("/bitable/v1/apps/%s/tables/%s/records", url.PathEscape(appToken), url.PathEscape(tableID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(path), bytes.NewReader(reqBody))
if err != nil {
return BitableWriteResult{}, err
}
req.Header.Set("Authorization", "Bearer "+tenantToken)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Record struct {
RecordID string `json:"record_id"`
} `json:"record"`
RecordID string `json:"record_id"`
} `json:"data"`
}
if err := c.doJSON(req, &resp); err != nil {
return BitableWriteResult{}, err
}
if resp.Code != 0 {
return BitableWriteResult{}, fmt.Errorf("Feishu bitable create returned code %d: %s", resp.Code, resp.Msg)
}
recordID := firstNonEmpty(resp.Data.Record.RecordID, resp.Data.RecordID)
return BitableWriteResult{RecordID: recordID, Created: true}, nil
}
func (c OpenAPIClient) UpdateBitableRecord(ctx context.Context, tenantToken string, appToken string, tableID string, recordID string, fields map[string]interface{}) (BitableWriteResult, error) {
body := map[string]interface{}{"fields": fields}
reqBody, err := json.Marshal(body)
if err != nil {
return BitableWriteResult{}, err
}
path := fmt.Sprintf("/bitable/v1/apps/%s/tables/%s/records/%s", url.PathEscape(appToken), url.PathEscape(tableID), url.PathEscape(recordID))
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.endpoint(path), bytes.NewReader(reqBody))
if err != nil {
return BitableWriteResult{}, err
}
req.Header.Set("Authorization", "Bearer "+tenantToken)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Record struct {
RecordID string `json:"record_id"`
} `json:"record"`
RecordID string `json:"record_id"`
} `json:"data"`
}
if err := c.doJSON(req, &resp); err != nil {
return BitableWriteResult{}, err
}
if resp.Code != 0 {
return BitableWriteResult{}, fmt.Errorf("Feishu bitable update returned code %d: %s", resp.Code, resp.Msg)
}
return BitableWriteResult{RecordID: firstNonEmpty(resp.Data.Record.RecordID, resp.Data.RecordID, recordID), Updated: true}, nil
}
func (c OpenAPIClient) CreateTask(ctx context.Context, tenantToken string, task TaskCandidate) (CreatedTask, error) {
body := map[string]interface{}{
"summary": task.Title,
"description": task.Description + taskLinkSuffix(task),
}
reqBody, err := json.Marshal(body)
if err != nil {
return CreatedTask{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/task/v2/tasks"), bytes.NewReader(reqBody))
if err != nil {
return CreatedTask{}, err
}
req.Header.Set("Authorization", "Bearer "+tenantToken)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Task struct {
GUID string `json:"guid"`
TaskID string `json:"task_id"`
} `json:"task"`
TaskID string `json:"task_id"`
GUID string `json:"guid"`
} `json:"data"`
}
if err := c.doJSON(req, &resp); err != nil {
return CreatedTask{}, err
}
if resp.Code != 0 {
return CreatedTask{}, fmt.Errorf("Feishu task create returned code %d: %s", resp.Code, resp.Msg)
}
return CreatedTask{TaskID: firstNonEmpty(resp.Data.Task.GUID, resp.Data.Task.TaskID, resp.Data.GUID, resp.Data.TaskID)}, nil
}
func (c OpenAPIClient) endpoint(path string) string {
base := strings.TrimRight(c.BaseURL, "/")
if base == "" {
base = strings.TrimRight(openAPIBaseURL, "/")
}
return base + path
}
func (c OpenAPIClient) doJSON(req *http.Request, target interface{}) error {
httpClient := c.HTTP
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var decoded struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
detail := strings.TrimSpace(string(body))
if err := json.Unmarshal(body, &decoded); err == nil && decoded.Msg != "" {
detail = fmt.Sprintf("code %d: %s", decoded.Code, decoded.Msg)
}
if len(detail) > 300 {
detail = detail[:300] + "..."
}
return fmt.Errorf("Feishu OpenAPI %s %s returned HTTP %d: %s", req.Method, redactOpenAPIPath(req.URL.Path), resp.StatusCode, detail)
}
if err := json.Unmarshal(body, target); err != nil {
return fmt.Errorf("parse Feishu OpenAPI response: %w", err)
}
return nil
}
func redactOpenAPIPath(path string) string {
replacements := []struct {
pattern string
repl string
}{
{`/documents/[^/]+`, `/documents/...`},
{`/blocks/[^/]+`, `/blocks/...`},
{`/apps/[^/]+`, `/apps/...`},
{`/tables/[^/]+`, `/tables/...`},
{`/records/[^/]+`, `/records/...`},
{`/tasks/[^/]+`, `/tasks/...`},
}
for _, replacement := range replacements {
path = regexp.MustCompile(replacement.pattern).ReplaceAllString(path, replacement.repl)
}
return path
}
func taskLinkSuffix(task TaskCandidate) string {
links := []string{}
if task.GitLinkURL != "" {
links = append(links, "GitLink: "+task.GitLinkURL)
}
if task.DocURL != "" {
links = append(links, "Feishu report: "+task.DocURL)
}
if len(links) == 0 {
return ""
}
return "\n\n" + strings.Join(links, "\n")
}
func diagnoseOpenAPIError(err error, category string, targetType string) string {
if err == nil {
return ""
}
message := err.Error()
likely := "check Feishu app scopes, resource permissions, IDs, and tenant availability"
switch category {
case "task create":
likely = "grant Task API scopes and verify task creation is enabled for the app"
case "bitable":
likely = "grant Base/Bitable scopes and verify app token, table ID, and unique_key field"
case "docx":
likely = "grant DocX/Drive scopes and write access to the target document, Wiki node, or folder"
}
return fmt.Sprintf("%s failed for %s: %s; likely reason: %s", category, targetType, message, likely)
}

146
shortcuts/feishu/options.go Normal file
View File

@ -0,0 +1,146 @@
package feishu
import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type DeliveryOptions struct {
WebhookURL string `json:"-"`
Secret string `json:"-"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
}
func deliveryOptionsFromContext(ctx *common.RuntimeContext) (DeliveryOptions, error) {
opts := DeliveryOptions{
WebhookURL: firstNonEmpty(ctx.Arg("webhook-url"), os.Getenv("FEISHU_WEBHOOK_URL")),
Secret: firstNonEmpty(ctx.Arg("secret"), os.Getenv("FEISHU_WEBHOOK_SECRET")),
Send: parseBool(ctx.Arg("send")),
DryRun: parseBool(ctx.Arg("dry-run")),
}
if opts.Send && opts.DryRun {
return DeliveryOptions{}, fmt.Errorf("--send and --dry-run cannot be used together")
}
if opts.Send && strings.TrimSpace(opts.WebhookURL) == "" {
return DeliveryOptions{}, fmt.Errorf("--send requires --webhook-url or FEISHU_WEBHOOK_URL")
}
if opts.WebhookURL != "" {
if err := validateWebhookURL(opts.WebhookURL); err != nil {
return DeliveryOptions{}, err
}
}
return opts, nil
}
func validateWebhookURL(raw string) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("invalid Feishu webhook URL")
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return fmt.Errorf("invalid Feishu webhook URL: scheme must be https or http")
}
return nil
}
func redactWebhookURL(raw string) string {
if strings.TrimSpace(raw) == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return "***"
}
path := strings.Trim(parsed.EscapedPath(), "/")
parts := strings.Split(path, "/")
last := ""
if len(parts) > 0 {
last = parts[len(parts)-1]
}
if len(last) > 8 {
last = last[:4] + "..." + last[len(last)-4:]
} else if last != "" {
last = "***"
}
return parsed.Scheme + "://" + parsed.Host + "/.../" + last
}
func redactToken(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if len(value) <= 8 {
return "***"
}
return value[:4] + "..." + value[len(value)-4:]
}
func redactResourceURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return "***"
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for i := 0; i+1 < len(parts); i++ {
switch parts[i] {
case "wiki", "docx", "base", "folder":
parts[i+1] = redactToken(parts[i+1])
}
}
parsed.Path = "/" + strings.Join(parts, "/")
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}
func parseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
}
func parseList(value string) []string {
parts := strings.Split(value, ",")
seen := map[string]bool{}
result := []string{}
for _, part := range parts {
part = strings.ToLower(strings.TrimSpace(part))
if part == "" || seen[part] {
continue
}
if part == "pulls" {
part = "prs"
}
seen[part] = true
result = append(result, part)
}
return result
}
func hasItem(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

118
shortcuts/feishu/render.go Normal file
View File

@ -0,0 +1,118 @@
package feishu
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type DeliveryOutput struct {
Mode string `json:"mode"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
WebhookURL string `json:"webhook_url,omitempty"`
Payload WebhookPayload `json:"payload"`
Response *WebhookResponse `json:"response,omitempty"`
}
func deliverOrPreview(ctx *common.RuntimeContext, opts DeliveryOptions, payload WebhookPayload, markdown string) error {
output := DeliveryOutput{
Mode: "preview",
Send: opts.Send,
DryRun: !opts.Send,
WebhookURL: redactWebhookURL(opts.WebhookURL),
Payload: payload,
}
if opts.Send {
client := WebhookClient{
URL: opts.WebhookURL,
Secret: opts.Secret,
}
resp, err := client.Send(context.Background(), payload)
output.Mode = "sent"
output.DryRun = false
output.Response = resp
if err != nil {
_ = renderDeliveryOutput(os.Stdout, output, ctx.Format, markdown)
return err
}
}
return renderDeliveryOutput(os.Stdout, output, ctx.Format, markdown)
}
func renderDeliveryOutput(w io.Writer, output DeliveryOutput, format string, markdown string) error {
switch normalizeFormat(format) {
case "markdown":
if markdown != "" {
_, err := fmt.Fprint(w, markdown)
return err
}
return writeDeliveryMarkdown(w, output)
case "table":
return writeDeliveryTable(w, output)
default:
return writeJSON(w, output)
}
}
func writeDeliveryMarkdown(w io.Writer, output DeliveryOutput) error {
lines := []string{
"# Feishu Delivery Preview",
"",
fmt.Sprintf("- Mode: `%s`", output.Mode),
fmt.Sprintf("- Send: `%t`", output.Send),
fmt.Sprintf("- Dry run: `%t`", output.DryRun),
}
if output.WebhookURL != "" {
lines = append(lines, fmt.Sprintf("- Webhook: `%s`", output.WebhookURL))
}
if output.Response != nil {
lines = append(lines, fmt.Sprintf("- HTTP status: `%d`", output.Response.StatusCode))
lines = append(lines, fmt.Sprintf("- Feishu code: `%d`", output.Response.Code))
if output.Response.Message != "" {
lines = append(lines, fmt.Sprintf("- Message: `%s`", output.Response.Message))
}
}
_, err := fmt.Fprintln(w, strings.Join(lines, "\n"))
return err
}
func writeDeliveryTable(w io.Writer, output DeliveryOutput) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "MODE\tSEND\tDRY_RUN\tWEBHOOK\tHTTP_STATUS\tFEISHU_CODE"); err != nil {
return err
}
status := ""
code := ""
if output.Response != nil {
status = fmt.Sprintf("%d", output.Response.StatusCode)
code = fmt.Sprintf("%d", output.Response.Code)
}
if _, err := fmt.Fprintf(tw, "%s\t%t\t%t\t%s\t%s\t%s\n", output.Mode, output.Send, output.DryRun, output.WebhookURL, status, code); err != nil {
return err
}
return tw.Flush()
}
func writeJSON(w io.Writer, data interface{}) error {
encoded, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(encoded))
return err
}
func normalizeFormat(format string) string {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
return "json"
}
return format
}

21
shortcuts/feishu/sign.go Normal file
View File

@ -0,0 +1,21 @@
package feishu
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"strconv"
"time"
)
// SignCustomBotRequest implements the Feishu custom bot signature algorithm.
// Feishu uses timestamp + "\n" + secret as the HMAC key and signs an empty body.
func SignCustomBotRequest(timestamp int64, secret string) string {
stringToSign := strconv.FormatInt(timestamp, 10) + "\n" + secret
mac := hmac.New(sha256.New, []byte(stringToSign))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func timestampSeconds(now time.Time) int64 {
return now.Unix()
}

341
shortcuts/feishu/task.go Normal file
View File

@ -0,0 +1,341 @@
package feishu
import (
"context"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
type TaskCandidate struct {
UniqueKey string `json:"unique_key"`
Title string `json:"title"`
Description string `json:"description"`
SourceType string `json:"source_type"`
SourceKey string `json:"source_key"`
Repository string `json:"repository"`
Priority string `json:"priority"`
TaskType string `json:"task_type"`
RecommendedOwner string `json:"recommended_owner,omitempty"`
Status string `json:"status"`
DueHint string `json:"due_hint,omitempty"`
GitLinkURL string `json:"gitlink_url,omitempty"`
DocURL string `json:"doc_url,omitempty"`
}
type TaskCreateOptions struct {
AppID string `json:"-"`
AppSecret string `json:"-"`
TaskProjectID string `json:"task_project_id,omitempty"`
TaskSectionID string `json:"task_section_id,omitempty"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
}
type TaskOutput struct {
Mode string `json:"mode"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
TaskProjectID string `json:"task_project_id,omitempty"`
TaskSectionID string `json:"task_section_id,omitempty"`
TaskCount int `json:"task_count"`
Tasks []TaskCandidate `json:"tasks"`
Results []TaskCreateResult `json:"results,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
type TaskCreateResult struct {
UniqueKey string `json:"unique_key"`
Title string `json:"title"`
TaskID string `json:"task_id,omitempty"`
Created bool `json:"created"`
Error string `json:"error,omitempty"`
}
func BuildTaskCandidates(report workflow.RepoReportResult, docURL string) []TaskCandidate {
return buildTaskCandidates(report, docURL, defaultLang)
}
func BuildTaskCandidatesLocalized(report workflow.RepoReportResult, docURL string, lang string) []TaskCandidate {
return buildTaskCandidates(report, docURL, lang)
}
func buildTaskCandidates(report workflow.RepoReportResult, docURL string, lang string) []TaskCandidate {
tasks := []TaskCandidate{}
repoURL := gitlinkRepoURL(report.Repository)
for i, recommendation := range report.Recommendations {
title := firstNonEmpty(localizeFeishuText(recommendation, lang), "Review workflow recommendation")
tasks = append(tasks, TaskCandidate{
UniqueKey: stableKey("task", report.Repository, "recommendation", fmt.Sprintf("%d", i+1)),
Title: title,
Description: feishuLabel(lang, "task_description_default"),
SourceType: "recommendation",
SourceKey: fmt.Sprintf("recommendation-%d", i+1),
Repository: report.Repository,
Priority: priorityForRisk(report.RiskLevel),
TaskType: "workflow_recommendation",
Status: "todo",
DueHint: "next review cycle",
GitLinkURL: repoURL,
DocURL: strings.TrimSpace(docURL),
})
}
if report.IssueSummary.HighRisk > 0 {
tasks = append(tasks, TaskCandidate{
UniqueKey: stableKey("task", report.Repository, "issues", "high-risk"),
Title: localizeFeishuText(fmt.Sprintf("Triage %d high-risk GitLink issues", report.IssueSummary.HighRisk), lang),
Description: localizeFeishuText("High-risk issue bucket from workflow report. Review GitLink issues before routine work.", lang),
SourceType: "issues",
SourceKey: "issues-high-risk",
Repository: report.Repository,
Priority: "high",
TaskType: "issue_triage",
Status: "todo",
DueHint: "as soon as possible",
GitLinkURL: appendPath(repoURL, "issues"),
DocURL: strings.TrimSpace(docURL),
})
}
if report.IssueSummary.MissingInfo > 0 {
tasks = append(tasks, TaskCandidate{
UniqueKey: stableKey("task", report.Repository, "issues", "missing-info"),
Title: localizeFeishuText(fmt.Sprintf("Request missing information for %d issues", report.IssueSummary.MissingInfo), lang),
Description: feishuLabel(lang, "task_missing_info_desc"),
SourceType: "issues",
SourceKey: "issues-missing-info",
Repository: report.Repository,
Priority: "medium",
TaskType: "issue_followup",
Status: "todo",
DueHint: "this week",
GitLinkURL: appendPath(repoURL, "issues"),
DocURL: strings.TrimSpace(docURL),
})
}
if report.PRSummary.HighRisk > 0 {
tasks = append(tasks, TaskCandidate{
UniqueKey: stableKey("task", report.Repository, "prs", "high-risk"),
Title: localizeFeishuText(fmt.Sprintf("Review %d high-risk pull requests", report.PRSummary.HighRisk), lang),
Description: feishuLabel(lang, "task_pr_high_risk_desc"),
SourceType: "prs",
SourceKey: "prs-high-risk",
Repository: report.Repository,
Priority: "high",
TaskType: "pr_review",
Status: "todo",
DueHint: "before next merge window",
GitLinkURL: appendPath(repoURL, "pulls"),
DocURL: strings.TrimSpace(docURL),
})
}
if len(report.PRSummary.ReviewFocus) > 0 {
tasks = append(tasks, TaskCandidate{
UniqueKey: stableKey("task", report.Repository, "prs", "review-focus"),
Title: localizeFeishuText("Review PR focus areas", lang),
Description: strings.Join(limitStrings(localizeFeishuLines(report.PRSummary.ReviewFocus, lang), 8), "\n"),
SourceType: "prs",
SourceKey: "prs-review-focus",
Repository: report.Repository,
Priority: "medium",
TaskType: "review_focus",
Status: "todo",
DueHint: "this week",
GitLinkURL: appendPath(repoURL, "pulls"),
DocURL: strings.TrimSpace(docURL),
})
}
if len(tasks) == 0 {
tasks = append(tasks, TaskCandidate{
UniqueKey: stableKey("task", report.Repository, "report", "review"),
Title: localizeFeishuText("Review GitLink workflow report", lang),
Description: feishuLabel(lang, "task_review_report_desc"),
SourceType: "report",
SourceKey: "report-review",
Repository: report.Repository,
Priority: "low",
TaskType: "report_review",
Status: "todo",
DueHint: "next review cycle",
GitLinkURL: repoURL,
DocURL: strings.TrimSpace(docURL),
})
}
return dedupeTasks(tasks)
}
func taskPreviewOutput(tasks []TaskCandidate) TaskOutput {
return TaskOutput{
Mode: "preview",
DryRun: true,
TaskCount: len(tasks),
Tasks: tasks,
}
}
func taskCreateOptionsFromContext(ctx *common.RuntimeContext) (TaskCreateOptions, error) {
opts := TaskCreateOptions{
AppID: firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")),
AppSecret: firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET")),
TaskProjectID: firstNonEmpty(ctx.Arg("task-project-id"), os.Getenv("FEISHU_TASK_PROJECT_ID")),
TaskSectionID: firstNonEmpty(ctx.Arg("task-section-id"), os.Getenv("FEISHU_TASK_SECTION_ID")),
Send: parseBool(ctx.Arg("send")),
DryRun: parseBool(ctx.Arg("dry-run")),
}
if opts.Send && opts.DryRun {
return TaskCreateOptions{}, fmt.Errorf("--send and --dry-run cannot be used together")
}
if opts.Send {
if strings.TrimSpace(opts.AppID) == "" {
return TaskCreateOptions{}, fmt.Errorf("--send requires --app-id or FEISHU_APP_ID")
}
if strings.TrimSpace(opts.AppSecret) == "" {
return TaskCreateOptions{}, fmt.Errorf("--send requires --app-secret or FEISHU_APP_SECRET")
}
}
return opts, nil
}
func createTasksOrPreview(ctx *common.RuntimeContext, opts TaskCreateOptions, tasks []TaskCandidate) error {
output := TaskOutput{
Mode: "preview",
Send: opts.Send,
DryRun: !opts.Send,
TaskProjectID: redactToken(opts.TaskProjectID),
TaskSectionID: redactToken(opts.TaskSectionID),
TaskCount: len(tasks),
Tasks: tasks,
Warnings: []string{
"Experimental: Feishu task creation requires self-built app task scopes.",
"Deduplication is local unique_key generation only; Feishu Task API search/linking is not implemented in this pass.",
},
}
if !opts.Send {
return renderTaskOutput(os.Stdout, output, formatOrDefault(ctx, "markdown"))
}
client := NewOpenAPIClient(nil)
token, err := client.TenantAccessToken(context.Background(), opts.AppID, opts.AppSecret)
if err != nil {
return err
}
output.Mode = "sent"
output.DryRun = false
for _, task := range tasks {
result := TaskCreateResult{UniqueKey: task.UniqueKey, Title: task.Title}
created, err := client.CreateTask(context.Background(), token.Value, task)
if err != nil {
result.Error = diagnoseOpenAPIError(err, "task create", "task")
output.Results = append(output.Results, result)
_ = renderTaskOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return err
}
result.TaskID = created.TaskID
result.Created = true
output.Results = append(output.Results, result)
}
return renderTaskOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
}
func renderTaskOutput(w io.Writer, output TaskOutput, format string) error {
switch normalizeFormat(format) {
case "markdown":
return writeTaskMarkdown(w, output)
case "table":
return writeTaskTable(w, output)
default:
return writeJSON(w, output)
}
}
func writeTaskMarkdown(w io.Writer, output TaskOutput) error {
if _, err := fmt.Fprintf(w, "# Feishu Task %s\n\n", titleWord(output.Mode)); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "- Send: `%t`\n- Dry run: `%t`\n- Tasks: `%d`\n\n", output.Send, output.DryRun, output.TaskCount); err != nil {
return err
}
for _, warning := range output.Warnings {
if _, err := fmt.Fprintf(w, "- %s\n", warning); err != nil {
return err
}
}
if len(output.Warnings) > 0 {
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
for _, task := range output.Tasks {
if _, err := fmt.Fprintf(w, "## %s\n\n- Key: `%s`\n- Priority: `%s`\n- Source: `%s/%s`\n\n%s\n\n", task.Title, task.UniqueKey, task.Priority, task.SourceType, task.SourceKey, task.Description); err != nil {
return err
}
}
return nil
}
func writeTaskTable(w io.Writer, output TaskOutput) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if len(output.Results) > 0 {
if _, err := fmt.Fprintln(tw, "KEY\tCREATED\tTASK_ID\tTITLE\tERROR"); err != nil {
return err
}
for _, result := range output.Results {
if _, err := fmt.Fprintf(tw, "%s\t%t\t%s\t%s\t%s\n", result.UniqueKey, result.Created, redactToken(result.TaskID), result.Title, result.Error); err != nil {
return err
}
}
return tw.Flush()
}
if _, err := fmt.Fprintln(tw, "KEY\tPRIORITY\tSOURCE\tTITLE"); err != nil {
return err
}
for _, task := range output.Tasks {
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s/%s\t%s\n", task.UniqueKey, task.Priority, task.SourceType, task.SourceKey, task.Title); err != nil {
return err
}
}
return tw.Flush()
}
func titleWord(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return strings.ToUpper(value[:1]) + value[1:]
}
func priorityForRisk(risk string) string {
switch strings.ToLower(strings.TrimSpace(risk)) {
case "critical", "high":
return "high"
case "medium":
return "medium"
default:
return "low"
}
}
func appendPath(base string, path string) string {
if strings.TrimSpace(base) == "" {
return ""
}
return strings.TrimRight(base, "/") + "/" + strings.Trim(path, "/")
}
func dedupeTasks(tasks []TaskCandidate) []TaskCandidate {
seen := map[string]bool{}
result := []TaskCandidate{}
for _, task := range tasks {
if task.UniqueKey == "" || seen[task.UniqueKey] {
continue
}
seen[task.UniqueKey] = true
result = append(result, task)
}
return result
}

View File

@ -0,0 +1,114 @@
package feishu
import (
"encoding/json"
"fmt"
"os"
"strings"
"unicode/utf16"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func readWorkflowReport(path string, lang string) (workflow.RepoReportResult, error) {
data, err := os.ReadFile(path)
if err != nil {
return workflow.RepoReportResult{}, fmt.Errorf("read workflow JSON: %w", err)
}
data, err = normalizeJSONBytes(data)
if err != nil {
return workflow.RepoReportResult{}, err
}
data, err = unwrapWorkflowJSON(data)
if err != nil {
return workflow.RepoReportResult{}, err
}
var result workflow.RepoReportResult
if err := json.Unmarshal(data, &result); err == nil && looksLikeRepoReportResult(result) {
return normalizeReportResult(result), nil
}
var input workflow.RepoReportInput
if err := json.Unmarshal(data, &input); err == nil && looksLikeRepoReportInput(input) {
return workflow.AnalyzeRepoReport(input, lang), nil
}
return workflow.RepoReportResult{}, fmt.Errorf("parse workflow JSON: expected workflow RepoReportResult or RepoReportInput")
}
func normalizeJSONBytes(data []byte) ([]byte, error) {
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
return data[3:], nil
}
if len(data) >= 2 && data[0] == 0xFF && data[1] == 0xFE {
return decodeUTF16(data[2:], true)
}
if len(data) >= 2 && data[0] == 0xFE && data[1] == 0xFF {
return decodeUTF16(data[2:], false)
}
return data, nil
}
func decodeUTF16(data []byte, littleEndian bool) ([]byte, error) {
if len(data)%2 != 0 {
return nil, fmt.Errorf("parse workflow JSON: invalid UTF-16 byte length")
}
words := make([]uint16, 0, len(data)/2)
for i := 0; i < len(data); i += 2 {
if littleEndian {
words = append(words, uint16(data[i])|uint16(data[i+1])<<8)
} else {
words = append(words, uint16(data[i])<<8|uint16(data[i+1]))
}
}
return []byte(string(utf16.Decode(words))), nil
}
func unwrapWorkflowJSON(data []byte) ([]byte, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse workflow JSON: %w", err)
}
for _, key := range []string{"data", "repo_report", "report"} {
if value, ok := raw[key]; ok && len(value) > 0 && string(value) != "null" {
return value, nil
}
}
return data, nil
}
func looksLikeRepoReportResult(result workflow.RepoReportResult) bool {
return strings.TrimSpace(result.Repository) != "" &&
(strings.TrimSpace(result.RiskLevel) != "" ||
result.ReportScore != 0 ||
result.IssueSummary.Total != 0 ||
result.PRSummary.Total != 0 ||
len(result.Recommendations) > 0)
}
func looksLikeRepoReportInput(input workflow.RepoReportInput) bool {
return strings.TrimSpace(input.Repository) != "" ||
input.Health != nil ||
len(input.Issues) > 0 ||
len(input.PullRequests) > 0
}
func normalizeReportResult(result workflow.RepoReportResult) workflow.RepoReportResult {
if strings.TrimSpace(result.Source) == "" {
result.Source = "workflow-json"
}
if result.IssueSummary.ByType == nil {
result.IssueSummary.ByType = map[string]int{}
}
if result.IssueSummary.ByPriority == nil {
result.IssueSummary.ByPriority = map[string]int{}
}
if result.PRSummary.ByType == nil {
result.PRSummary.ByType = map[string]int{}
}
if result.PRSummary.ByRisk == nil {
result.PRSummary.ByRisk = map[string]int{}
}
return result
}

View File

@ -3,21 +3,27 @@ package shortcuts
import (
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
"github.com/gitlink-org/gitlink-cli/shortcuts/dataset"
"github.com/gitlink-org/gitlink-cli/shortcuts/feishu"
"github.com/gitlink-org/gitlink-cli/shortcuts/health"
"github.com/gitlink-org/gitlink-cli/shortcuts/ignore"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/license"
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
"github.com/gitlink-org/gitlink-cli/shortcuts/notification"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
"github.com/gitlink-org/gitlink-cli/shortcuts/profile"
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
"github.com/gitlink-org/gitlink-cli/shortcuts/snippet"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
@ -25,47 +31,61 @@ import (
)
// RegisterAll mounts all shortcut groups onto the root command.
func RegisterAll(root *cobra.Command) {
func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"compare": compare.Shortcuts(),
"webhook": webhook.Shortcuts(),
"workflow": workflow.Shortcuts(),
"wiki": wiki.Shortcuts(),
"label": label.Shortcuts(),
"notification": notification.Shortcuts(),
"snippet": snippet.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
"profile": profile.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
"user": user.Shortcuts(tr),
"search": search.Shortcuts(tr),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"dataset": dataset.Shortcuts(tr),
"feishu": feishu.Shortcuts(tr),
"webhook": webhook.Shortcuts(tr),
"wiki": wiki.Shortcuts(),
"health": health.Shortcuts(tr),
"ignore": ignore.Shortcuts(),
"workflow": workflow.Shortcuts(),
}
descriptions := map[string]string{
"repo": "Repository operations",
"issue": "Issue operations",
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
"milestone": "Milestone operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"compare": "Compare branches, tags, or commits",
"webhook": "Webhook operations",
"workflow": "AI agent workflow analysis",
"wiki": "Wiki page operations",
"label": "Label operations",
"notification": "Notification operations",
"snippet": "Code snippet operations",
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
"profile": tr.T("cmd.profile.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),
"user": tr.T("cmd.user.short"),
"search": tr.T("cmd.search.short"),
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"dataset": tr.T("cmd.dataset.short"),
"feishu": "Export GitLink workflow data to Feishu",
"webhook": tr.T("cmd.webhook.short"),
"wiki": "Wiki page management",
"health": "Project health data collection",
"ignore": tr.T("cmd.ignore.short"),
"workflow": "AI agent workflow analysis",
}
for name, shortcuts := range groups {
@ -73,7 +93,7 @@ func RegisterAll(root *cobra.Command) {
Use: name,
Short: descriptions[name],
}
common.MountShortcuts(groupCmd, shortcuts)
common.MountShortcuts(groupCmd, shortcuts, tr)
root.AddCommand(groupCmd)
}
}

View File

@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) {
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"dataset", "health", "ignore", "file", "snippet", "pm", "wiki",
"dataset", "health", "ignore", "wiki", "feishu",
}
groupSet := map[string]bool{}

View File

@ -104,7 +104,7 @@ func apiList(data interface{}) []interface{} {
case []interface{}:
return v
case map[string]interface{}:
for _, key := range []string{"issues", "pulls", "pull_requests", "files", "commits", "releases", "builds", "items", "records", "data", "journals", "issue_journals"} {
for _, key := range []string{"issues", "pulls", "pull_requests", "reviews", "journals", "comments", "notes", "files", "commits", "releases", "builds", "items", "records", "data"} {
if raw, ok := v[key]; ok {
if items := apiList(raw); len(items) > 0 {
return items
@ -259,7 +259,6 @@ func parseAPIStringTime(value string) time.Time {
time.RFC3339,
time.RFC3339Nano,
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006-01-02T15:04:05",
"2006-01-02",
}

View File

@ -495,36 +495,27 @@ func TestQueryWithPageLimit(t *testing.T) {
func TestIssueListQuery(t *testing.T) {
q := issueListQuery("open")
if q.Get("state") != "" {
t.Fatalf("issueListQuery must not send state, got %q", q.Get("state"))
if q.Get("category") != "opened" || q.Get("state") != "" {
t.Fatalf("issueListQuery = %v, want category=opened", q)
}
if q.Get("category") != "opened" {
t.Fatalf("issueListQuery category = %q, want opened", q.Get("category"))
}
if got := issueListQuery("closed").Get("category"); got != "closed" {
t.Fatalf("issueListQuery(closed) category = %q, want closed", got)
}
if got := issueListQuery("all").Get("category"); got != "all" {
t.Fatalf("issueListQuery(all) category = %q, want all", got)
q = issueListQuery("closed")
if q.Get("category") != "closed" {
t.Fatalf("issueListQuery closed = %v", q)
}
}
func TestPullListQuery(t *testing.T) {
q := pullListQuery("open")
if q.Get("state") != "" {
t.Fatalf("pullListQuery must not send state, got %q", q.Get("state"))
if q.Get("status") != "0" || q.Get("state") != "" {
t.Fatalf("pullListQuery = %v, want status=0", q)
}
if q.Get("status") != "0" {
t.Fatalf("pullListQuery status = %q, want 0", q.Get("status"))
q = pullListQuery("merged")
if q.Get("status") != "1" {
t.Fatalf("pullListQuery merged = %v", q)
}
if got := pullListQuery("merged").Get("status"); got != "1" {
t.Fatalf("pullListQuery(merged) status = %q, want 1", got)
}
if got := pullListQuery("closed").Get("status"); got != "2" {
t.Fatalf("pullListQuery(closed) status = %q, want 2", got)
}
if _, ok := pullListQuery("all")["status"]; ok {
t.Fatal("pullListQuery(all) should omit status so the API returns every state")
q = pullListQuery("closed")
if q.Get("status") != "2" {
t.Fatalf("pullListQuery closed = %v", q)
}
}
@ -610,14 +601,6 @@ func TestUpdateRecentActivity(t *testing.T) {
if !known2 || days2 != days {
t.Fatalf("zero time update should not change: known=%v days=%d", known2, days2)
}
// A signal from today (days==0) must not be overwritten by an older one.
today := HealthInput{RecentActivityKnown: true, RecentActivityDays: 0}
old := time.Now().Add(-45 * 24 * time.Hour)
_, keptDays, _ := updateRecentActivity(today, old)
if keptDays != 0 {
t.Fatalf("today signal overwritten by older one: days=%d, want 0", keptDays)
}
}
func TestAPIIntStringFallback(t *testing.T) {

View File

@ -200,7 +200,7 @@ func updateRecentActivity(input HealthInput, latest time.Time) (bool, int, Healt
return input.RecentActivityKnown, input.RecentActivityDays, input
}
days := apiAgeInDays(latest)
if !input.RecentActivityKnown || days < input.RecentActivityDays {
if !input.RecentActivityKnown || days < input.RecentActivityDays || input.RecentActivityDays == 0 {
input.RecentActivityKnown = true
input.RecentActivityDays = days
}
@ -220,52 +220,52 @@ func queryWithPageLimit(base url.Values, page, limit int) url.Values {
return base
}
// The GitLink v1 list API filters issues by category and pulls by status; a
// stray "state" param is silently ignored and every state is returned.
func issueListQuery(state string) url.Values {
q := url.Values{}
q.Set("category", normalizeIssueListCategory(state))
switch strings.ToLower(strings.TrimSpace(state)) {
case "open", "opened", "":
q.Set("category", "opened")
case "closed":
q.Set("category", "closed")
case "all":
q.Set("category", "all")
default:
q.Set("category", state)
}
return q
}
func pullListQuery(state string) url.Values {
q := url.Values{}
if status := normalizePullListStatus(state); status != "" {
q.Set("status", status)
switch strings.ToLower(strings.TrimSpace(state)) {
case "open", "opened":
q.Set("status", "0")
case "merged":
q.Set("status", "1")
case "closed":
q.Set("status", "2")
case "all", "":
default:
q.Set("status", state)
}
return q
}
func normalizeIssueListCategory(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open", "opened":
return "opened"
case "closed":
return "closed"
default:
return "all"
}
}
func normalizePullListStatus(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open", "opened":
return "0"
case "merged":
return "1"
case "closed":
return "2"
default:
return ""
}
}
func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Values, pageSize int) ([]map[string]interface{}, error) {
return fetchListItems(ctx, path, baseQuery, pageSize, 0)
}
func fetchListItems(ctx *common.RuntimeContext, path string, baseQuery url.Values, pageSize, maxItems int) ([]map[string]interface{}, error) {
if pageSize <= 0 {
pageSize = 100
pageSize = 50
}
if maxItems > 0 && pageSize > maxItems {
pageSize = maxItems
}
all := []map[string]interface{}{}
for page := 1; ; page++ {
seen := map[string]struct{}{}
totalCount := 0
for page := 1; page <= 1000; page++ {
query := cloneValues(baseQuery)
query.Set("page", fmt.Sprintf("%d", page))
query.Set("limit", fmt.Sprintf("%d", pageSize))
@ -274,6 +274,12 @@ func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Va
if err != nil {
return nil, err
}
if env.Meta != nil && env.Meta.TotalCount > totalCount {
totalCount = env.Meta.TotalCount
}
if dataTotal := apiListTotal(env.Data); dataTotal > totalCount {
totalCount = dataTotal
}
items := apiList(env.Data)
pageItems := make([]map[string]interface{}, 0, len(items))
for _, raw := range items {
@ -284,14 +290,52 @@ func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Va
if len(pageItems) == 0 {
break
}
all = append(all, pageItems...)
if len(pageItems) < pageSize {
before := len(all)
for _, item := range pageItems {
key := listItemIdentity(item)
if key != "" {
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
}
all = append(all, item)
if maxItems > 0 && len(all) >= maxItems {
return all[:maxItems], nil
}
}
if totalCount > 0 && len(all) >= totalCount {
break
}
if len(all) == before {
break
}
if totalCount == 0 && len(pageItems) < pageSize {
break
}
}
return all, nil
}
func apiListTotal(data interface{}) int {
object := apiObject(data)
for _, key := range []string{"total_count", "opened_count", "total"} {
if total := apiInt(object[key]); total > 0 {
return total
}
}
return 0
}
func listItemIdentity(item map[string]interface{}) string {
for _, key := range []string{"id", "database_id", "index", "number", "iid", "project_issues_index"} {
if value := apiString(item[key]); value != "" {
return key + ":" + value
}
}
return ""
}
func cloneValues(values url.Values) url.Values {
if values == nil {
return url.Values{}

View File

@ -156,7 +156,7 @@ func prAPIObject(data interface{}) map[string]interface{} {
}
func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool) {
number := firstPRInt(item, "number", "iid", "pull_request_number")
number := firstPRInt(item, "number", "index", "iid", "pull_request_number")
title := firstPRString(item, "title", "subject")
if number == 0 && strings.TrimSpace(title) == "" {
return PRSummaryInput{}, false
@ -164,18 +164,28 @@ func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool)
body := firstPRString(item, "body", "description", "content")
state := firstPRString(item, "state", "status")
author := firstPRAuthor(item)
issueID := firstPRIssueID(item)
base := firstPRBranch(item, "base_branch", "target_branch", "base")
head := firstPRBranch(item, "head_branch", "source_branch", "head")
createdAt := firstPRTime(item, "created_at", "createdAt")
updatedAt := apiLatestTime(
firstPRTime(item, "updated_at", "updatedAt"),
firstPRTime(item, "last_updated_at", "lastUpdatedAt"),
firstPRTime(item, "last_activity_at", "lastActivityAt"),
)
additions := firstPRInt(item, "additions", "additions_count")
deletions := firstPRInt(item, "deletions", "deletions_count")
return PRSummaryInput{
Number: number,
IssueID: issueID,
Title: title,
Author: author,
State: state,
BaseBranch: base,
HeadBranch: head,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Body: body,
Additions: additions,
Deletions: deletions,
@ -298,6 +308,26 @@ func firstPRCommitAuthor(item map[string]interface{}) string {
return ""
}
func firstPRIssueID(item map[string]interface{}) int {
for _, key := range []string{"issue_id", "issueId"} {
if value, ok := item[key]; ok {
if id := apiInt(value); id != 0 {
return id
}
}
}
for _, key := range []string{"issue", "issue_info"} {
if raw, ok := item[key].(map[string]interface{}); ok {
for _, field := range []string{"id", "issue_id"} {
if id := apiInt(raw[field]); id != 0 {
return id
}
}
}
}
return 0
}
func firstPRBranch(item map[string]interface{}, keys ...string) string {
for _, key := range keys {
if value, ok := item[key]; ok {

View File

@ -0,0 +1,354 @@
package workflow
import (
"fmt"
"net/url"
"sort"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const (
reviewStandardFormal = "formal_review"
reviewStandardReviewerJournal = "reviewer_journal_feedback"
reviewStandardUnreviewed = "unreviewed"
actorSubmitter = "submitter"
actorReviewer = "reviewer"
actorParticipant = "participant"
actorBot = "bot"
actorSystem = "system"
actorUnknown = "unknown"
)
type normalizedPRReview struct {
Actor string
Status string
At time.Time
}
func fetchPRReviewAudit(ctx *common.RuntimeContext, owner, repo string, prs []PRSummaryInput, maxItems int) (*RepoPRReviewAudit, []ScoringNote) {
if maxItems > 0 && len(prs) > maxItems {
prs = prs[:maxItems]
}
audit := &RepoPRReviewAudit{
Source: "remote-read-only-fetch:reviews-and-journals",
PullRequests: make([]PRReviewAudit, 0, len(prs)),
}
notes := []ScoringNote{}
for _, pr := range prs {
item, itemNotes := fetchOnePRReviewAudit(ctx, owner, repo, pr)
notes = append(notes, itemNotes...)
audit.PullRequests = append(audit.PullRequests, item)
audit.Audited++
if item.Reviewed {
audit.Reviewed++
} else {
audit.Unreviewed++
}
if item.NeedsReReview {
audit.NeedsReReview++
}
audit.FormalReviews += item.FormalReviewCount
audit.ReviewerComments += item.ReviewerComments
audit.SubmitterComments += item.SubmitterComments
audit.ParticipantComments += item.ParticipantComments
audit.BotEvents += item.BotEvents
audit.SystemEvents += item.SystemEvents
audit.UnknownActorEvents += item.UnknownActorEvents
if len(item.Notes) > 0 {
audit.Errors += len(item.Notes)
}
}
return audit, uniqueScoringNotes(notes)
}
func fetchOnePRReviewAudit(ctx *common.RuntimeContext, owner, repo string, pr PRSummaryInput) (PRReviewAudit, []ScoringNote) {
result := PRReviewAudit{
Number: pr.Number,
Author: pr.Author,
IssueID: pr.IssueID,
ReviewStandard: reviewStandardUnreviewed,
FormalReviewStatus: "unreviewed",
}
notes := []ScoringNote{}
if result.Number <= 0 {
result.Notes = append(result.Notes, "missing PR number")
return result, []ScoringNote{{Metric: "repo_report_pr_review_audit", Note: "skipped PR review audit: missing PR number"}}
}
if result.IssueID == 0 || strings.TrimSpace(result.Author) == "" {
base, err := fetchPRBase(ctx, owner, repo, result.Number)
if err != nil {
note := fmt.Sprintf("PR #%d base detail unavailable for review audit: %v", result.Number, err)
result.Notes = append(result.Notes, note)
notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note})
} else {
if result.IssueID == 0 {
result.IssueID = base.IssueID
}
if strings.TrimSpace(result.Author) == "" {
result.Author = base.Author
}
if pr.CreatedAt.IsZero() {
pr.CreatedAt = base.CreatedAt
}
if pr.UpdatedAt.IsZero() {
pr.UpdatedAt = base.UpdatedAt
}
}
}
reviews, err := fetchFormalPRReviews(ctx, owner, repo, result.Number)
if err != nil {
note := fmt.Sprintf("PR #%d formal reviews unavailable: %v", result.Number, err)
result.Notes = append(result.Notes, note)
notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note})
} else {
result.FormalReviewCount = len(reviews)
result.FormalReviewStatus = summarizeFormalReviewStatus(reviews)
result.Reviewers = uniqueReviewerNames(reviews)
if latest := latestReviewTime(reviews); !latest.IsZero() {
result.LatestReviewerAt = latest.Format(time.RFC3339)
}
if result.FormalReviewCount > 0 {
result.Reviewed = true
result.ReviewStandard = reviewStandardFormal
}
}
reviewerSet := reviewerSet(reviews)
var latestReviewerAt time.Time
if result.LatestReviewerAt != "" {
latestReviewerAt = apiTime(result.LatestReviewerAt)
}
var latestSubmitterAt time.Time
if result.IssueID == 0 {
note := fmt.Sprintf("PR #%d conversation journal skipped: missing associated issue id", result.Number)
result.Notes = append(result.Notes, note)
notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note})
} else {
journals, err := fetchPRJournals(ctx, owner, repo, result.IssueID)
if err != nil {
note := fmt.Sprintf("PR #%d conversation journal unavailable: %v", result.Number, err)
result.Notes = append(result.Notes, note)
notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note})
} else {
for _, journal := range journals {
switch classifyJournalActor(journal, result.Author, reviewerSet) {
case actorReviewer:
result.ReviewerComments++
latestReviewerAt = apiLatestTime(latestReviewerAt, journalTime(journal))
case actorSubmitter:
result.SubmitterComments++
latestSubmitterAt = apiLatestTime(latestSubmitterAt, journalTime(journal))
case actorParticipant:
result.ParticipantComments++
case actorBot:
result.BotEvents++
case actorSystem:
result.SystemEvents++
default:
result.UnknownActorEvents++
}
}
if !result.Reviewed && result.ReviewerComments > 0 {
result.Reviewed = true
result.ReviewStandard = reviewStandardReviewerJournal
}
}
}
if !latestReviewerAt.IsZero() {
result.LatestReviewerAt = latestReviewerAt.Format(time.RFC3339)
}
if !latestSubmitterAt.IsZero() {
result.LatestSubmitterAt = latestSubmitterAt.Format(time.RFC3339)
}
latestCommitAt := latestCommitTime(pr.Commits)
if !latestCommitAt.IsZero() {
result.LatestCommitAt = latestCommitAt.Format(time.RFC3339)
}
if !pr.UpdatedAt.IsZero() {
result.LatestPRUpdateAt = pr.UpdatedAt.Format(time.RFC3339)
}
result.NeedsReReview = needsReReview(latestReviewerAt, latestSubmitterAt, latestCommitAt, pr.UpdatedAt)
return result, notes
}
func fetchFormalPRReviews(ctx *common.RuntimeContext, owner, repo string, number int) ([]normalizedPRReview, error) {
items, err := fetchListItems(ctx, prPath(owner, repo, number)+"/reviews", url.Values{}, 50, 0)
if err != nil {
return nil, err
}
reviews := make([]normalizedPRReview, 0, len(items))
for _, item := range items {
review := normalizedPRReview{
Actor: firstReviewActor(item),
Status: strings.ToLower(strings.TrimSpace(firstPRString(item, "status", "state", "review_status"))),
At: journalTime(item),
}
if review.Actor == "" && review.Status == "" {
continue
}
if review.Status == "" {
review.Status = "common"
}
reviews = append(reviews, review)
}
return reviews, nil
}
func latestReviewTime(reviews []normalizedPRReview) time.Time {
var latest time.Time
for _, review := range reviews {
latest = apiLatestTime(latest, review.At)
}
return latest
}
func journalTime(item map[string]interface{}) time.Time {
return apiLatestTime(
firstPRTime(item, "updated_at", "updatedAt"),
firstPRTime(item, "created_at", "createdAt"),
)
}
func latestCommitTime(commits []PRCommit) time.Time {
var latest time.Time
for _, commit := range commits {
latest = apiLatestTime(latest, commit.Date)
}
return latest
}
func needsReReview(latestReviewerAt, latestSubmitterAt, latestCommitAt, latestPRUpdateAt time.Time) bool {
if latestReviewerAt.IsZero() {
return false
}
return isAfter(latestSubmitterAt, latestReviewerAt) ||
isAfter(latestCommitAt, latestReviewerAt) ||
isAfter(latestPRUpdateAt, latestReviewerAt)
}
func isAfter(value, baseline time.Time) bool {
return !value.IsZero() && !baseline.IsZero() && value.After(baseline)
}
func fetchPRJournals(ctx *common.RuntimeContext, owner, repo string, issueID int) ([]map[string]interface{}, error) {
return fetchListItems(ctx, fmt.Sprintf("/v1/%s/%s/issues/%d/journals", owner, repo, issueID), url.Values{}, 50, 0)
}
func firstReviewActor(item map[string]interface{}) string {
for _, key := range []string{"reviewer", "user", "author", "creator"} {
if value, ok := item[key]; ok {
if actor := apiAuthor(value); actor != "" {
return actor
}
}
}
return ""
}
func summarizeFormalReviewStatus(reviews []normalizedPRReview) string {
if len(reviews) == 0 {
return "unreviewed"
}
hasApproved := false
hasCommon := false
for _, review := range reviews {
switch strings.ToLower(strings.TrimSpace(review.Status)) {
case "rejected", "reject", "changes_requested", "request_changes":
return "rejected"
case "approved", "approve":
hasApproved = true
default:
hasCommon = true
}
}
if hasApproved {
return "approved"
}
if hasCommon {
return "common"
}
return "reviewed"
}
func uniqueReviewerNames(reviews []normalizedPRReview) []string {
set := map[string]string{}
for _, review := range reviews {
key := normalizeActorID(review.Actor)
if key != "" {
set[key] = review.Actor
}
}
keys := make([]string, 0, len(set))
for key := range set {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]string, 0, len(keys))
for _, key := range keys {
out = append(out, set[key])
}
return out
}
func reviewerSet(reviews []normalizedPRReview) map[string]bool {
set := map[string]bool{}
for _, review := range reviews {
if key := normalizeActorID(review.Actor); key != "" {
set[key] = true
}
}
return set
}
func classifyJournalActor(item map[string]interface{}, author string, reviewers map[string]bool) string {
category := strings.ToLower(strings.TrimSpace(firstPRString(item, "operate_category", "category", "type", "event")))
content := strings.TrimSpace(firstPRString(item, "notes", "note", "body", "content", "operate_content"))
actor := firstReviewActor(item)
if isSystemJournalEvent(category, content, actor) {
return actorSystem
}
if actor == "" {
return actorUnknown
}
if isBotActor(actor) {
return actorBot
}
actorID := normalizeActorID(actor)
if actorID != "" && actorID == normalizeActorID(author) {
return actorSubmitter
}
if actorID != "" && reviewers[actorID] {
return actorReviewer
}
return actorParticipant
}
func isSystemJournalEvent(category, content, actor string) bool {
if strings.TrimSpace(content) == "" {
return true
}
switch category {
case "status", "state", "system", "relation", "assignee", "label", "milestone":
return true
}
if strings.TrimSpace(actor) == "" && category != "" {
return true
}
return false
}
func isBotActor(actor string) bool {
actor = strings.ToLower(strings.TrimSpace(actor))
return strings.Contains(actor, "bot") || strings.Contains(actor, "机器人") || strings.Contains(actor, "automation")
}
func normalizeActorID(actor string) string {
return strings.ToLower(strings.TrimSpace(actor))
}

View File

@ -33,11 +33,14 @@ const (
type PRSummaryInput struct {
Repository string `json:"repository"`
Number int `json:"number"`
IssueID int `json:"issue_id,omitempty"`
Title string `json:"title"`
Author string `json:"author"`
State string `json:"state"`
BaseBranch string `json:"base_branch"`
HeadBranch string `json:"head_branch"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
Body string `json:"body,omitempty"`
ChangedFiles []PRChangedFile `json:"changed_files"`
Commits []PRCommit `json:"commits"`
@ -76,6 +79,7 @@ type PRSummaryResult struct {
CommitCount int `json:"commit_count"`
ChangeType string `json:"change_type"`
RiskLevel string `json:"risk_level"`
RiskReasons []string `json:"risk_reasons,omitempty"`
ReviewFocus []string `json:"review_focus"`
TestSuggestions []string `json:"test_suggestions"`
MergeChecklist []string `json:"merge_checklist"`
@ -209,6 +213,7 @@ func AnalyzePRSummary(input PRSummaryInput, lang string) PRSummaryResult {
CommitCount: len(input.Commits),
ChangeType: changeType,
RiskLevel: riskLevel,
RiskReasons: riskReasons,
ReviewFocus: reviewFocus,
TestSuggestions: testSuggestions,
MergeChecklist: mergeChecklist,

View File

@ -82,6 +82,9 @@ func TestAnalyzePRSummaryAuthTokenCriticalRisk(t *testing.T) {
if result.RiskLevel != PRRiskCritical {
t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskCritical)
}
if len(result.RiskReasons) != 1 || result.RiskReasons[0] != "security-sensitive keyword" {
t.Fatalf("RiskReasons = %v, want security-sensitive keyword", result.RiskReasons)
}
}
func TestAnalyzePRSummaryMixedFiles(t *testing.T) {

View File

@ -178,10 +178,22 @@ func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) err
if len(result.Recommendations) > 0 {
topRecommendation = truncateTableText(result.Recommendations[0], 96)
}
if _, err := fmt.Fprintln(tw, "REPOSITORY\tREPORT_SCORE\tRISK\tHEALTH_SCORE\tISSUES\tHIGH_RISK_ISSUES\tPRS\tHIGH_RISK_PRS\tTOP_RECOMMENDATION"); err != nil {
openPRs, mergedPRs, closedPRs := "N/A", "N/A", "N/A"
if result.PRLifecycle != nil {
openPRs = fmt.Sprintf("%d", result.PRLifecycle.Open)
mergedPRs = fmt.Sprintf("%d", result.PRLifecycle.Merged)
closedPRs = fmt.Sprintf("%d", result.PRLifecycle.ClosedOrRejected)
}
reviewedPRs, unreviewedPRs, needsReReviewPRs := "N/A", "N/A", "N/A"
if result.PRReviewAudit != nil {
reviewedPRs = fmt.Sprintf("%d", result.PRReviewAudit.Reviewed)
unreviewedPRs = fmt.Sprintf("%d", result.PRReviewAudit.Unreviewed)
needsReReviewPRs = fmt.Sprintf("%d", result.PRReviewAudit.NeedsReReview)
}
if _, err := fmt.Fprintln(tw, "REPOSITORY\tREPORT_SCORE\tRISK\tHEALTH_SCORE\tISSUES_ANALYZED\tHIGH_RISK_ISSUES\tPRS_ANALYZED\tHIGH_RISK_PRS\tOPEN_PRS\tMERGED_PRS\tCLOSED_PRS\tREVIEWED_PRS\tUNREVIEWED_PRS\tNEEDS_RE_REVIEW\tTOP_RECOMMENDATION"); err != nil {
return err
}
if _, err := fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%d\t%d\t%d\t%d\t%s\n",
if _, err := fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%d\t%d\t%d\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
result.Repository,
result.ReportScore,
result.RiskLevel,
@ -190,6 +202,12 @@ func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) err
result.IssueSummary.HighRisk,
result.PRSummary.Total,
result.PRSummary.HighRisk,
openPRs,
mergedPRs,
closedPRs,
reviewedPRs,
unreviewedPRs,
needsReReviewPRs,
topRecommendation,
); err != nil {
return err
@ -305,6 +323,36 @@ func writeRepoReportMarkdown(w io.Writer, result RepoReportResult, lang string)
}
writeCountMapMarkdown(w, "By type", result.PRSummary.ByType)
writeCountMapMarkdown(w, "By risk", result.PRSummary.ByRisk)
writeCountMapMarkdown(w, "Risk rule sources", result.PRSummary.RiskSources)
if result.PRLifecycle != nil {
if _, err := fmt.Fprintf(w, "- Lifecycle totals: open `%d`, merged `%d`, closed/rejected `%d`, all states `%d`\n",
result.PRLifecycle.Open,
result.PRLifecycle.Merged,
result.PRLifecycle.ClosedOrRejected,
result.PRLifecycle.Total,
); err != nil {
return err
}
}
if result.PRReviewAudit != nil {
if _, err := fmt.Fprintf(w, "- Review audit: audited `%d`, reviewed `%d`, unreviewed `%d`, needs re-review `%d`, formal reviews `%d`\n",
result.PRReviewAudit.Audited,
result.PRReviewAudit.Reviewed,
result.PRReviewAudit.Unreviewed,
result.PRReviewAudit.NeedsReReview,
result.PRReviewAudit.FormalReviews,
); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "- Review actor attribution: reviewer comments `%d`, submitter comments `%d`, participant comments `%d`, system events `%d`\n",
result.PRReviewAudit.ReviewerComments,
result.PRReviewAudit.SubmitterComments,
result.PRReviewAudit.ParticipantComments,
result.PRReviewAudit.SystemEvents,
); err != nil {
return err
}
}
if len(result.PRSummary.ReviewFocus) > 0 {
if _, err := fmt.Fprintln(w, "- Review focus:"); err != nil {
return err

View File

@ -8,29 +8,80 @@ import (
"strings"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type RepoReportInput struct {
Repository string `json:"repository"`
Health *HealthInput `json:"health,omitempty"`
Issues []IssueInput `json:"issues,omitempty"`
PullRequests []PRSummaryInput `json:"pull_requests,omitempty"`
Source string `json:"source"`
Repository string `json:"repository"`
Health *HealthInput `json:"health,omitempty"`
Issues []IssueInput `json:"issues,omitempty"`
PullRequests []PRSummaryInput `json:"pull_requests,omitempty"`
PRLifecycle *RepoPRLifecycle `json:"pr_lifecycle,omitempty"`
PRReviewAudit *RepoPRReviewAudit `json:"pr_review_audit,omitempty"`
Source string `json:"source"`
}
type RepoReportResult struct {
Repository string `json:"repository"`
Health *HealthResult `json:"health,omitempty"`
IssueSummary RepoIssueSummary `json:"issue_summary"`
PRSummary RepoPRSummary `json:"pr_summary"`
Recommendations []string `json:"recommendations"`
RiskLevel string `json:"risk_level"`
ReportScore int `json:"report_score"`
Sections []string `json:"sections"`
Reasoning []string `json:"reasoning"`
Source string `json:"source"`
Repository string `json:"repository"`
Health *HealthResult `json:"health,omitempty"`
IssueSummary RepoIssueSummary `json:"issue_summary"`
PRSummary RepoPRSummary `json:"pr_summary"`
PRLifecycle *RepoPRLifecycle `json:"pr_lifecycle,omitempty"`
PRReviewAudit *RepoPRReviewAudit `json:"pr_review_audit,omitempty"`
Recommendations []string `json:"recommendations"`
RiskLevel string `json:"risk_level"`
ReportScore int `json:"report_score"`
Sections []string `json:"sections"`
Reasoning []string `json:"reasoning"`
Source string `json:"source"`
}
type RepoPRLifecycle struct {
Open int `json:"open"`
Merged int `json:"merged"`
ClosedOrRejected int `json:"closed_or_rejected"`
Total int `json:"total"`
Source string `json:"source"`
}
type RepoPRReviewAudit struct {
Audited int `json:"audited"`
Reviewed int `json:"reviewed"`
Unreviewed int `json:"unreviewed"`
NeedsReReview int `json:"needs_re_review"`
FormalReviews int `json:"formal_reviews"`
ReviewerComments int `json:"reviewer_comments"`
SubmitterComments int `json:"submitter_comments"`
ParticipantComments int `json:"participant_comments"`
BotEvents int `json:"bot_events"`
SystemEvents int `json:"system_events"`
UnknownActorEvents int `json:"unknown_actor_events"`
Errors int `json:"errors"`
Source string `json:"source"`
PullRequests []PRReviewAudit `json:"pull_requests,omitempty"`
}
type PRReviewAudit struct {
Number int `json:"number"`
Author string `json:"author,omitempty"`
IssueID int `json:"issue_id,omitempty"`
Reviewed bool `json:"reviewed"`
NeedsReReview bool `json:"needs_re_review"`
ReviewStandard string `json:"review_standard"`
FormalReviewStatus string `json:"formal_review_status"`
FormalReviewCount int `json:"formal_review_count"`
ReviewerComments int `json:"reviewer_comments"`
SubmitterComments int `json:"submitter_comments"`
ParticipantComments int `json:"participant_comments"`
BotEvents int `json:"bot_events"`
SystemEvents int `json:"system_events"`
UnknownActorEvents int `json:"unknown_actor_events"`
Reviewers []string `json:"reviewers,omitempty"`
LatestReviewerAt string `json:"latest_reviewer_at,omitempty"`
LatestSubmitterAt string `json:"latest_submitter_at,omitempty"`
LatestCommitAt string `json:"latest_commit_at,omitempty"`
LatestPRUpdateAt string `json:"latest_pr_update_at,omitempty"`
Notes []string `json:"notes,omitempty"`
}
type RepoIssueSummary struct {
@ -45,23 +96,27 @@ type RepoPRSummary struct {
Total int `json:"total"`
ByType map[string]int `json:"by_type"`
ByRisk map[string]int `json:"by_risk"`
RiskSources map[string]int `json:"risk_sources,omitempty"`
HighRisk int `json:"high_risk"`
ReviewFocus []string `json:"review_focus"`
}
func newRepoReportShortcut(tr *i18n.Translator) *common.Shortcut {
func newRepoReportShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "repo-report",
Description: tr.T("cmd.workflow.repo-report.short"),
Description: "Generate a read-only repository workflow report",
Flags: []common.Flag{
{Name: "from", Usage: tr.T("flag.workflow.repo_report.from")},
{Name: "issue-limit", Usage: tr.T("flag.workflow.repo_report.issue_limit"), Default: "20"},
{Name: "pr-limit", Usage: tr.T("flag.workflow.repo_report.pr_limit"), Default: "10"},
{Name: "stale-days", Usage: tr.T("flag.workflow.repo_report.stale_days"), Default: "30"},
{Name: "include-issues", Usage: tr.T("flag.workflow.repo_report.include_issues"), Bool: true, Default: "true"},
{Name: "include-prs", Usage: tr.T("flag.workflow.repo_report.include_prs"), Bool: true, Default: "true"},
{Name: "include-health", Usage: tr.T("flag.workflow.repo_report.include_health"), Bool: true, Default: "true"},
{Name: "lang", Usage: tr.T("flag.workflow.repo_report.lang"), Default: langEN},
{Name: "from", Usage: "Read repository report input from a JSON file"},
{Name: "issue-limit", Usage: "Maximum issues to fetch and analyze; 0 analyzes all open issues", Default: "0"},
{Name: "pr-limit", Usage: "Maximum pull requests to fetch and summarize; 0 analyzes all open pull requests", Default: "0"},
{Name: "stale-days", Usage: "Days before an issue or PR is considered stale", Default: "30"},
{Name: "include-issues", Usage: "Include issue triage summary", Bool: true, Default: "true"},
{Name: "include-prs", Usage: "Include pull request summary", Bool: true, Default: "true"},
{Name: "include-pr-lifecycle", Usage: "Include open/merged/closed PR totals", Bool: true, Default: "true"},
{Name: "include-pr-review-audit", Usage: "Read formal reviews and PR conversation journals for actor attribution", Bool: true, Default: "false"},
{Name: "pr-review-audit-limit", Usage: "Maximum analyzed PRs to review-audit; 0 audits every analyzed PR", Default: "0"},
{Name: "include-health", Usage: "Include repository health summary", Bool: true, Default: "true"},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
},
Run: runRepoReport,
}
@ -105,11 +160,11 @@ func collectRepoReportInput(ctx *common.RuntimeContext) (RepoReportInput, []Scor
return input, nil, nil
}
issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-limit")
issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 0, "issue-limit")
if err != nil {
return RepoReportInput{}, nil, err
}
prLimit, err := parseIntArg(ctx.Arg("pr-limit"), 10, "pr-limit")
prLimit, err := parseIntArg(ctx.Arg("pr-limit"), 0, "pr-limit")
if err != nil {
return RepoReportInput{}, nil, err
}
@ -117,14 +172,21 @@ func collectRepoReportInput(ctx *common.RuntimeContext) (RepoReportInput, []Scor
if err != nil {
return RepoReportInput{}, nil, err
}
prReviewAuditLimit, err := parseIntArg(ctx.Arg("pr-review-audit-limit"), 0, "pr-review-audit-limit")
if err != nil {
return RepoReportInput{}, nil, err
}
return FetchRepoReportInput(ctx, RepoReportFetchOptions{
IssueLimit: issueLimit,
PRLimit: prLimit,
StaleDays: staleDays,
IncludeIssues: parseBoolArg(ctx.Arg("include-issues")),
IncludePRs: parseBoolArg(ctx.Arg("include-prs")),
IncludeHealth: parseBoolArg(ctx.Arg("include-health")),
IssueLimit: issueLimit,
PRLimit: prLimit,
StaleDays: staleDays,
PRReviewAuditLimit: prReviewAuditLimit,
IncludeIssues: parseBoolArg(ctx.Arg("include-issues")),
IncludePRs: parseBoolArg(ctx.Arg("include-prs")),
IncludePRLifecycle: parseBoolArg(ctx.Arg("include-pr-lifecycle")),
IncludePRReviewAudit: parseBoolArg(ctx.Arg("include-pr-review-audit")),
IncludeHealth: parseBoolArg(ctx.Arg("include-health")),
})
}
@ -199,6 +261,8 @@ func AnalyzeRepoReport(input RepoReportInput, lang string) RepoReportResult {
Health: healthResult,
IssueSummary: issueSummary,
PRSummary: prSummary,
PRLifecycle: input.PRLifecycle,
PRReviewAudit: input.PRReviewAudit,
Recommendations: recommendations,
RiskLevel: risk,
ReportScore: reportScore,
@ -232,8 +296,9 @@ func summarizeRepoIssues(issues []IssueInput, lang string) (RepoIssueSummary, []
func summarizeRepoPRs(inputs []PRSummaryInput, lang string) (RepoPRSummary, []PRSummaryResult) {
summary := RepoPRSummary{
ByType: map[string]int{},
ByRisk: map[string]int{},
ByType: map[string]int{},
ByRisk: map[string]int{},
RiskSources: map[string]int{},
}
results := make([]PRSummaryResult, 0, len(inputs))
focus := []string{}
@ -245,6 +310,9 @@ func summarizeRepoPRs(inputs []PRSummaryInput, lang string) (RepoPRSummary, []PR
summary.ByRisk[result.RiskLevel]++
if result.RiskLevel == PRRiskHigh || result.RiskLevel == PRRiskCritical {
summary.HighRisk++
for _, reason := range result.RiskReasons {
summary.RiskSources[reason]++
}
}
focus = append(focus, result.ReviewFocus...)
}

View File

@ -8,14 +8,17 @@ import (
)
type RepoReportFetchOptions struct {
Owner string
Repo string
IssueLimit int
PRLimit int
StaleDays int
IncludeIssues bool
IncludePRs bool
IncludeHealth bool
Owner string
Repo string
IssueLimit int
PRLimit int
StaleDays int
PRReviewAuditLimit int
IncludeIssues bool
IncludePRs bool
IncludePRLifecycle bool
IncludePRReviewAudit bool
IncludeHealth bool
}
func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOptions) (RepoReportInput, []ScoringNote, error) {
@ -23,12 +26,6 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption
if err != nil {
return RepoReportInput{}, nil, fmt.Errorf("workflow +repo-report remote mode requires --owner and --repo or a Git remote: %w", err)
}
if opts.IssueLimit <= 0 {
opts.IssueLimit = 20
}
if opts.PRLimit <= 0 {
opts.PRLimit = 10
}
if opts.StaleDays <= 0 {
opts.StaleDays = 30
}
@ -59,13 +56,7 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption
}
if opts.IncludeIssues {
issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{
Owner: owner,
Repo: repo,
State: "open",
Limit: opts.IssueLimit,
Page: 1,
})
issues, err := fetchIssueListForReport(ctx, owner, repo, opts.IssueLimit)
if err != nil {
notes = append(notes, ScoringNote{Metric: "repo_report_issues", Note: fmt.Sprintf("issue fetch failed: %v", err)})
} else {
@ -81,6 +72,16 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption
} else {
input.PullRequests = prs
successes++
if opts.IncludePRLifecycle {
lifecycle, lifecycleNotes := fetchPRLifecycle(ctx, owner, repo)
input.PRLifecycle = lifecycle
notes = append(notes, lifecycleNotes...)
}
if opts.IncludePRReviewAudit {
audit, auditNotes := fetchPRReviewAudit(ctx, owner, repo, prs, opts.PRReviewAuditLimit)
input.PRReviewAudit = audit
notes = append(notes, auditNotes...)
}
if len(prs) > 0 {
notes = append(notes, ScoringNote{
Metric: "repo_report_prs",
@ -99,25 +100,65 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption
return input, uniqueScoringNotes(notes), nil
}
func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]PRSummaryInput, error) {
if limit <= 0 {
limit = 10
func fetchPRLifecycle(ctx *common.RuntimeContext, owner, repo string) (*RepoPRLifecycle, []ScoringNote) {
states := []struct {
name string
value *int
}{
{name: "open"},
{name: "merged"},
{name: "closed"},
}
query := pullListQuery("open")
query.Set("page", "1")
query.Set("limit", fmt.Sprintf("%d", limit))
lifecycle := &RepoPRLifecycle{Source: "remote-read-only-fetch:list-totals"}
states[0].value = &lifecycle.Open
states[1].value = &lifecycle.Merged
states[2].value = &lifecycle.ClosedOrRejected
notes := []ScoringNote{}
successes := 0
for _, state := range states {
total, err := fetchPRStateTotal(ctx, owner, repo, state.name)
if err != nil {
notes = append(notes, ScoringNote{
Metric: "repo_report_pr_lifecycle",
Note: fmt.Sprintf("%s PR total unavailable: %v", state.name, err),
})
continue
}
*state.value = total
successes++
}
if successes == 0 {
return nil, notes
}
lifecycle.Total = lifecycle.Open + lifecycle.Merged + lifecycle.ClosedOrRejected
return lifecycle, notes
}
func fetchPRStateTotal(ctx *common.RuntimeContext, owner, repo, state string) (int, error) {
query := pullListQuery(state)
query.Set("page", "1")
query.Set("limit", "1")
env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/pulls", query)
if err != nil {
return 0, err
}
if env.Meta != nil && env.Meta.TotalCount > 0 {
return env.Meta.TotalCount, nil
}
if total := apiListTotal(env.Data); total > 0 {
return total, nil
}
return len(apiList(env.Data)), nil
}
func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]PRSummaryInput, error) {
pageSize := reportPageSize(limit)
items, err := fetchListItems(ctx, workflowRepoPath(owner, repo)+"/pulls", pullListQuery("open"), pageSize, limit)
if err != nil {
return nil, err
}
items := apiList(env.Data)
inputs := make([]PRSummaryInput, 0, len(items))
for _, raw := range items {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
for _, item := range items {
input, ok := normalizePRSummaryItem(item)
if !ok {
continue
@ -128,9 +169,37 @@ func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit
input.State = "open"
}
inputs = append(inputs, input)
if len(inputs) >= limit {
if limit > 0 && len(inputs) >= limit {
break
}
}
return inputs, nil
}
func fetchIssueListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]IssueInput, error) {
pageSize := reportPageSize(limit)
items, err := fetchListItems(ctx, workflowRepoPath(owner, repo)+"/issues", issueListQuery("open"), pageSize, limit)
if err != nil {
return nil, err
}
issues := make([]IssueInput, 0, len(items))
for _, item := range items {
issue, ok := normalizeIssueItem(item)
if !ok {
continue
}
issues = append(issues, issue)
if limit > 0 && len(issues) >= limit {
break
}
}
return issues, nil
}
func reportPageSize(limit int) int {
const maxPageSize = 50
if limit > 0 && limit < maxPageSize {
return limit
}
return maxPageSize
}

View File

@ -1,6 +1,7 @@
package workflow
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
@ -179,6 +180,210 @@ func TestFetchRepoReportInputPRListMetadata(t *testing.T) {
}
}
func TestFetchRepoReportInputPaginatesAllOpenItemsByDefault(t *testing.T) {
prPages := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/owner/repo/issues.json":
if got := r.URL.Query().Get("category"); got != "opened" {
t.Fatalf("issue category = %q, want opened", got)
}
writeWorkflowJSON(t, w, map[string]interface{}{
"total_count": 2,
"issues": []map[string]interface{}{
{"id": 1, "number": 1, "title": "First open issue"},
{"id": 2, "number": 2, "title": "Second open issue"},
},
})
case "/v1/owner/repo/pulls.json":
if got := r.URL.Query().Get("status"); got != "0" {
t.Fatalf("PR status = %q, want 0", got)
}
page := mustParseInt(r.URL.Query().Get("page"), 1)
prPages++
start := (page - 1) * 50
end := start + 50
if end > 120 {
end = 120
}
pulls := make([]map[string]interface{}, 0, end-start)
for index := start + 1; index <= end; index++ {
pulls = append(pulls, map[string]interface{}{
"id": 1000 + index,
"index": index,
"title": fmt.Sprintf("PR %d", index),
})
}
writeWorkflowJSON(t, w, map[string]interface{}{
"total_count": 120,
"pulls": pulls,
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, _, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
IncludeHealth: false,
IncludeIssues: true,
IncludePRs: true,
})
if err != nil {
t.Fatalf("FetchRepoReportInput returned error: %v", err)
}
if len(input.Issues) != 2 {
t.Fatalf("len(Issues) = %d, want 2", len(input.Issues))
}
if len(input.PullRequests) != 120 {
t.Fatalf("len(PullRequests) = %d, want 120", len(input.PullRequests))
}
if prPages != 3 {
t.Fatalf("PR pages = %d, want 3", prPages)
}
if input.PullRequests[119].Number != 120 {
t.Fatalf("last PR number = %d, want 120", input.PullRequests[119].Number)
}
}
func TestFetchRepoReportInputIncludesPRLifecycleTotals(t *testing.T) {
openCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("limit"); got != "1" {
t.Fatalf("PR limit = %q, want 1", got)
}
switch r.URL.Query().Get("status") {
case "0":
openCalls++
writeWorkflowJSON(t, w, map[string]interface{}{
"total_count": 12,
"pulls": []map[string]interface{}{
{"id": 1001, "index": 1, "title": "Open PR"},
},
})
case "1":
writeWorkflowJSON(t, w, map[string]interface{}{
"total_count": 5,
"pulls": []map[string]interface{}{},
})
case "2":
writeWorkflowJSON(t, w, map[string]interface{}{
"total_count": 7,
"pulls": []map[string]interface{}{},
})
default:
t.Fatalf("unexpected PR status query: %q", r.URL.Query().Get("status"))
}
}))
defer server.Close()
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
PRLimit: 1,
IncludeHealth: false,
IncludeIssues: false,
IncludePRs: true,
IncludePRLifecycle: true,
})
if err != nil {
t.Fatalf("FetchRepoReportInput returned error: %v", err)
}
if len(input.PullRequests) != 1 {
t.Fatalf("len(PullRequests) = %d, want 1", len(input.PullRequests))
}
if input.PRLifecycle == nil {
t.Fatal("PRLifecycle is nil, want totals")
}
if input.PRLifecycle.Open != 12 || input.PRLifecycle.Merged != 5 || input.PRLifecycle.ClosedOrRejected != 7 || input.PRLifecycle.Total != 24 {
t.Fatalf("PRLifecycle = %+v, want open=12 merged=5 closed=7 total=24", input.PRLifecycle)
}
if openCalls != 2 {
t.Fatalf("open status calls = %d, want 2 for list fetch plus lifecycle total", openCalls)
}
if !hasNote(notes, "repo_report_prs") {
t.Fatalf("notes = %+v, want repo_report_prs note", notes)
}
}
func TestFetchRepoReportInputIncludesPRReviewAudit(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
writeWorkflowJSON(t, w, map[string]interface{}{
"total_count": 2,
"pulls": []map[string]interface{}{
{
"id": 1001,
"index": 1,
"title": "feat: reviewed change",
"author": map[string]interface{}{"login": "alice"},
"issue": map[string]interface{}{"id": 501},
"updated_at": "2026-06-02T12:00:00Z",
},
{"id": 1002, "index": 2, "title": "docs: unreviewed change", "author": map[string]interface{}{"login": "dana"}, "issue": map[string]interface{}{"id": 502}},
},
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/1/reviews.json":
writeWorkflowJSON(t, w, map[string]interface{}{"reviews": []map[string]interface{}{
{"reviewer": map[string]interface{}{"login": "bob"}, "status": "approved", "content": "looks good", "created_at": "2026-06-01T10:00:00Z"},
}})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/2/reviews.json":
writeWorkflowJSON(t, w, map[string]interface{}{"reviews": []map[string]interface{}{}})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/501/journals.json":
writeWorkflowJSON(t, w, map[string]interface{}{"journals": []map[string]interface{}{
{"user": map[string]interface{}{"login": "alice"}, "notes": "I updated the branch", "operate_category": "comment", "created_at": "2026-06-02T11:00:00Z"},
{"user": map[string]interface{}{"login": "bob"}, "notes": "Please keep this test", "operate_category": "comment", "created_at": "2026-06-01T09:30:00Z"},
{"user": map[string]interface{}{"login": "carol"}, "notes": "I can reproduce this", "operate_category": "comment", "created_at": "2026-06-01T12:00:00Z"},
{"user": map[string]interface{}{"login": "system"}, "operate_content": "status changed", "operate_category": "status"},
}})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/502/journals.json":
writeWorkflowJSON(t, w, map[string]interface{}{"journals": []map[string]interface{}{
{"user": map[string]interface{}{"login": "dana"}, "notes": "Initial description", "operate_category": "comment"},
}})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
IncludeHealth: false,
IncludeIssues: false,
IncludePRs: true,
IncludePRReviewAudit: true,
})
if err != nil {
t.Fatalf("FetchRepoReportInput returned error: %v", err)
}
if len(notes) != 1 || notes[0].Metric != "repo_report_prs" {
t.Fatalf("notes = %+v, want only list metadata note", notes)
}
audit := input.PRReviewAudit
if audit == nil {
t.Fatal("PRReviewAudit is nil")
}
if audit.Audited != 2 || audit.Reviewed != 1 || audit.Unreviewed != 1 || audit.NeedsReReview != 1 || audit.FormalReviews != 1 {
t.Fatalf("audit summary = %+v, want audited=2 reviewed=1 unreviewed=1 formal=1", audit)
}
if audit.SubmitterComments != 2 || audit.ReviewerComments != 1 || audit.ParticipantComments != 1 || audit.SystemEvents != 1 {
t.Fatalf("actor counts = submitter:%d reviewer:%d participant:%d system:%d",
audit.SubmitterComments, audit.ReviewerComments, audit.ParticipantComments, audit.SystemEvents)
}
first := audit.PullRequests[0]
if !first.Reviewed || first.ReviewStandard != reviewStandardFormal || first.FormalReviewStatus != "approved" {
t.Fatalf("first audit = %+v, want formal approved review", first)
}
if !first.NeedsReReview {
t.Fatalf("first audit = %+v, want needs_re_review after submitter update", first)
}
second := audit.PullRequests[1]
if second.Reviewed || second.ReviewStandard != reviewStandardUnreviewed {
t.Fatalf("second audit = %+v, want unreviewed despite submitter comment", second)
}
}
func hasNote(notes []ScoringNote, metric string) bool {
for _, note := range notes {
if note.Metric == metric {

View File

@ -76,6 +76,23 @@ func TestAnalyzeRepoReportPartialInput(t *testing.T) {
}
}
func TestAnalyzeRepoReportAggregatesPRRiskSources(t *testing.T) {
result := AnalyzeRepoReport(RepoReportInput{
Repository: "owner/repo",
PullRequests: []PRSummaryInput{{
Number: 1,
Title: "fix: prevent secret token leak",
Source: "remote-read-only-fetch:list-metadata",
}},
}, "en")
if result.PRSummary.HighRisk != 1 {
t.Fatalf("HighRisk = %d, want 1", result.PRSummary.HighRisk)
}
if result.PRSummary.RiskSources["security-sensitive keyword"] != 1 {
t.Fatalf("RiskSources = %v, want security-sensitive keyword=1", result.PRSummary.RiskSources)
}
}
func TestAnalyzeRepoReportChinese(t *testing.T) {
result := AnalyzeRepoReport(sampleRepoReportInput(), "zh-CN")
if len(result.Recommendations) == 0 {

View File

@ -2,7 +2,6 @@ package workflow
import (
"fmt"
"net/url"
"strings"
"time"
@ -28,8 +27,7 @@ func FetchIssuesForTriage(ctx *common.RuntimeContext, opts TriageFetchOptions) (
state = "open"
}
query := url.Values{}
query.Set("category", normalizeIssueListCategory(state))
query := issueListQuery(state)
query.Set("limit", fmt.Sprintf("%d", limit))
query.Set("page", fmt.Sprintf("%d", page))
if len(opts.Labels) > 0 {
@ -81,13 +79,13 @@ func normalizeIssueItem(raw interface{}) (IssueInput, bool) {
if id == "" {
id = fmt.Sprintf("%d", number)
}
state := firstIssueState(item)
state := firstIssueString(item, "state", "status")
author := firstIssueString(item, "author", "user", "creator")
urlValue := firstIssueString(item, "html_url", "url", "web_url")
labels := firstIssueLabels(item["labels"], item["tags"], item["issue_tags"])
labels := firstIssueLabels(item["labels"])
createdAt := firstIssueTime(item, "created_at", "created")
updatedAt := firstIssueTime(item, "updated_at", "updated", "last_updated_at")
comments := firstIssueInt(item, "comments_count", "comments", "comment_journals_count", "journals_count")
comments := firstIssueInt(item, "comments_count", "comments")
return IssueInput{
ID: id,
@ -150,48 +148,21 @@ func firstIssueTime(item map[string]interface{}, keys ...string) time.Time {
return time.Time{}
}
func firstIssueState(item map[string]interface{}) string {
if state := firstIssueString(item, "state", "status_name"); state != "" {
return state
}
if raw, ok := item["status"]; ok {
switch value := raw.(type) {
case map[string]interface{}:
for _, key := range []string{"name", "title", "label", "state"} {
if state := apiString(value[key]); state != "" {
return state
}
}
default:
if state := apiString(raw); state != "" {
return state
func firstIssueLabels(value interface{}) []string {
switch labels := value.(type) {
case []interface{}:
out := make([]string, 0, len(labels))
for _, label := range labels {
if s := apiStringValue(label); s != "" {
out = append(out, s)
}
}
return out
case []string:
return append([]string(nil), labels...)
case string:
return apiStringSlice(labels)
default:
return nil
}
return ""
}
func firstIssueLabels(values ...interface{}) []string {
out := []string{}
for _, value := range values {
switch labels := value.(type) {
case []interface{}:
for _, label := range labels {
if s := apiStringValue(label); s != "" {
out = append(out, s)
}
}
case []map[string]interface{}:
for _, label := range labels {
if s := apiStringValue(label); s != "" {
out = append(out, s)
}
}
case []string:
out = append(out, labels...)
case string:
out = append(out, apiStringSlice(labels)...)
}
}
return uniqueStrings(out)
}

View File

@ -20,7 +20,7 @@ func TestFetchIssuesForTriageNormalizesAPIResponse(t *testing.T) {
t.Fatalf("category query = %q, want opened", got)
}
if got := r.URL.Query().Get("state"); got != "" {
t.Fatalf("issue triage must not send state, got %q", got)
t.Fatalf("state query = %q, want empty", got)
}
if got := r.URL.Query().Get("limit"); got != "30" {
t.Fatalf("limit query = %q, want 30", got)

View File

@ -0,0 +1,176 @@
---
name: gitlink-feishu
version: 1.0.0
description: "Export GitLink workflow JSON to Feishu custom bot cards, digests, Bitable-ready records, and experimental Open Platform validation commands."
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli feishu --help"
---
# gitlink-feishu
Use this skill when a user needs to export GitLink workflow analysis into Feishu.
## Purpose
Stable path:
```text
workflow JSON -> Feishu bot card / weekly report / Bitable dry-run records
workflow JSON -> owner digest / contributor digest / task preview
```
Experimental path:
```text
workflow JSON -> Feishu DocX / Wiki export / Bitable sync / Task create
```
## Inputs
Workflow JSON should usually come from:
```bash
gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json > report.json
```
## Safety Rules
- Preview first.
- Use `--send` only when the user explicitly wants a Feishu network write.
- Never use BotBuilder or Robot Assistant workflows.
- Do not write to GitLink resources.
- Do not print webhook URLs, app secrets, access tokens, or table tokens.
- Treat `+bitable-schema`, `+bitable-records`, and `+task-preview` as local dry-run commands only.
- Treat `+doc-export` as experimental because it uses self-built app OpenAPI and document write permissions.
- Treat `+bitable-sync` and `+task-create` as experimental because they use self-built app OpenAPI and resource permissions.
## Preview Flow
Preview a card:
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --format json
```
Render a weekly report:
```bash
gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
```
Preview owner and contributor digests:
```bash
gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown
```
Generate Bitable schemas:
```bash
gitlink-cli feishu +bitable-schema --format markdown
```
Generate Bitable-ready records:
```bash
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
```
Preview task candidates:
```bash
gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown
```
## Send Flow
Custom bot commands need:
```text
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET optional
```
Send a card:
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
```
Send a weekly report card:
```bash
gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table
```
Send owner and contributor digest cards:
```bash
gitlink-cli feishu +owner-digest --from-workflow-json report.json --send --format table
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --send --format table
```
## Experimental Doc Export
DocX / Wiki export needs:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
```
Preview only:
```bash
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --format markdown
```
Write to Feishu:
```bash
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --send --format table
```
If Feishu returns `1770032: forBidden`, the app token is valid but the app cannot write to the target DocX/Wiki page or folder.
## Experimental Bitable Sync And Task Create
Open Platform validation commands need:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
FEISHU_BASE_APP_TOKEN for bitable-sync
FEISHU_REPORT_TABLE_ID / FEISHU_ISSUE_TABLE_ID / FEISHU_PR_TABLE_ID for selected tables
```
Preview Bitable sync:
```bash
gitlink-cli feishu +bitable-sync --from-workflow-json report.json --tables reports,issues,prs,tasks --format table
```
Write Bitable records:
```bash
gitlink-cli feishu +bitable-sync --from-workflow-json report.json --tables reports,issues,prs,tasks --send --format table
```
Create Feishu tasks:
```bash
gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table
```
## Non-Goals
- No GitLink remote writes.
- No GitLink comments, issue closure, merge, or webhook creation.
- No Bitable real writes in the stable path.
- No Feishu task creation in the stable path.
- No BotBuilder integration.
- No automatic Feishu permission changes.