diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index e503163..d4bd497 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -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 ./...
diff --git a/.gitignore b/.gitignore
index 9c5e2b3..5bdeb90 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.local/feishu-gitlink.env.example.ps1 b/.local/feishu-gitlink.env.example.ps1
new file mode 100644
index 0000000..202c7a9
--- /dev/null
+++ b/.local/feishu-gitlink.env.example.ps1
@@ -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=""
diff --git a/README.md b/README.md
index 2412eec..5577b2e 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 0fce1ff..f770289 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -5,7 +5,7 @@
[](https://golang.org)
[](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 @@
jiangtx
+
+
## 为什么选择 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
-
-# 导出 Wiki(markdown、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 --dry-run
+gitlink-cli dataset +delete-attachment --owner me --repo proj --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)
## 许可证
diff --git a/docs/FEISHU_CAPABILITY_LAYERS.md b/docs/FEISHU_CAPABILITY_LAYERS.md
new file mode 100644
index 0000000..b15a56a
--- /dev/null
+++ b/docs/FEISHU_CAPABILITY_LAYERS.md
@@ -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
+```
diff --git a/docs/FEISHU_ENVIRONMENT.md b/docs/FEISHU_ENVIRONMENT.md
new file mode 100644
index 0000000..bfed7d0
--- /dev/null
+++ b/docs/FEISHU_ENVIRONMENT.md
@@ -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.
+```
diff --git a/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md b/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md
new file mode 100644
index 0000000..e4dade1
--- /dev/null
+++ b/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md
@@ -0,0 +1,1199 @@
+# GitLink CLI x Feishu Redesign Research
+
+Date: 2026-06-26
+
+## 1. Executive Summary
+
+The current `gitlink-cli feishu` implementation is a safe export module. It can preview and send Feishu custom bot cards, render weekly reports, generate Bitable schemas and Bitable-ready records, and experiment with DocX / Wiki export.
+
+The redesigned product direction should be broader but staged:
+
+```text
+GitLink project collaboration gateway for maintainers, contributors, and teams inside Feishu.
+```
+
+The stable first path should remain conservative:
+
+```text
+GitLink data -> Feishu cards / reports / Bitable-ready records / Doc-ready content
+```
+
+The later path can become a permissioned collaboration gateway:
+
+```text
+Feishu app / card callback -> identity mapping -> GitLink action preview -> confirmation -> audited action
+```
+
+The key design correction is that Feishu should not be treated only as a message sink. Feishu's public product materials, Bilibili tutorials, and official Base documentation consistently position Bitable as:
+
+```text
+one data source
+multiple role-specific views
+lightweight business system
+dashboard / cockpit
+workflow and AI-enabled collaboration base
+```
+
+For GitLink projects, this means the most useful Feishu integration is not "send one message per PR". It is:
+
+```text
+1. owner cockpit
+2. contributor task panel
+3. PR / Issue / CI history base
+4. milestone Gantt
+5. weekly report archive
+6. Doc / Wiki project knowledge space
+7. optional low-risk GitLink action gateway
+```
+
+## 2. Research Sources
+
+### Current Repository
+
+Inspected local repository paths:
+
+```text
+README.md
+README.zh-CN.md
+shortcuts/feishu
+shortcuts/workflow
+shortcuts/issue
+shortcuts/pr
+shortcuts/webhook
+shortcuts/member
+shortcuts/ci
+shortcuts/pipeline
+skills/
+docs/feishu-integration.md
+docs/feishu-security.md
+reports/FEISHU_TASK_COMPLETION.md
+reports/FEISHU_TEST_ENTERPRISE_SMOKE_20260615.md
+feishu-export-design/ROLE_BASED_COLLABORATION.md
+```
+
+Local command checks:
+
+```text
+gitlink-cli feishu --help
+gitlink-cli issue --help
+gitlink-cli pr --help
+gitlink-cli webhook --help
+gitlink-cli workflow --help
+gitlink-cli member --help
+gitlink-cli ci --help
+gitlink-cli pipeline --help
+```
+
+### Feishu / Lark Official Sources
+
+Primary references:
+
+```text
+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
+https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN
+https://open.feishu.cn/document/server-docs/im-v1/message/create?lang=zh-CN
+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/bitable-v1/app-table-record/create?lang=zh-CN
+https://open.feishu.cn/document/task-v2/task/create?lang=zh-CN
+https://www.feishu.cn/product/base
+https://www.feishu.cn/hc/zh-CN/articles/360049067931-%E4%BD%BF%E7%94%A8%E5%A4%9A%E7%BB%B4%E8%A1%A8%E6%A0%BC%E8%A7%86%E5%9B%BE
+https://www.feishu.cn/hc/zh-CN/articles/558830919244-%E4%BD%BF%E7%94%A8%E5%A4%9A%E7%BB%B4%E8%A1%A8%E6%A0%BC%E7%9A%84%E7%94%98%E7%89%B9%E8%A7%86%E5%9B%BE
+https://www.feishu.cn/hc/zh-CN/articles/161059314076-%E4%BD%BF%E7%94%A8%E5%A4%9A%E7%BB%B4%E8%A1%A8%E6%A0%BC%E4%BB%AA%E8%A1%A8%E7%9B%98
+```
+
+### Bilibili / Social Content Sources
+
+Observed Bilibili content:
+
+```text
+https://www.bilibili.com/video/BV1rd4y167KZ/
+```
+
+This is a Feishu Help Center Bilibili video in the Feishu Base practical course collection. The collection includes:
+
+```text
+views: one Base, multiple presentation modes
+forms: information collection and aggregation
+dashboard: data visualization
+relations and lookup: data relationship modeling
+automation and advanced permissions
+Base-building methodology: build Base like a product
+sales management system
+HR recruiting and probation management system
+product and engineering agile development management
+Base plus Feishu collaboration combinations
+```
+
+Social-platform-facing Feishu content also emphasizes:
+
+```text
+one table for company operations
+enterprise cockpit
+multi-view switching
+project-management Gantt charts
+Kanban management
+calendar scheduling
+form collection
+dashboard analysis
+Xiaohongshu / Douyin / e-commerce content data management
+AI fields / AI summaries / content creation workflows
+```
+
+Direct Xiaohongshu pages are not reliably accessible through normal web indexing in this environment. The useful signal comes from Feishu official template and content pages that explicitly mention Xiaohongshu data management and content workflows:
+
+```text
+https://www.feishu.cn/template/group/base
+https://www.feishu.cn/content/article/7584012383060757728
+https://www.feishu.cn/content/article/7582904528077917408
+```
+
+### lark-cli Sources
+
+Primary references:
+
+```text
+https://github.com/larksuite/cli
+https://github.com/larksuite/cli/blob/main/README.zh.md
+https://open.larksuite.com/document/mcp_open_tools/feishu-cli-let-ai-actually-do-your-work-in-feishu
+https://www.feishu.cn/feishu-cli
+```
+
+Key lark-cli claims observed:
+
+```text
+official Lark / Feishu CLI
+built for humans and AI Agents
+covers Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, Markdown, etc.
+200+ commands
+20+ AI Agent Skills
+structured output
+schema introspection
+explicit safety warnings for AI Agent use
+```
+
+## 3. Current Repository Capability Assessment
+
+### Implemented Stable 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
+```
+
+Stable behavior:
+
+```text
+local preview by default
+real Feishu custom bot delivery only with --send
+--send and --dry-run conflict validation
+webhook URL redaction
+custom bot signing
+interactive card payload building
+workflow JSON input support
+weekly report rendering
+--doc-url button support
+Bitable schema dry-run
+Bitable-ready record dry-run
+UTF-8 / UTF-16 workflow JSON input support
+mockable HTTP client
+```
+
+### Experimental Command
+
+```bash
+gitlink-cli feishu +doc-export
+```
+
+Experimental behavior:
+
+```text
+app_id / app_secret
+tenant_access_token
+Wiki node resolution
+DocX block write attempt
+document export preview
+explicit --send for write attempt
+```
+
+Observed real Feishu test:
+
+```text
+custom bot send: passed
+notify send: passed
+weekly-report send: passed
+owner-digest send: passed
+contributor-digest send: passed
+Bitable schema / records dry-run: passed
+tenant_access_token: acquired
+DocX append: passed after the app and target document had permission
+Bitable search/create/update: passed after the test table fields were created
+Task create: passed at minimal summary/description level
+zh-CN Feishu output: passed for cards, digests, DocX blocks, and task candidates
+```
+
+Interpretation:
+
+```text
+The Open Platform path is practically usable in a configured test enterprise,
+but it should stay experimental because resource-level scopes, table fields,
+document permissions, and task placement are still operator-managed.
+```
+
+### Current Bitable Usage
+
+Current Bitable implementation has two surfaces:
+
+```text
+stable dry-run records
+experimental Open Platform sync
+```
+
+Implemented:
+
+```text
+feishu +bitable-schema
+feishu +bitable-records
+feishu +bitable-sync
+```
+
+Current tables:
+
+```text
+reports
+issues
+prs
+contributors
+tasks
+```
+
+Current records:
+
+```text
+reports: one row per workflow report
+issues: summary bucket rows
+prs: summary bucket rows
+contributors: role-oriented contributor digest row
+tasks: task candidates derived from workflow recommendations and risk buckets
+```
+
+Experimental real-write behavior:
+
+```text
+Bitable record search by unique_key
+Bitable record create
+Bitable record update
+create-only fallback when search fails
+no record deletion
+```
+
+Still not implemented:
+
+```text
+create Base
+create table
+create field
+create view
+batch create records
+Gantt / Kanban / Calendar / Gallery / Dashboard creation
+field permission setup
+```
+
+Conclusion:
+
+```text
+Current Bitable support is useful as schema and data-shape proof. The experimental sync path can validate writes into an existing Base, but it does not yet create views or a full project management cockpit.
+```
+
+To support project management, the next Bitable model must become row-level instead of summary-only:
+
+```text
+one row per PR
+one row per Issue
+one row per CI run
+one row per contributor-period
+one row per milestone
+one row per release
+one row per action/audit event
+```
+
+## 4. Feishu Capability Boundary
+
+### Custom Bot
+
+Fit:
+
+```text
+one-way group notifications
+message cards
+weekly report cards
+owner digest cards
+links to GitLink / Feishu Docs / Bitable views
+low-friction smoke test
+```
+
+Not fit:
+
+```text
+document writes
+Bitable writes
+task creation
+user identity mapping
+personal direct messages
+interactive GitLink write actions
+permission management
+callback server by itself
+```
+
+Design rule:
+
+```text
+Custom bot = notification and link surface.
+```
+
+### Self-Built App
+
+Fit:
+
+```text
+tenant_access_token
+app-level authentication
+IM message send
+DocX / Wiki write
+Bitable app / table / record operations
+Task API
+card callback / event subscription
+user identity resolution
+chat_id / repo binding
+permission diagnostics
+```
+
+Design rule:
+
+```text
+Self-built app = authorization, Feishu resource write, callbacks, and future interaction surface.
+```
+
+### lark-cli
+
+Fit:
+
+```text
+optional external bridge
+AI Agent operations on Feishu resources
+Docs / Base / Tasks / Calendar / Mail / IM operations
+manual experiments before native implementation
+developer productivity
+cross-platform workflows where Codex controls both CLIs
+```
+
+Not fit as a hard dependency:
+
+```text
+gitlink-cli mainline runtime
+stable Go tests
+minimal install path
+security-critical callback gateway
+deterministic server-side behavior
+```
+
+Design rule:
+
+```text
+gitlink-cli should not depend on lark-cli.
+lark-cli can be an optional bridge for agents and operators.
+```
+
+### gitlink-cli
+
+Fit:
+
+```text
+authoritative GitLink data reader
+authoritative GitLink action executor
+workflow report generation
+Issue / PR / Review / CI / Webhook / Member operations
+dry-run and confirmation behavior
+audit log source
+```
+
+Design rule:
+
+```text
+GitLink actions should be executed through gitlink-cli native commands, not through Feishu-side ad hoc HTTP calls hidden in callbacks.
+```
+
+Detailed GitLink capability boundary:
+
+```text
+docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md
+```
+
+## 5. Bilibili / Xiaohongshu / Social Content Findings
+
+### What Feishu Emphasizes Publicly
+
+The Bilibili tutorial collection and Feishu public materials repeatedly emphasize:
+
+```text
+Base is positioned as a lightweight business system, not just a spreadsheet.
+The same data can be presented through different views.
+Managers and executors should see role-specific views.
+Dashboards serve as business cockpits.
+Forms collect structured input.
+Kanban views show workflow state.
+Gantt views show time plans.
+Calendar views show schedules.
+Gallery views show card-style records.
+Automation reduces manual data movement.
+Advanced permissions protect sensitive data.
+AI fields and summaries support content production, classification, and analysis.
+```
+
+This matters for GitLink because GitLink data naturally has multiple roles:
+
+```text
+owner / maintainer
+reviewer
+contributor
+PM / project manager
+organization manager
+AI agent
+```
+
+The Feishu product narrative suggests that we should not build a single flat report. We should design a data model that supports multiple views.
+
+### Mapping Social Product Narrative to GitLink
+
+| Feishu narrative | GitLink equivalent | Product implication |
+| --- | --- | --- |
+| one table, many views | one project data model, many project views | Store PR / Issue / CI / milestone rows in Bitable |
+| management cockpit | owner dashboard | owner digest + dashboard records |
+| personal task panel | contributor pending action view | contributor digest and assigned PR/Issue records |
+| Kanban | PR / Issue stage board | group by review stage, issue status, CI status |
+| Gantt | milestone and release planning | start/end dates, dependencies, owners |
+| Calendar | review deadlines, release dates, meetings | due_date and scheduled review fields |
+| Gallery | project showcase and PR cards | card-style PR / contributor / feature display |
+| Form | collection and intake | issue intake, contributor weekly update, review request |
+| Dashboard | high-level metrics | open PRs, stale PRs, merged PRs, risk count, CI pass rate |
+| AI field / summary | summarization and classification | AI can summarize PRs, classify risks, draft weekly report |
+
+### Implication for Current Bitable Commands
+
+Current `+bitable-records` produces summary buckets. That is not enough for:
+
+```text
+Kanban
+Gantt
+Calendar
+Gallery
+Form-driven workflow
+individual task panel
+enterprise cockpit
+```
+
+Required next data shape:
+
+```text
+Pull Requests table:
+ pr_key
+ repository
+ number
+ title
+ author
+ assignee
+ reviewer
+ stage
+ stage_color
+ risk_level
+ review_rounds
+ patchset_count
+ changed_files
+ additions
+ deletions
+ mergeable
+ needs_rebase
+ ci_status
+ opened_at
+ updated_at
+ due_date
+ url
+
+Issues table:
+ issue_key
+ repository
+ number
+ title
+ author
+ assignee
+ status
+ priority
+ labels
+ risk_level
+ due_date
+ stale_days
+ url
+
+CI Runs table:
+ ci_key
+ repository
+ ref
+ commit
+ status
+ started_at
+ finished_at
+ duration
+ url
+
+Milestones table:
+ milestone_key
+ repository
+ title
+ owner
+ start_date
+ due_date
+ status
+ progress_percent
+ linked_prs
+ linked_issues
+ risk_level
+
+Contributors table:
+ contributor_key
+ gitlink_username
+ feishu_user_id
+ role
+ active_prs
+ merged_prs
+ pending_actions
+ last_activity_at
+
+Audit table:
+ action_id
+ source
+ actor_feishu_id
+ actor_gitlink_user
+ repository
+ action_type
+ risk_level
+ dry_run
+ confirmed
+ result
+ created_at
+```
+
+### Recommended Feishu Views
+
+| View | Target user | Backing table | Purpose |
+| --- | --- | --- | --- |
+| Table view | Admin / maintainer | all tables | raw data inspection |
+| Kanban view | Owner / reviewer | PRs, Issues | stage tracking |
+| Gantt view | PM / owner | Milestones, PRs | release and milestone planning |
+| Calendar view | Reviewer / contributor | PRs, Issues, Milestones | due dates and review windows |
+| Gallery view | Community manager | PRs, Contributors | showcase features and contributor highlights |
+| Form view | Contributors | Issues, Updates | issue intake and weekly updates |
+| Dashboard | Owner / organization | PRs, Issues, CI, Milestones | enterprise cockpit |
+| Personal task panel | Contributor | PRs, Issues | my pending actions |
+
+### Enterprise Cockpit
+
+For organization managers, the cockpit should answer:
+
+```text
+How many active repositories are healthy?
+Which repos have stale PRs?
+Which maintainers are overloaded?
+Which milestones are at risk?
+How many PRs are near merge?
+How many PRs need rebase?
+What is the CI pass rate trend?
+Which contributors need response?
+Which issues are high risk?
+```
+
+This should be implemented through Bitable records and dashboard views, not through long chat messages.
+
+### Personal Task Panel
+
+For contributors, the personal panel should answer:
+
+```text
+Which of my PRs need rebase?
+Which PRs received review comments?
+Which issues are assigned to me?
+Which CI runs failed on my branch?
+Which maintainer action am I waiting for?
+What was merged this week?
+```
+
+This requires identity mapping:
+
+```text
+GitLink username -> Feishu open_id / union_id / email
+```
+
+Until identity mapping exists, the CLI should only generate dry-run contributor records.
+
+## 6. Current gitlink-cli Capability Matrix
+
+This section summarizes the command surface. The detailed action-risk boundary is maintained in:
+
+```text
+docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md
+```
+
+### Issue
+
+Available commands:
+
+```text
+issue +list
+issue +view
+issue +create
+issue +update
+issue +comment
+issue +close
+issue +batch-close
+issue +authors
+issue +assigners
+issue +priorities
+issue +statuses
+issue +tags
+```
+
+Gateway classification:
+
+```text
+read: list, view, metadata list
+low-risk write: comment, create
+medium-risk write: update metadata
+high-risk write: close, batch-close
+```
+
+### Pull Request
+
+Available commands:
+
+```text
+pr +list
+pr +view
+pr +files
+pr +diff
+pr +versions
+pr +version-diff
+pr +reviews
+pr +review
+pr +comment
+pr +create
+pr +merge
+pr +refuse
+pr +reopen
+```
+
+Gateway classification:
+
+```text
+read: list, view, files, diff, versions, version-diff, reviews
+low-risk write: comment, review comment, approve, request changes
+medium-risk write: create, reopen
+high-risk write: merge, refuse / close
+```
+
+### Webhook
+
+Available commands:
+
+```text
+webhook +list
+webhook +view
+webhook +create
+webhook +update
+webhook +delete
+webhook +test
+webhook +tasks
+```
+
+Gateway classification:
+
+```text
+read: list, view, tasks
+medium-risk write: test
+high-risk write: create, update, delete
+```
+
+### Workflow
+
+Available commands:
+
+```text
+workflow +triage
+workflow +health
+workflow +pr-summary
+workflow +repo-report
+```
+
+Gateway classification:
+
+```text
+read / local analysis: all workflow commands
+```
+
+These are ideal first-class inputs for Feishu reports.
+
+### CI / Pipeline
+
+Available commands:
+
+```text
+ci +builds
+ci +logs
+ci +restart
+ci +stop
+
+pipeline +list
+pipeline +view
+pipeline +runs
+pipeline +run
+pipeline +logs
+pipeline +results
+pipeline +save-yaml
+pipeline +enable
+pipeline +disable
+pipeline +delete
+```
+
+Gateway classification:
+
+```text
+read: builds, logs, list, view, runs, results, save-yaml
+medium-risk write: restart, run, stop, enable, disable
+high-risk write: delete
+```
+
+### Member
+
+Available commands:
+
+```text
+member +list
+member +add
+member +batch-add
+member +remove
+member +role
+member +invite-link
+member +invite-info
+member +accept-invite
+```
+
+Gateway classification:
+
+```text
+read: list, invite-info
+medium-risk write: accept-invite
+high-risk write: add, batch-add, remove, role, invite-link
+```
+
+## 7. Functional Matrix
+
+| Function | Custom bot | Self-built app | lark-cli | gitlink-cli | Recommendation |
+| --- | --- | --- | --- | --- | --- |
+| Project progress card | yes | yes | possible | data source | Stage 1 custom bot |
+| Weekly report | yes | yes | possible | workflow | Stage 1 custom bot |
+| Owner digest | yes | yes | possible | workflow / PR / Issue | Stage 1 custom bot |
+| Contributor digest | group only | direct message possible | possible | data source | Stage 1 dry-run, Stage 2 app |
+| PR stage cards | yes | yes | possible | PR reader | Stage 1 |
+| Issue risk summary | yes | yes | possible | Issue/workflow | Stage 1 |
+| CI summary | yes | yes | possible | CI/pipeline | Stage 1 read |
+| Link to GitLink | yes | yes | yes | URL source | Stage 1 |
+| DocX / Wiki write | no | yes | yes | content source | Stage 2 |
+| Bitable real sync | no | yes | yes | record source | Stage 2 |
+| Bitable views | no | partial through API/manual | possible | schema source | Stage 2/manual first |
+| Task creation | no | yes | yes | task source | Stage 2 |
+| Card callback | no as standalone | yes | possible | action executor | Stage 3 |
+| Issue comment | no | entry only | bridge possible | issue +comment | Stage 3 low-risk |
+| PR review | no | entry only | bridge possible | pr +review | Stage 3 low-risk |
+| Merge PR | no | entry only | bridge possible | pr +merge | High-risk, disabled by default |
+| Close issue | no | entry only | bridge possible | issue +close | High-risk, disabled by default |
+| Member management | no | entry only | bridge possible | member commands | High-risk, disabled by default |
+
+## 8. Revised Product Stages
+
+### Stage 1: Safe Export and Notification
+
+Goal:
+
+```text
+Make GitLink project state visible in Feishu without Feishu-triggered GitLink writes.
+```
+
+Capabilities:
+
+```text
+custom bot notification
+weekly report
+owner digest
+contributor digest dry-run
+PR stage summary
+Issue risk summary
+CI summary
+Bitable-ready row-level records
+Doc/Wiki-ready markdown preview
+explicit --send for notification
+```
+
+Implementation priority:
+
+```text
+1. owner digest card
+2. contributor digest preview
+3. PR row records
+4. Issue row records
+5. CI row records
+6. milestone records
+7. README / Doc-ready markdown
+```
+
+No:
+
+```text
+no callback server
+no GitLink write actions
+no default real Bitable writes
+no default task creation
+no permission management
+```
+
+### Stage 2: Feishu Open Platform App Integration
+
+Goal:
+
+```text
+Turn Feishu into the project collaboration space while still avoiding GitLink writes from Feishu.
+```
+
+Capabilities:
+
+```text
+app_id / app_secret config
+tenant_access_token cache
+app-check
+scope diagnostics
+chat_id / repo binding
+DocX / Wiki write
+Bitable record sync
+task creation
+IM send through app bot
+permission diagnostics
+lark-cli-check optional
+```
+
+No:
+
+```text
+no PR merge
+no issue close
+no member management
+no branch protection changes
+```
+
+### Stage 3: Callback-Based Low-Risk GitLink Actions
+
+Goal:
+
+```text
+Allow permissioned, audited low-risk GitLink actions from Feishu cards.
+```
+
+Capabilities:
+
+```text
+callback server
+callback signature verification
+action payload parser
+repo binding check
+identity mapping
+GitLink permission check
+dry-run preview
+confirmation
+audit log
+issue comment
+PR review comment
+PR approve
+PR request changes
+create issue
+result writeback to Feishu
+```
+
+No:
+
+```text
+no merge by default
+no close by default
+no delete by default
+no member role change by default
+```
+
+### Stage 4: High-Risk Action Design
+
+Goal:
+
+```text
+Design high-risk actions but keep them disabled unless explicitly enabled.
+```
+
+Actions:
+
+```text
+merge PR
+close issue
+delete branch
+delete release
+add/remove member
+change member role
+protect/unprotect branch
+disable/delete pipeline
+```
+
+Required safeguards:
+
+```text
+--enable-dangerous-actions
+dry-run first
+second confirmation
+maintainer role check
+repo allowlist
+audit log
+rate limit
+rollback guidance where possible
+```
+
+## 9. lark-cli and gitlink-cli Interaction
+
+### Decision
+
+```text
+gitlink-cli should not depend on lark-cli.
+lark-cli should be an optional agent bridge.
+```
+
+Reasons:
+
+```text
+hard dependency increases installation complexity
+hard dependency complicates Go tests
+hard dependency mixes Feishu and GitLink ownership
+security boundary becomes unclear
+lark-cli is broad and fast-moving
+gitlink-cli needs a minimal stable Feishu path
+```
+
+### Recommended Interaction Modes
+
+Mode A: gitlink-cli to Feishu
+
+```text
+gitlink-cli reads GitLink data
+gitlink-cli renders card/report/records
+gitlink-cli sends custom bot card or exports dry-run data
+```
+
+Mode B: Feishu to gitlink-cli
+
+```text
+Feishu callback arrives at action gateway
+gateway validates Feishu identity and repo binding
+gateway maps action to gitlink-cli dry-run
+user confirms
+gateway executes allowed GitLink action
+gateway writes audit log and Feishu result message
+```
+
+Mode C: Agent bridge with both CLIs
+
+```text
+Codex / Claude / OpenClaw loads gitlink-cli skills and lark-cli skills
+gitlink-cli handles GitLink
+lark-cli handles Feishu Docs / Base / Tasks / Calendar / IM
+Agent orchestrates both for one-off or operator-assisted workflows
+```
+
+### Effect of lark-cli Skills on GitLink Organizations
+
+For maintainers:
+
+```text
+less context switching between GitLink, Feishu, and terminal
+Feishu Docs can become project memory
+Bitable can become project cockpit
+meetings can generate GitLink issues or review checklists
+weekly reports can be generated from GitLink data and written to Feishu
+```
+
+For contributors:
+
+```text
+personal pending-action panel
+review feedback pushed into Feishu
+rebase / CI failure reminders
+quick jump back to GitLink PR / Issue
+self-summary of weekly contribution
+```
+
+For AI agents:
+
+```text
+gitlink-cli skills provide GitLink understanding and action execution
+lark-cli skills provide Feishu resource operations
+structured outputs improve cross-platform automation
+dry-run / confirmation policies reduce accidental writes
+```
+
+Main risk:
+
+```text
+An AI Agent with Feishu user authorization and GitLink token can accidentally bridge two permission domains.
+```
+
+Required control:
+
+```text
+least privilege
+explicit --send / --apply
+dry-run first
+identity mapping
+repo binding
+confirmation
+audit log
+high-risk actions disabled
+secret redaction
+```
+
+## 10. Recommended Documents to Add Next
+
+This research report should be followed by focused boundary documents:
+
+```text
+docs/FEISHU_CAPABILITY_BOUNDARY.md
+docs/FEISHU_OPEN_PLATFORM_PLAN.md
+docs/FEISHU_ACTION_GATEWAY_SECURITY.md
+docs/FEISHU_LARK_CLI_INTEROP.md
+```
+
+Detailed API collection for the implemented branch is maintained in:
+
+```text
+docs/FEISHU_OPENAPI_INVENTORY.md
+reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md
+```
+
+Recommended command planning documents:
+
+```text
+gitlink-cli feishu +app-check
+gitlink-cli feishu +binding-list
+gitlink-cli feishu +binding-add
+gitlink-cli feishu +binding-remove
+gitlink-cli feishu +action-preview
+gitlink-cli feishu +serve
+gitlink-cli feishu +audit-log
+gitlink-cli feishu +lark-cli-check
+```
+
+Do not implement all commands immediately. Document the gateway and permission model first.
+
+## 11. Implementation Implications for Current Code
+
+### Keep
+
+```text
+custom bot signer
+custom bot client
+card builders
+workflow JSON reader
+weekly report renderer
+Bitable schema builder
+Bitable records builder
+DocX / Wiki experimental client
+redaction and send-mode validation
+```
+
+### Refactor Later
+
+```text
+current Bitable records are summary-oriented
+new Bitable records should support row-level PR / Issue / CI / milestone records
+card builders need owner digest and contributor digest variants
+Doc export needs clearer permission diagnostics and app-check
+OpenAPI token client needs cache and scope diagnostics
+```
+
+### Add Later
+
+```text
+role-aware data models
+PR stage classifier
+owner digest
+contributor digest
+row-level Bitable records
+README / Doc-ready markdown exporter
+app-check
+binding model
+action gateway preview
+audit log
+```
+
+### Avoid
+
+```text
+claiming stable real Bitable sync before more real-enterprise validation
+claiming stable Feishu task creation before more real-enterprise validation
+claiming card callback support before server and validation exist
+claiming Feishu-triggered GitLink write actions before gateway exists
+putting real secrets, IDs, table IDs, chat IDs, open IDs, or document tokens in repo
+```
+
+## 12. Final Verdict
+
+The project should be repositioned from:
+
+```text
+GitLink workflow export to Feishu
+```
+
+to:
+
+```text
+GitLink project collaboration gateway for Feishu, implemented in staged safety layers.
+```
+
+The immediate engineering plan should still stay conservative:
+
+```text
+Stage 1 = safe visibility.
+Stage 2 = Feishu workspace integration.
+Stage 3 = low-risk action gateway.
+Stage 4 = high-risk action design only.
+```
+
+The most important design insight from Feishu's Bilibili and public content is:
+
+```text
+Feishu's value is not just notification. Its value is turning structured data into role-specific work surfaces.
+```
+
+For GitLink, that means:
+
+```text
+owner cockpit
+contributor task panel
+PR / Issue / CI / milestone Base
+Doc / Wiki project memory
+weekly report archive
+optional permissioned action gateway
+```
+
+This direction is practical, extensible, and matches Feishu's own product narrative while keeping the first implementation safe enough for a mainline PR.
diff --git a/docs/FEISHU_OPENAPI_INVENTORY.md b/docs/FEISHU_OPENAPI_INVENTORY.md
new file mode 100644
index 0000000..9da2a26
--- /dev/null
+++ b/docs/FEISHU_OPENAPI_INVENTORY.md
@@ -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.
+```
diff --git a/docs/FEISHU_PR_ACTIVITY_STRATEGY.md b/docs/FEISHU_PR_ACTIVITY_STRATEGY.md
new file mode 100644
index 0000000..85f9728
--- /dev/null
+++ b/docs/FEISHU_PR_ACTIVITY_STRATEGY.md
@@ -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.
diff --git a/docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md b/docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md
new file mode 100644
index 0000000..af6a6bf
--- /dev/null
+++ b/docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md
@@ -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.
diff --git a/docs/PR_REVIEW_NOTES_DRAFT.md b/docs/PR_REVIEW_NOTES_DRAFT.md
new file mode 100644
index 0000000..7f4dbe5
--- /dev/null
+++ b/docs/PR_REVIEW_NOTES_DRAFT.md
@@ -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?
diff --git a/docs/feishu-bitable-schema.md b/docs/feishu-bitable-schema.md
new file mode 100644
index 0000000..da58060
--- /dev/null
+++ b/docs/feishu-bitable-schema.md
@@ -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`.
+
diff --git a/docs/feishu-integration.md b/docs/feishu-integration.md
new file mode 100644
index 0000000..1460104
--- /dev/null
+++ b/docs/feishu-integration.md
@@ -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
+```
diff --git a/docs/feishu-security.md b/docs/feishu-security.md
new file mode 100644
index 0000000..92fe94f
--- /dev/null
+++ b/docs/feishu-security.md
@@ -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
+```
+
diff --git a/docs/pr-draft.md b/docs/pr-draft.md
index b12ff57..e9fcb54 100644
--- a/docs/pr-draft.md
+++ b/docs/pr-draft.md
@@ -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?
diff --git a/examples/feishu/README.md b/examples/feishu/README.md
new file mode 100644
index 0000000..8059047
--- /dev/null
+++ b/examples/feishu/README.md
@@ -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
+```
diff --git a/feishu-export-design/CODEX_CLEAN_TASK_CHAIN.md b/feishu-export-design/CODEX_CLEAN_TASK_CHAIN.md
new file mode 100644
index 0000000..4dfb058
--- /dev/null
+++ b/feishu-export-design/CODEX_CLEAN_TASK_CHAIN.md
@@ -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.
+```
+
diff --git a/feishu-export-design/EVALUATION.md b/feishu-export-design/EVALUATION.md
new file mode 100644
index 0000000..a6917d1
--- /dev/null
+++ b/feishu-export-design/EVALUATION.md
@@ -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
+```
diff --git a/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md b/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md
new file mode 100644
index 0000000..17504fc
--- /dev/null
+++ b/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md
@@ -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 --send
+4. gitlink-cli feishu +notify --from-workflow-json report.json --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
+--document-id optional later
+--wiki-url optional later
+--wiki-node-token optional later
+--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://.feishu.cn/wiki/
+```
+
+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.
+```
diff --git a/feishu-export-design/ROLE_BASED_COLLABORATION.md b/feishu-export-design/ROLE_BASED_COLLABORATION.md
new file mode 100644
index 0000000..ff8e60c
--- /dev/null
+++ b/feishu-export-design/ROLE_BASED_COLLABORATION.md
@@ -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 \
+ --repo \
+ --period daily \
+ --webhook-url "$FEISHU_WEBHOOK_URL" \
+ --send
+```
+
+Alternative input-only flow:
+
+```bash
+gitlink-cli workflow +repo-report --owner --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 \
+ --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 \
+ --repo \
+ --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 "" \
+ --format markdown
+```
+
+Later remote mode can use GitLink repository file APIs:
+
+```bash
+gitlink-cli feishu +readme-doc \
+ --owner \
+ --repo \
+ --ref master \
+ --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.
diff --git a/feishu-export-design/TECHNICAL_PLAN.md b/feishu-export-design/TECHNICAL_PLAN.md
new file mode 100644
index 0000000..d5f4dd1
--- /dev/null
+++ b/feishu-export-design/TECHNICAL_PLAN.md
@@ -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 --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=
+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.
diff --git a/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md b/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md
new file mode 100644
index 0000000..92098ec
--- /dev/null
+++ b/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md
@@ -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
+```
diff --git a/reports/FEISHU_LOCAL_TESTING_GUIDE.md b/reports/FEISHU_LOCAL_TESTING_GUIDE.md
new file mode 100644
index 0000000..40d3131
--- /dev/null
+++ b/reports/FEISHU_LOCAL_TESTING_GUIDE.md
@@ -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.
diff --git a/reports/FEISHU_PERMISSION_MATRIX.md b/reports/FEISHU_PERMISSION_MATRIX.md
new file mode 100644
index 0000000..5e0c7a8
--- /dev/null
+++ b/reports/FEISHU_PERMISSION_MATRIX.md
@@ -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 |
diff --git a/reports/FEISHU_SMOKE_20260626.md b/reports/FEISHU_SMOKE_20260626.md
new file mode 100644
index 0000000..bc47d3a
--- /dev/null
+++ b/reports/FEISHU_SMOKE_20260626.md
@@ -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.
diff --git a/reports/FEISHU_SMOKE_20260627.md b/reports/FEISHU_SMOKE_20260627.md
new file mode 100644
index 0000000..cd5d2bc
--- /dev/null
+++ b/reports/FEISHU_SMOKE_20260627.md
@@ -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
+```
diff --git a/reports/FEISHU_SMOKE_EVIDENCE_20260627.md b/reports/FEISHU_SMOKE_EVIDENCE_20260627.md
new file mode 100644
index 0000000..2a4070c
--- /dev/null
+++ b/reports/FEISHU_SMOKE_EVIDENCE_20260627.md
@@ -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.
diff --git a/reports/FEISHU_TASK_COMPLETION.md b/reports/FEISHU_TASK_COMPLETION.md
new file mode 100644
index 0000000..449e3b3
--- /dev/null
+++ b/reports/FEISHU_TASK_COMPLETION.md
@@ -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 --send --format table
+```
diff --git a/reports/FEISHU_TEST_ENTERPRISE_SMOKE_20260615.md b/reports/FEISHU_TEST_ENTERPRISE_SMOKE_20260615.md
new file mode 100644
index 0000000..581bf7c
--- /dev/null
+++ b/reports/FEISHU_TEST_ENTERPRISE_SMOKE_20260615.md
@@ -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.
+
diff --git a/reports/FEISHU_USER_COLLECTION_LIST_20260626.md b/reports/FEISHU_USER_COLLECTION_LIST_20260626.md
new file mode 100644
index 0000000..736d839
--- /dev/null
+++ b/reports/FEISHU_USER_COLLECTION_LIST_20260626.md
@@ -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.
+```
diff --git a/scripts/feishu-gitlink-env-check.ps1 b/scripts/feishu-gitlink-env-check.ps1
new file mode 100644
index 0000000..45f94a6
--- /dev/null
+++ b/scripts/feishu-gitlink-env-check.ps1
@@ -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
diff --git a/scripts/feishu-gitlink-setup.ps1 b/scripts/feishu-gitlink-setup.ps1
new file mode 100644
index 0000000..fb331e9
--- /dev/null
+++ b/scripts/feishu-gitlink-setup.ps1
@@ -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))
+}
diff --git a/scripts/feishu-gitlink-smoke.ps1 b/scripts/feishu-gitlink-smoke.ps1
new file mode 100644
index 0000000..48bc361
--- /dev/null
+++ b/scripts/feishu-gitlink-smoke.ps1
@@ -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
+}
diff --git a/shortcuts/feishu/bitable.go b/shortcuts/feishu/bitable.go
new file mode 100644
index 0000000..b93bce8
--- /dev/null
+++ b/shortcuts/feishu/bitable.go
@@ -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
+}
diff --git a/shortcuts/feishu/bitable_sync.go b/shortcuts/feishu/bitable_sync.go
new file mode 100644
index 0000000..cb6cbec
--- /dev/null
+++ b/shortcuts/feishu/bitable_sync.go
@@ -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()
+}
diff --git a/shortcuts/feishu/card.go b/shortcuts/feishu/card.go
new file mode 100644
index 0000000..0cc60b0
--- /dev/null
+++ b/shortcuts/feishu/card.go
@@ -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", " ")
+}
diff --git a/shortcuts/feishu/client.go b/shortcuts/feishu/client.go
new file mode 100644
index 0000000..842833b
--- /dev/null
+++ b/shortcuts/feishu/client.go
@@ -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
+}
diff --git a/shortcuts/feishu/diagnostics.go b/shortcuts/feishu/diagnostics.go
new file mode 100644
index 0000000..541dd3a
--- /dev/null
+++ b/shortcuts/feishu/diagnostics.go
@@ -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), " ")
+}
diff --git a/shortcuts/feishu/digest.go b/shortcuts/feishu/digest.go
new file mode 100644
index 0000000..e11d2ea
--- /dev/null
+++ b/shortcuts/feishu/digest.go
@@ -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
+}
diff --git a/shortcuts/feishu/doc_export.go b/shortcuts/feishu/doc_export.go
new file mode 100644
index 0000000..a3dc826
--- /dev/null
+++ b/shortcuts/feishu/doc_export.go
@@ -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")
+}
diff --git a/shortcuts/feishu/feishu.go b/shortcuts/feishu/feishu.go
new file mode 100644
index 0000000..d5d4512
--- /dev/null
+++ b/shortcuts/feishu/feishu.go
@@ -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
+}
diff --git a/shortcuts/feishu/feishu_test.go b/shortcuts/feishu/feishu_test.go
new file mode 100644
index 0000000..3554fbc
--- /dev/null
+++ b/shortcuts/feishu/feishu_test.go
@@ -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
+}
diff --git a/shortcuts/feishu/l10n.go b/shortcuts/feishu/l10n.go
new file mode 100644
index 0000000..6c6af69
--- /dev/null
+++ b/shortcuts/feishu/l10n.go
@@ -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"},
+}
diff --git a/shortcuts/feishu/openapi.go b/shortcuts/feishu/openapi.go
new file mode 100644
index 0000000..d14491e
--- /dev/null
+++ b/shortcuts/feishu/openapi.go
@@ -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)
+}
diff --git a/shortcuts/feishu/options.go b/shortcuts/feishu/options.go
new file mode 100644
index 0000000..62e7209
--- /dev/null
+++ b/shortcuts/feishu/options.go
@@ -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 ""
+}
diff --git a/shortcuts/feishu/render.go b/shortcuts/feishu/render.go
new file mode 100644
index 0000000..74c1f4a
--- /dev/null
+++ b/shortcuts/feishu/render.go
@@ -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
+}
diff --git a/shortcuts/feishu/sign.go b/shortcuts/feishu/sign.go
new file mode 100644
index 0000000..1af79b1
--- /dev/null
+++ b/shortcuts/feishu/sign.go
@@ -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()
+}
diff --git a/shortcuts/feishu/task.go b/shortcuts/feishu/task.go
new file mode 100644
index 0000000..037d505
--- /dev/null
+++ b/shortcuts/feishu/task.go
@@ -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
+}
diff --git a/shortcuts/feishu/workflow_input.go b/shortcuts/feishu/workflow_input.go
new file mode 100644
index 0000000..5c67320
--- /dev/null
+++ b/shortcuts/feishu/workflow_input.go
@@ -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
+}
diff --git a/shortcuts/register.go b/shortcuts/register.go
index faead30..8855031 100644
--- a/shortcuts/register.go
+++ b/shortcuts/register.go
@@ -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)
}
}
diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go
index b2f9b8d..592c412 100644
--- a/shortcuts/register_test.go
+++ b/shortcuts/register_test.go
@@ -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{}
diff --git a/shortcuts/workflow/api_types.go b/shortcuts/workflow/api_types.go
index d023741..8eb51fa 100644
--- a/shortcuts/workflow/api_types.go
+++ b/shortcuts/workflow/api_types.go
@@ -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",
}
diff --git a/shortcuts/workflow/api_types_test.go b/shortcuts/workflow/api_types_test.go
index 880a17e..7cf5b0f 100644
--- a/shortcuts/workflow/api_types_test.go
+++ b/shortcuts/workflow/api_types_test.go
@@ -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) {
diff --git a/shortcuts/workflow/health_fetch.go b/shortcuts/workflow/health_fetch.go
index f9ae1ea..a9aa6a2 100644
--- a/shortcuts/workflow/health_fetch.go
+++ b/shortcuts/workflow/health_fetch.go
@@ -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{}
diff --git a/shortcuts/workflow/pr_fetch.go b/shortcuts/workflow/pr_fetch.go
index e8a854f..b70e3c9 100644
--- a/shortcuts/workflow/pr_fetch.go
+++ b/shortcuts/workflow/pr_fetch.go
@@ -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 {
diff --git a/shortcuts/workflow/pr_review_audit.go b/shortcuts/workflow/pr_review_audit.go
new file mode 100644
index 0000000..6cbb7b0
--- /dev/null
+++ b/shortcuts/workflow/pr_review_audit.go
@@ -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))
+}
diff --git a/shortcuts/workflow/pr_summary.go b/shortcuts/workflow/pr_summary.go
index 4035610..7705401 100644
--- a/shortcuts/workflow/pr_summary.go
+++ b/shortcuts/workflow/pr_summary.go
@@ -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,
diff --git a/shortcuts/workflow/pr_summary_test.go b/shortcuts/workflow/pr_summary_test.go
index 29983a9..4a8e6f7 100644
--- a/shortcuts/workflow/pr_summary_test.go
+++ b/shortcuts/workflow/pr_summary_test.go
@@ -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) {
diff --git a/shortcuts/workflow/render.go b/shortcuts/workflow/render.go
index b25d3e1..712a8de 100644
--- a/shortcuts/workflow/render.go
+++ b/shortcuts/workflow/render.go
@@ -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
diff --git a/shortcuts/workflow/repo_report.go b/shortcuts/workflow/repo_report.go
index 5cd3b16..f7a7fca 100644
--- a/shortcuts/workflow/repo_report.go
+++ b/shortcuts/workflow/repo_report.go
@@ -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...)
}
diff --git a/shortcuts/workflow/repo_report_fetch.go b/shortcuts/workflow/repo_report_fetch.go
index 30c47de..c7d1a3b 100644
--- a/shortcuts/workflow/repo_report_fetch.go
+++ b/shortcuts/workflow/repo_report_fetch.go
@@ -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
+}
diff --git a/shortcuts/workflow/repo_report_fetch_test.go b/shortcuts/workflow/repo_report_fetch_test.go
index 592a19c..91f1874 100644
--- a/shortcuts/workflow/repo_report_fetch_test.go
+++ b/shortcuts/workflow/repo_report_fetch_test.go
@@ -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 {
diff --git a/shortcuts/workflow/repo_report_test.go b/shortcuts/workflow/repo_report_test.go
index 5b508cd..20400e9 100644
--- a/shortcuts/workflow/repo_report_test.go
+++ b/shortcuts/workflow/repo_report_test.go
@@ -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 {
diff --git a/shortcuts/workflow/triage_fetch.go b/shortcuts/workflow/triage_fetch.go
index 9363a73..af2b47a 100644
--- a/shortcuts/workflow/triage_fetch.go
+++ b/shortcuts/workflow/triage_fetch.go
@@ -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)
}
diff --git a/shortcuts/workflow/triage_fetch_test.go b/shortcuts/workflow/triage_fetch_test.go
index b805025..782ab3c 100644
--- a/shortcuts/workflow/triage_fetch_test.go
+++ b/shortcuts/workflow/triage_fetch_test.go
@@ -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)
diff --git a/skills/gitlink-feishu/SKILL.md b/skills/gitlink-feishu/SKILL.md
new file mode 100644
index 0000000..040e1f5
--- /dev/null
+++ b/skills/gitlink-feishu/SKILL.md
@@ -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 --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 "" --format markdown
+```
+
+Write to Feishu:
+
+```bash
+gitlink-cli feishu +doc-export --from-workflow-json report.json --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.
+