Merge PR #346: # feat(feishu): 新增分层飞书协作导出能力
# Conflicts: # .github/workflows/test.yml # .gitignore # README.zh-CN.md # docs/pr-draft.md # shortcuts/register.go # shortcuts/register_test.go # shortcuts/workflow/api_types.go # shortcuts/workflow/api_types_test.go # shortcuts/workflow/health_fetch.go # shortcuts/workflow/repo_report.go # shortcuts/workflow/repo_report_fetch.go # shortcuts/workflow/triage_fetch.go # shortcuts/workflow/triage_fetch_test.go
This commit is contained in:
commit
63f9f196dd
|
|
@ -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 ./...
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=""
|
||||
62
README.md
62
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
|
||||
|
||||
|
|
|
|||
230
README.zh-CN.md
230
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 @@
|
|||
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/luwanzhou" title="luwanzhou"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/165_135_246/120.png" width="40" height="40" alt="luwanzhou" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/luwanzhou">luwanzhou</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/whale_hihihi" title="whale_hihihi"><img src="https://www.gitlink.org.cn/images/avatars/User/137722?t=1778575729" width="40" height="40" alt="whale_hihihi" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/whale_hihihi">whale_hihihi</a></sub>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 为什么选择 gitlink-cli?
|
||||
|
||||
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/README.md),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
|
||||
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
|
||||
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
|
||||
|
|
@ -96,8 +104,7 @@
|
|||
| 分类 | 能力 |
|
||||
|------|------|
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 |
|
||||
| ⭐ 互动 | 关注、取消关注、点赞、取消点赞,查看关注者和点赞者 |
|
||||
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
||||
| 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue |
|
||||
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
|
||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
||||
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
|
||||
|
|
@ -106,9 +113,11 @@
|
|||
| 🏢 组织 | 管理组织、成员、团队 |
|
||||
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
|
||||
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
|
||||
| 📖 Wiki | 列出、查看、创建、更新、删除、导出 Wiki 页面 |
|
||||
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
|
||||
| 🔍 搜索 | 搜索仓库、用户 |
|
||||
| 📊 数据集 | 按项目查询科研数据集 |
|
||||
| 👤 用户 | 查看用户资料和信息 |
|
||||
| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 |
|
||||
| 📋 项目管理 | Sprint 管理、看板、周报 |
|
||||
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
|
||||
|
||||
|
|
@ -259,24 +268,6 @@ gitlink-cli repo +create -n my-project -d "项目描述"
|
|||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### 仓库互动
|
||||
|
||||
```bash
|
||||
# 列出仓库关注者
|
||||
gitlink-cli reaction +watchers --owner Gitlink --repo forgeplus
|
||||
|
||||
# 列出仓库点赞者
|
||||
gitlink-cli reaction +stargazers --owner Gitlink --repo forgeplus
|
||||
|
||||
# 关注或取消关注仓库
|
||||
gitlink-cli reaction +follow --owner Gitlink --repo forgeplus
|
||||
gitlink-cli reaction +unfollow --owner Gitlink --repo forgeplus
|
||||
|
||||
# 点赞或取消点赞仓库
|
||||
gitlink-cli reaction +like --owner Gitlink --repo forgeplus
|
||||
gitlink-cli reaction +unlike --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Webhook 管理
|
||||
|
||||
```bash
|
||||
|
|
@ -294,6 +285,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
|||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### Wiki 管理
|
||||
|
||||
```bash
|
||||
# 列出 Wiki 页面(目录结构)
|
||||
gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
|
||||
|
||||
# 查看 Wiki 页面
|
||||
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
|
||||
|
||||
# 创建 Wiki 页面
|
||||
gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
|
||||
-n getting-started -t "快速开始" -c "# 快速开始指南"
|
||||
|
||||
# 更新 Wiki 页面标题和/或内容
|
||||
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题"
|
||||
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容"
|
||||
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" -c "新内容"
|
||||
|
||||
# 删除 Wiki 页面
|
||||
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
|
||||
```
|
||||
|
||||
### 成员管理
|
||||
|
||||
```bash
|
||||
|
|
@ -343,6 +356,14 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
|
|||
# 从 CSV 文件批量关闭 Issue
|
||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
|
||||
|
||||
# 按 API issue id 预览批量更新元数据
|
||||
# 注意:--ids 是 API issue id,不是网页 URL 中的 Issue 编号。
|
||||
gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run
|
||||
|
||||
# 危险批量删除必须先 dry-run,真实执行还要显式 --yes
|
||||
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run
|
||||
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes
|
||||
|
||||
# 添加评论
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
|
||||
|
||||
|
|
@ -465,6 +486,16 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow
|
|||
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
|
||||
```
|
||||
|
||||
### 忽略文件模板
|
||||
|
||||
```bash
|
||||
# 列出所有可用的 .gitignore 模板
|
||||
gitlink-cli ignore +list
|
||||
|
||||
# 按名称筛选模板
|
||||
gitlink-cli ignore +list --name Go
|
||||
```
|
||||
|
||||
### 搜索
|
||||
|
||||
```bash
|
||||
|
|
@ -475,31 +506,143 @@ gitlink-cli search +repos -k "machine learning"
|
|||
gitlink-cli search +users -k "zhangsan"
|
||||
```
|
||||
|
||||
### Wiki 管理
|
||||
### 用户画像
|
||||
|
||||
`wiki` 管理仓库的 Wiki 页面。GitLink 项目 ID 会自动从 `--owner/--repo` 解析,也可用 `--project-id` 指定。
|
||||
`profile` 暴露 GitLink 原生的用户画像统计(开发能力、角色定位、专业定位、近期活动、贡献热力图)。
|
||||
省略 `--user` 时默认使用当前认证用户。
|
||||
|
||||
```bash
|
||||
# 列出并查看 Wiki 页面
|
||||
gitlink-cli wiki +list --owner Gitlink --repo gitlink-cli
|
||||
gitlink-cli wiki +view --owner Gitlink --repo gitlink-cli --page Home
|
||||
# 开发能力评分 + 语言分布
|
||||
gitlink-cli profile +ability --user zhangsan
|
||||
|
||||
# 创建页面(内容自动 base64 编码)
|
||||
gitlink-cli wiki +create --owner Gitlink --repo gitlink-cli --page Home --title Home --content "# 欢迎"
|
||||
# 角色定位 / 专业(学科)定位
|
||||
gitlink-cli profile +role --user zhangsan
|
||||
gitlink-cli profile +major --user zhangsan
|
||||
|
||||
# 从文件创建
|
||||
gitlink-cli wiki +create --owner Gitlink --repo gitlink-cli --page Guide --content-file guide.md
|
||||
# 指定时间范围的开发能力(Unix 时间戳)
|
||||
gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600
|
||||
|
||||
# 更新(内容可选),并用 --dry-run 预览
|
||||
gitlink-cli wiki +update --owner Gitlink --repo gitlink-cli --page Home --title "首页" --dry-run
|
||||
# 当前用户的近期活动(每日 疑修 / 合并请求 / 提交)
|
||||
gitlink-cli profile +activity
|
||||
|
||||
# 删除页面
|
||||
gitlink-cli wiki +delete --owner Gitlink --repo gitlink-cli --page Home
|
||||
|
||||
# 导出 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 <uuid> --dry-run
|
||||
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --yes
|
||||
```
|
||||
|
||||
> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404),待平台上线后即可生效。
|
||||
|
||||
### 飞书协作导出
|
||||
|
||||
`feishu` 将 `workflow +repo-report` JSON 转成飞书协作内容。
|
||||
|
||||
`workflow +repo-report` 默认分页读取并分析全部开放 Issue 和 PR。飞书卡片会把这些值明确标为“已分析数量”。只有显式传 `--issue-limit` 或 `--pr-limit` 时才会采样,此时结果不能解释为仓库总量。
|
||||
|
||||
#### 稳定层:自定义机器人通知
|
||||
|
||||
稳定层只依赖飞书群自定义机器人。它适合把 GitLink 项目状态、周报、Owner 摘要和贡献者摘要推送到群里。默认只预览,真实发送必须显式传 `--send`。
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --format json > report.json
|
||||
|
||||
# 仅在明确需要采样时设置上限
|
||||
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --issue-limit 20 --pr-limit 50 --format json > report.sample.json
|
||||
|
||||
gitlink-cli feishu +notify --from-workflow-json report.json --format json
|
||||
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
|
||||
|
||||
gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
|
||||
gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown
|
||||
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown
|
||||
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
|
||||
gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown
|
||||
```
|
||||
|
||||
中文输出建议同时给 workflow 和 feishu 命令传 `--lang zh-CN`:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --lang zh-CN --format json > report.zh-CN.json
|
||||
|
||||
gitlink-cli feishu +notify --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table
|
||||
gitlink-cli feishu +owner-digest --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table
|
||||
gitlink-cli feishu +contributor-digest --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table
|
||||
```
|
||||
|
||||
#### 配置诊断层:先检查再写入
|
||||
|
||||
诊断命令用于降低飞书开放平台配置成本。默认只检查本地变量和目标配置;传 `--remote` 后会调用飞书只读/检查接口,例如获取 `tenant_access_token`、解析 Wiki node、搜索 Bitable sentinel `unique_key`。这些命令不会创建文档、不会写入多维表格、不会创建任务,也不会修改 GitLink。
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +app-check --format table
|
||||
gitlink-cli feishu +doc-check --remote --format table
|
||||
gitlink-cli feishu +bitable-check --tables reports,issues,prs,tasks --remote --format table
|
||||
gitlink-cli feishu +task-check --remote --format table
|
||||
```
|
||||
|
||||
#### 实验层:飞书开放平台写入
|
||||
|
||||
实验层使用飞书开放平台自建应用。当前已在测试企业中验证 DocX 追加、多维表格写入和飞书任务创建,但这部分不是零配置稳定能力。真实写入仍然必须显式传 `--send`,并且要求自建应用有对应 API scope 和目标资源权限。
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "$FEISHU_WIKI_URL" --send --format table
|
||||
gitlink-cli feishu +bitable-sync --from-workflow-json report.json --tables reports,issues,prs,tasks --send --format table
|
||||
gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table
|
||||
```
|
||||
|
||||
为了完成端到端验证,测试企业里的自建应用授予了较宽的权限。正式部署时不建议照搬测试权限,应由维护者或管理员按命令实际需要开最小权限。
|
||||
|
||||
多维表格已完成两类真实验证:
|
||||
|
||||
- 单表多视图验证:把 `reports/issues/prs/contributors/tasks` 写入同一张测试表,证明字段和写入链路可用。
|
||||
- 独立表验证:拆成 `gitlink_reports`、`gitlink_issues`、`gitlink_prs`、`gitlink_contributors`、`gitlink_tasks` 五张表,分别写入 `1/5/2/1/7` 条记录,证明每类记录都能写入独立表。
|
||||
|
||||
#### 当前边界
|
||||
|
||||
本分支不实现 GitLink 写操作。飞书卡片按钮仅用于跳转。开放平台能力必须显式传 `--send`,并要求自建应用具备对应资源权限。是否在正式部署中启用这些实验能力,由 GitLink 维护者和部署管理员决定。
|
||||
|
||||
下一阶段再考虑:
|
||||
|
||||
- 飞书任务项目/分组归属。
|
||||
- 飞书任务执行者/关注人。
|
||||
- 飞书侧任务去重或搜索。
|
||||
- 多维表格自动建 Base、建表、建字段、建视图。
|
||||
- 飞书卡片回调和 GitLink 低风险写动作。
|
||||
|
||||
详细文档:
|
||||
|
||||
- [飞书集成](./docs/feishu-integration.md)
|
||||
- [飞书能力分层](./docs/FEISHU_CAPABILITY_LAYERS.md)
|
||||
- [飞书环境变量](./docs/FEISHU_ENVIRONMENT.md)
|
||||
- [飞书权限矩阵](./reports/FEISHU_PERMISSION_MATRIX.md)
|
||||
|
||||
本地配置和 smoke 测试:
|
||||
|
||||
```powershell
|
||||
.\scripts\feishu-gitlink-setup.ps1
|
||||
.\scripts\feishu-gitlink-env-check.ps1 -Layer stable
|
||||
.\scripts\feishu-gitlink-smoke.ps1 -Mode preview
|
||||
```
|
||||
|
||||
真实值只会写入被忽略的 `.local/feishu-gitlink.env.ps1`,不要提交。
|
||||
### Raw API
|
||||
|
||||
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:
|
||||
|
|
@ -556,14 +699,13 @@ git push gitlink
|
|||
|
||||
`skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
||||
|
||||
详见 [skills/README.md](skills/README.md)
|
||||
详见 [skills/README.md](./skills/README.md)
|
||||
|
||||
| Skill | 说明 |
|
||||
|-------|------|
|
||||
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
|
||||
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork、洞察数据等) |
|
||||
| `gitlink-reaction` | 仓库互动(关注、点赞、关注者、点赞者) |
|
||||
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
|
||||
| `gitlink-issue` | Issue 操作(创建、更新、关闭、批量更新/删除、评论等) |
|
||||
| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) |
|
||||
| `gitlink-member` | 仓库成员与邀请链接管理 |
|
||||
| `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) |
|
||||
|
|
@ -625,7 +767,7 @@ gitlink-cli/
|
|||
|
||||
## 文档
|
||||
|
||||
- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明
|
||||
- [Skills 使用指南](./skills/README.md) — AI Agent Skills 详细说明
|
||||
- [设计文档](doc/design.md) — 架构设计和开发计划
|
||||
|
||||
## 常见问题
|
||||
|
|
@ -688,7 +830,7 @@ gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Creden
|
|||
|
||||
### Q: 如何查看完整的 API 参考?
|
||||
|
||||
查看 [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md)
|
||||
查看 [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
# Feishu Environment Variables
|
||||
|
||||
Date: 2026-06-26
|
||||
|
||||
Do not commit real values. Use a local shell profile, CI secret store, or test terminal session.
|
||||
|
||||
Recommended local workflow:
|
||||
|
||||
```powershell
|
||||
.\scripts\feishu-gitlink-setup.ps1
|
||||
.\scripts\feishu-gitlink-env-check.ps1 -Layer stable
|
||||
.\scripts\feishu-gitlink-smoke.ps1 -Mode preview
|
||||
```
|
||||
|
||||
`feishu-gitlink-setup.ps1` opens the relevant Feishu / GitLink pages, lets the user paste values locally, and writes only to:
|
||||
|
||||
```text
|
||||
.local/feishu-gitlink.env.ps1
|
||||
```
|
||||
|
||||
The real local env file is ignored. The tracked example is:
|
||||
|
||||
```text
|
||||
.local/feishu-gitlink.env.example.ps1
|
||||
```
|
||||
|
||||
## Stable Custom Bot Variables
|
||||
|
||||
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `FEISHU_WEBHOOK_URL` | Feishu custom bot webhook URL | Required for `--send` bot delivery | `+bot-test`, `+notify`, `+weekly-report`, `+owner-digest`, `+contributor-digest` | Yes | Feishu group custom bot settings |
|
||||
| `FEISHU_WEBHOOK_SECRET` | Optional custom bot signing secret | Optional | same as above | Yes | Feishu group custom bot security settings |
|
||||
|
||||
Example:
|
||||
|
||||
```powershell
|
||||
$env:FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/REDACTED"
|
||||
$env:FEISHU_WEBHOOK_SECRET="REDACTED"
|
||||
```
|
||||
|
||||
## Open Platform App Variables
|
||||
|
||||
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `FEISHU_APP_ID` | Self-built app ID | Required for Open Platform `--send` or diagnostic `--remote` | `+app-check`, `+doc-check`, `+bitable-check`, `+task-check`, `+doc-export`, `+bitable-sync`, `+task-create` | Yes | Feishu Open Platform app page |
|
||||
| `FEISHU_APP_SECRET` | Self-built app secret | Required for Open Platform `--send` or diagnostic `--remote` | same as above | Yes | Feishu Open Platform app credentials |
|
||||
|
||||
Example:
|
||||
|
||||
```powershell
|
||||
$env:FEISHU_APP_ID="cli_REDACTED"
|
||||
$env:FEISHU_APP_SECRET="REDACTED"
|
||||
```
|
||||
|
||||
## DocX / Wiki Variables
|
||||
|
||||
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `FEISHU_WIKI_URL` | Existing Wiki page URL | Optional target | `+doc-check`, `+doc-export` | Can expose workspace/resource ID | Copy from Feishu Wiki |
|
||||
| `FEISHU_WIKI_NODE_TOKEN` | Existing Wiki node token | Optional target | `+doc-check`, `+doc-export` | Yes | Parsed from Wiki URL or API |
|
||||
| `FEISHU_FOLDER_TOKEN` | Folder token for creating a new DocX | Optional target | `+doc-check`, `+doc-export` | Yes | Feishu Drive folder URL / Open Platform docs |
|
||||
| `FEISHU_DOCUMENT_ID` | Existing DocX document ID for append | Optional target | `+doc-check`, `+doc-export` | Yes | Existing Feishu DocX URL or Open Platform docs |
|
||||
|
||||
Legacy compatibility:
|
||||
|
||||
```text
|
||||
FEISHU_DOC_FOLDER_TOKEN is still accepted after FEISHU_FOLDER_TOKEN.
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```powershell
|
||||
$env:FEISHU_WIKI_URL="https://example.feishu.cn/wiki/REDACTED"
|
||||
$env:FEISHU_FOLDER_TOKEN="REDACTED"
|
||||
$env:FEISHU_DOCUMENT_ID="REDACTED"
|
||||
```
|
||||
|
||||
For localized output, generate the source workflow report and the Feishu output
|
||||
with the same language flag:
|
||||
|
||||
```powershell
|
||||
go run . workflow +repo-report --owner "$env:GITLINK_OWNER" --repo "$env:GITLINK_REPO" --lang zh-CN --format json > .local\report.zh-CN.json
|
||||
go run . feishu +notify --from-workflow-json .local\report.zh-CN.json --lang zh-CN --format table
|
||||
```
|
||||
|
||||
## Base / Bitable Variables
|
||||
|
||||
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `FEISHU_BASE_APP_TOKEN` | Base app token | Required for `+bitable-check` and `+bitable-sync --send` | `+bitable-check`, `+bitable-sync` | Yes | Feishu Base URL / Open Platform docs |
|
||||
| `FEISHU_REPORT_TABLE_ID` | Reports table ID | Required when checking or syncing `reports` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
|
||||
| `FEISHU_ISSUE_TABLE_ID` | Issues table ID | Required when checking or syncing `issues` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
|
||||
| `FEISHU_PR_TABLE_ID` | Pull request table ID | Required when checking or syncing `prs` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
|
||||
| `FEISHU_CONTRIBUTOR_TABLE_ID` | Contributors table ID | Optional unless selected | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
|
||||
| `FEISHU_TASK_TABLE_ID` | Task-candidate table ID | Optional unless selected | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
|
||||
|
||||
Example:
|
||||
|
||||
```powershell
|
||||
$env:FEISHU_BASE_APP_TOKEN="REDACTED"
|
||||
$env:FEISHU_REPORT_TABLE_ID="REDACTED"
|
||||
$env:FEISHU_ISSUE_TABLE_ID="REDACTED"
|
||||
$env:FEISHU_PR_TABLE_ID="REDACTED"
|
||||
$env:FEISHU_CONTRIBUTOR_TABLE_ID="REDACTED"
|
||||
$env:FEISHU_TASK_TABLE_ID="REDACTED"
|
||||
```
|
||||
|
||||
## Feishu Task Variables
|
||||
|
||||
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `FEISHU_TASK_PROJECT_ID` | Optional task project target | Optional | `+task-check`, `+task-create` | Yes | Feishu Task project settings / API |
|
||||
| `FEISHU_TASK_SECTION_ID` | Optional task section target | Optional | `+task-check`, `+task-create` | Yes | Feishu Task section settings / API |
|
||||
|
||||
Current limitation:
|
||||
|
||||
```text
|
||||
The experimental task create command creates task candidates through the Task API.
|
||||
Task project and section IDs are currently collected and redacted in output,
|
||||
but they are not yet mapped into the create-task request body.
|
||||
Project/section placement should be wired only after the official request fields
|
||||
and test-enterprise behavior are confirmed.
|
||||
```
|
||||
|
||||
## GitLink Test Variables
|
||||
|
||||
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `GITLINK_OWNER` | Test repository owner | Optional for local smoke | workflow report generation | No | GitLink repository URL |
|
||||
| `GITLINK_REPO` | Test repository name | Optional for local smoke | workflow report generation | No | GitLink repository URL |
|
||||
| `GITLINK_TEST_PR_IDS` | Comma-separated PR IDs for smoke reference | Optional | smoke report only unless workflow supports filtering | No | GitLink PR URLs |
|
||||
| `GITLINK_TOKEN` | GitLink API token | Optional if already logged in | workflow read operations | Yes | GitLink account settings |
|
||||
|
||||
Example:
|
||||
|
||||
```powershell
|
||||
$env:GITLINK_OWNER="OWNER"
|
||||
$env:GITLINK_REPO="REPO"
|
||||
$env:GITLINK_TEST_PR_IDS="1,2,3"
|
||||
$env:GITLINK_TOKEN="REDACTED"
|
||||
```
|
||||
|
||||
## Safety Warnings
|
||||
|
||||
```text
|
||||
Never paste real secrets into committed docs.
|
||||
Never paste real secrets into ChatGPT.
|
||||
Never print raw webhook URLs or app secrets in smoke reports.
|
||||
Do not commit tenant_access_token or user_access_token.
|
||||
Do not enable --send in shared scripts unless the target test enterprise is intentional.
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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?
|
||||
|
|
@ -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`.
|
||||
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
305
docs/pr-draft.md
305
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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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.
|
||||
```
|
||||
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
# Feishu Official Docs Alignment
|
||||
|
||||
## Sources Checked
|
||||
|
||||
- Custom bot usage guide: https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
|
||||
- Send message cards with custom bot: https://open.feishu.cn/document/feishu-cards/quick-start/send-message-cards-with-custom-bot?lang=zh-CN
|
||||
- Custom app tenant access token: https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN
|
||||
- Send message API: https://open.feishu.cn/document/server-docs/im-v1/message/create?lang=zh-CN
|
||||
- Create DocX document: https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/create
|
||||
- Create DocX blocks: https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document-block/create?lang=zh-CN
|
||||
- Bitable create record: https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/create?lang=zh-CN
|
||||
- Bitable batch create records: https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/batch_create?lang=zh-CN
|
||||
- Docs token FAQ: https://open.feishu.cn/document/faq/trouble-shooting/how-to-get-docs-tokens
|
||||
- Docs permission FAQ: https://open.feishu.cn/document/server-docs/docs/faq?lang=zh-CN
|
||||
|
||||
## Important Product Boundary
|
||||
|
||||
The BotBuilder shutdown notice does not affect this design if the implementation uses:
|
||||
|
||||
```text
|
||||
Feishu Open Platform custom bot webhooks
|
||||
Feishu Open Platform custom app APIs
|
||||
Feishu Docs / Bitable OpenAPI
|
||||
```
|
||||
|
||||
Do not integrate:
|
||||
|
||||
```text
|
||||
botbuilder.feishu.cn
|
||||
Feishu Robot Assistant workflows
|
||||
```
|
||||
|
||||
## Correct Integration Modes
|
||||
|
||||
### Mode A: Custom Group Bot Webhook
|
||||
|
||||
Use this for the first working proof.
|
||||
|
||||
Required inputs:
|
||||
|
||||
```text
|
||||
FEISHU_WEBHOOK_URL
|
||||
FEISHU_WEBHOOK_SECRET optional
|
||||
```
|
||||
|
||||
Capabilities:
|
||||
|
||||
```text
|
||||
Send one-way group notifications.
|
||||
Send interactive card JSON to a group.
|
||||
No tenant token.
|
||||
No app_id/app_secret.
|
||||
No user, tenant, document, or Bitable data access.
|
||||
```
|
||||
|
||||
Fit in this project:
|
||||
|
||||
```text
|
||||
feishu +bot-test
|
||||
feishu +notify
|
||||
feishu +weekly-report --send
|
||||
```
|
||||
|
||||
### Mode B: Open Platform Custom App
|
||||
|
||||
Use this for real document and Bitable operations.
|
||||
|
||||
Required inputs:
|
||||
|
||||
```text
|
||||
FEISHU_APP_ID
|
||||
FEISHU_APP_SECRET
|
||||
```
|
||||
|
||||
Token flow:
|
||||
|
||||
```text
|
||||
POST /open-apis/auth/v3/tenant_access_token/internal
|
||||
request: app_id + app_secret
|
||||
response: tenant_access_token, expire
|
||||
```
|
||||
|
||||
Required implementation:
|
||||
|
||||
```text
|
||||
Token client
|
||||
token cache with expiry
|
||||
redacted errors
|
||||
permission diagnostics
|
||||
mocked HTTP tests
|
||||
```
|
||||
|
||||
Fit in this project:
|
||||
|
||||
```text
|
||||
Phase 2: feishu +doc-export
|
||||
Phase 3: feishu +bitable-sync or +bitable-upsert
|
||||
Optional: app bot message send through im/v1/messages
|
||||
```
|
||||
|
||||
### Mode C: Low-Code Alternatives
|
||||
|
||||
Multidimensional table workflows, Aily, and AnyCross are valid migration choices for BotBuilder users, but they are not a good first implementation target inside `gitlink-cli`.
|
||||
|
||||
Use them as documentation references only.
|
||||
|
||||
## Recommended Product Flow
|
||||
|
||||
The practical GitLink-to-Feishu workflow should be:
|
||||
|
||||
```text
|
||||
1. gitlink-cli workflow +repo-report --format json > report.json
|
||||
2. gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
|
||||
3. gitlink-cli feishu +doc-export --from-workflow-json report.json --folder-token <folder_token> --send
|
||||
4. gitlink-cli feishu +notify --from-workflow-json report.json --doc-url <doc_url> --send
|
||||
5. gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
|
||||
6. Later: gitlink-cli feishu +bitable-sync --from-workflow-json report.json --send
|
||||
```
|
||||
|
||||
Key point:
|
||||
|
||||
```text
|
||||
Card = notification.
|
||||
Doc = collaboration artifact.
|
||||
Bitable = structured tracking data.
|
||||
```
|
||||
|
||||
The earlier design covered card and Bitable dry-run, but missed the document artifact.
|
||||
|
||||
## Doc Export Requirements
|
||||
|
||||
Add a later `feishu +doc-export` command.
|
||||
|
||||
Inputs:
|
||||
|
||||
```text
|
||||
--from-workflow-json report.json
|
||||
--folder-token <folder_token>
|
||||
--document-id <document_id> optional later
|
||||
--wiki-url <wiki_url> optional later
|
||||
--wiki-node-token <node_token> optional later
|
||||
--title <title>
|
||||
--send
|
||||
```
|
||||
|
||||
Environment:
|
||||
|
||||
```text
|
||||
FEISHU_APP_ID
|
||||
FEISHU_APP_SECRET
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
```text
|
||||
Default preview only.
|
||||
--send creates or updates a Feishu DocX document.
|
||||
Create document first.
|
||||
Then create blocks under the document root block.
|
||||
Return document_id and URL.
|
||||
No document operation without --send.
|
||||
```
|
||||
|
||||
Permission notes:
|
||||
|
||||
```text
|
||||
The app must have required DocX/Drive application scopes.
|
||||
The target folder or document must grant the app document permission.
|
||||
folder_token/document_id/app_token must be read from URL or OpenAPI.
|
||||
```
|
||||
|
||||
## Knowledge Base / Wiki Fit
|
||||
|
||||
Knowledge Base pages are useful for project showcase and reference material.
|
||||
|
||||
The supplied project page shape:
|
||||
|
||||
```text
|
||||
https://<tenant>.feishu.cn/wiki/<node_token>
|
||||
```
|
||||
|
||||
Official API flow:
|
||||
|
||||
```text
|
||||
1. Get tenant_access_token with app_id/app_secret.
|
||||
2. Resolve wiki node token with Wiki API.
|
||||
3. If obj_type is docx, use obj_token as the DocX document target.
|
||||
4. Export or append report blocks with DocX block APIs.
|
||||
5. Send a Feishu bot card with the wiki/doc URL as the collaboration entry.
|
||||
```
|
||||
|
||||
Design impact:
|
||||
|
||||
```text
|
||||
Add wiki-url/wiki-node-token support to doc-export.
|
||||
Add --doc-url to notify/weekly-report card commands.
|
||||
Keep Wiki operations behind --send.
|
||||
Do not edit knowledge base permissions automatically.
|
||||
```
|
||||
|
||||
This makes the project output more suitable for display:
|
||||
|
||||
```text
|
||||
Knowledge Base page = project homepage / reference index.
|
||||
DocX report blocks = generated workflow report.
|
||||
Bot card = notification and entry link.
|
||||
Bitable records = structured data for later dashboards.
|
||||
```
|
||||
|
||||
Observed permission behavior:
|
||||
|
||||
```text
|
||||
tenant_access_token acquisition succeeded.
|
||||
Wiki get_node succeeded.
|
||||
DocX create-block failed with HTTP 403 / code 1770032 / forBidden.
|
||||
```
|
||||
|
||||
This means the design must include explicit permission diagnostics:
|
||||
|
||||
```text
|
||||
The self-built app must have both approved DocX/Drive scopes and write access to the target Wiki/DocX page or folder.
|
||||
```
|
||||
|
||||
## Bitable Real Write Requirements
|
||||
|
||||
Keep current `+bitable-schema` and `+bitable-records` as dry-run commands.
|
||||
|
||||
Only add real write after the app auth layer exists.
|
||||
|
||||
Required inputs:
|
||||
|
||||
```text
|
||||
FEISHU_APP_ID
|
||||
FEISHU_APP_SECRET
|
||||
FEISHU_BASE_APP_TOKEN
|
||||
FEISHU_REPORT_TABLE_ID
|
||||
FEISHU_ISSUE_TABLE_ID
|
||||
FEISHU_PR_TABLE_ID
|
||||
FEISHU_CONTRIBUTOR_TABLE_ID optional
|
||||
```
|
||||
|
||||
Required behavior:
|
||||
|
||||
```text
|
||||
Fetch tenant_access_token.
|
||||
Validate table IDs.
|
||||
Create records or batch create records.
|
||||
For update/upsert, search existing records first.
|
||||
Do not call Bitable OpenAPI unless --send is explicit.
|
||||
```
|
||||
|
||||
## Design Verdict
|
||||
|
||||
Current design is reasonable as a first safe MVP:
|
||||
|
||||
```text
|
||||
custom bot send
|
||||
workflow JSON local input
|
||||
weekly report markdown
|
||||
Bitable schema/records dry-run
|
||||
mock tests
|
||||
```
|
||||
|
||||
But it is incomplete for a "Feishu collaboration export" feature because it does not create or update Feishu Docs.
|
||||
|
||||
Required design adjustment:
|
||||
|
||||
```text
|
||||
Keep custom bot as the low-friction stable smoke test path.
|
||||
Keep Bitable as dry-run in the stable path.
|
||||
Treat doc-export with DocX/Wiki support as experimental because it uses self-built app OpenAPI and document write permissions.
|
||||
Keep real Bitable writes out of scope.
|
||||
```
|
||||
|
|
@ -0,0 +1,899 @@
|
|||
# Role-Based Feishu Collaboration Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Extend the Feishu export workflow from a simple report sender into a role-aware collaboration layer.
|
||||
|
||||
The product should not notify every maintainer about every Pull Request event. It should separate the two communication needs:
|
||||
|
||||
```text
|
||||
Owner / maintainer: periodic, summarized, prioritized project state.
|
||||
Contributor: immediate, personal feedback for work they own.
|
||||
```
|
||||
|
||||
This keeps Feishu useful as a collaboration surface instead of turning it into a noisy event stream.
|
||||
|
||||
## Role Model
|
||||
|
||||
### Owner View
|
||||
|
||||
Owners need batch summaries and decision support.
|
||||
|
||||
Default owner delivery:
|
||||
|
||||
```text
|
||||
daily digest
|
||||
weekly report
|
||||
milestone status
|
||||
review queue summary
|
||||
high-risk PR summary
|
||||
stale contribution summary
|
||||
```
|
||||
|
||||
Owners should receive:
|
||||
|
||||
```text
|
||||
repository status
|
||||
new contributor activity
|
||||
PRs grouped by review stage
|
||||
PRs blocked by conflicts or required rebase
|
||||
PRs close to merge
|
||||
PRs needing owner decision
|
||||
review coverage and stale review data
|
||||
links to Feishu Doc / Wiki pages for full context
|
||||
```
|
||||
|
||||
Owners should not receive by default:
|
||||
|
||||
```text
|
||||
one message for every new PR
|
||||
one message for every comment
|
||||
one message for every patchset push
|
||||
one message for every review reply
|
||||
```
|
||||
|
||||
### Contributor View
|
||||
|
||||
Contributors need immediate feedback on their own work.
|
||||
|
||||
Default contributor delivery:
|
||||
|
||||
```text
|
||||
review comment received
|
||||
review status changed
|
||||
changes requested
|
||||
rebase required
|
||||
merge conflict detected
|
||||
CI or quality gate failed
|
||||
PR approved
|
||||
PR merged
|
||||
PR refused or closed
|
||||
maintainer requested more information
|
||||
```
|
||||
|
||||
Contributor notifications should be personal and direct where Feishu identity mapping is available. If open_id mapping is not configured, the system should fall back to repository-level cards or dry-run output.
|
||||
|
||||
## Event Strategy
|
||||
|
||||
### Owner Events
|
||||
|
||||
Owner notifications are aggregation jobs, not raw events.
|
||||
|
||||
Recommended command shape:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +owner-digest \
|
||||
--owner <owner> \
|
||||
--repo <repo> \
|
||||
--period daily \
|
||||
--webhook-url "$FEISHU_WEBHOOK_URL" \
|
||||
--send
|
||||
```
|
||||
|
||||
Alternative input-only flow:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json > report.json
|
||||
gitlink-cli feishu +owner-digest --from-workflow-json report.json --send
|
||||
```
|
||||
|
||||
The first implementation should prefer the input-only flow. Direct GitLink collection can come later once the data model is stable.
|
||||
|
||||
### Contributor Events
|
||||
|
||||
Contributor notifications can be real-time if GitLink has webhooks, or near-real-time through polling.
|
||||
|
||||
Recommended command shape:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +contributor-notify \
|
||||
--from-event-json event.json \
|
||||
--send
|
||||
```
|
||||
|
||||
Polling shape:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +contributor-watch \
|
||||
--owner <owner> \
|
||||
--repo <repo> \
|
||||
--interval 5m \
|
||||
--state-file .gitlink-feishu-state.json \
|
||||
--send
|
||||
```
|
||||
|
||||
The state file records delivered event IDs so repeated polling does not resend old notifications.
|
||||
|
||||
## Feishu Channel Matrix
|
||||
|
||||
Different Feishu surfaces should not be used for the same job.
|
||||
|
||||
| Surface | Best Fit | Delivery Style | Current Status |
|
||||
| --- | --- | --- | --- |
|
||||
| Custom group bot webhook | Owner digest, weekly report, project status card | One-way group card | Implemented for current notify/report path |
|
||||
| Self-built app IM API | Personal contributor notifications | Direct message or mention | Future, needs open_id mapping and IM scopes |
|
||||
| DocX / Wiki | Long-form report, README mirror, project knowledge base | Persistent document | Experimental `+doc-export` exists |
|
||||
| Bitable records | PR stage table, milestones, dashboard source | Structured rows | Dry-run records only |
|
||||
| Bitable Gantt view | Milestone timeline | Visual project planning | Manual view first, OpenAPI later |
|
||||
| Feishu AI summary / weekly report | Summarize generated docs and records | Feishu-side automation | Do not depend on private API |
|
||||
|
||||
Practical rule:
|
||||
|
||||
```text
|
||||
Card = attention.
|
||||
Doc / Wiki = context.
|
||||
Bitable = structured state.
|
||||
Gantt = milestone visualization.
|
||||
AI summary = Feishu-side value added on top of structured content.
|
||||
```
|
||||
|
||||
## Scheduling Model
|
||||
|
||||
Owner notifications should be scheduled. Contributor notifications should be event-driven where possible.
|
||||
|
||||
Owner cadence:
|
||||
|
||||
```text
|
||||
daily: review queue and blocked items
|
||||
weekly: contributor activity, milestone progress, risk trend
|
||||
on demand: full report preview or manual send
|
||||
```
|
||||
|
||||
Contributor cadence:
|
||||
|
||||
```text
|
||||
immediate: review/comment/merge/rebase/conflict events
|
||||
debounced: repeated comments in the same PR within a short window
|
||||
digest fallback: if personal identity mapping is missing
|
||||
```
|
||||
|
||||
Recommended debounce rule:
|
||||
|
||||
```text
|
||||
If the same contributor receives multiple comments on the same PR within 10 minutes,
|
||||
combine them into one notification with a count and latest link.
|
||||
```
|
||||
|
||||
This avoids replacing owner spam with contributor spam.
|
||||
|
||||
## Pull Request Stage Colors
|
||||
|
||||
Feishu cards should use color as a stage signal, not as decoration.
|
||||
|
||||
Default stage rules:
|
||||
|
||||
| Stage | Card Color | Meaning | Typical Inputs |
|
||||
| --- | --- | --- | --- |
|
||||
| `new` | blue | New PR, not reviewed yet | no reviews, one patchset, recently opened |
|
||||
| `active-review` | grey | Review is active, no clear risk yet | comments or common reviews exist |
|
||||
| `near-ready` | green | Small gap to merge | approved or low-risk review, no conflict, checks pass |
|
||||
| `needs-rebase` | yellow | Contributor action needed before review can continue | base branch changed, conflict, stale branch, merge check failed |
|
||||
| `major-changes` | orange | Larger change request or high-risk delta | rejected review, large diff, missing tests, repeated review cycles |
|
||||
| `blocked` | red | Owner or platform action needed | permission issue, failing required checks, unresolved dependency |
|
||||
| `merged` | green | Completed | merged status |
|
||||
| `closed` | grey | Closed without merge | closed/refused status |
|
||||
|
||||
The first stable implementation should support defaults only. User customization can be added as a config file after the stage model is proven.
|
||||
|
||||
Recommended config shape:
|
||||
|
||||
```yaml
|
||||
feishu:
|
||||
pr_stages:
|
||||
near_ready:
|
||||
color: green
|
||||
max_unresolved_comments: 2
|
||||
require_no_conflicts: true
|
||||
needs_rebase:
|
||||
color: yellow
|
||||
require_mergeable: false
|
||||
major_changes:
|
||||
color: orange
|
||||
min_review_rounds: 2
|
||||
high_risk_labels:
|
||||
- missing-tests
|
||||
- large-diff
|
||||
```
|
||||
|
||||
## Review Degree Model
|
||||
|
||||
The stage calculation should be explainable. A card should not only show a color; it should also show why the PR is in that stage.
|
||||
|
||||
Recommended derived fields:
|
||||
|
||||
```text
|
||||
review_rounds
|
||||
patchset_count
|
||||
last_review_status
|
||||
unresolved_comment_count
|
||||
requested_changes_count
|
||||
approved_count
|
||||
changed_files_count
|
||||
additions
|
||||
deletions
|
||||
mergeable
|
||||
needs_rebase
|
||||
ci_status
|
||||
last_activity_at
|
||||
```
|
||||
|
||||
GitLink anchors already available in the CLI ecosystem:
|
||||
|
||||
```text
|
||||
pr +list
|
||||
pr +view
|
||||
pr +reviews
|
||||
pr +versions
|
||||
pr +files
|
||||
```
|
||||
|
||||
The design should avoid scraping pages. It should use existing GitLink APIs or existing CLI JSON outputs.
|
||||
|
||||
## Stage Classification Order
|
||||
|
||||
The stage classifier should be deterministic. Later rules should not override higher-priority terminal or blocking states.
|
||||
|
||||
Recommended order:
|
||||
|
||||
```text
|
||||
1. merged
|
||||
2. closed
|
||||
3. blocked
|
||||
4. needs-rebase
|
||||
5. major-changes
|
||||
6. near-ready
|
||||
7. active-review
|
||||
8. new
|
||||
```
|
||||
|
||||
Classification logic:
|
||||
|
||||
```text
|
||||
merged:
|
||||
pull_request_status == merged
|
||||
|
||||
closed:
|
||||
pull_request_status == closed/refused
|
||||
|
||||
blocked:
|
||||
required check failed, permission issue, unresolved dependency, or owner-defined blocked label
|
||||
|
||||
needs-rebase:
|
||||
mergeable == false, conflict exists, stale base branch, or merge check says rebase is required
|
||||
|
||||
major-changes:
|
||||
last review rejected, requested_changes_count > 0, large diff threshold exceeded, or repeated review rounds
|
||||
|
||||
near-ready:
|
||||
approved_count > 0, no conflict, no requested changes, low remaining risk
|
||||
|
||||
active-review:
|
||||
common review/comment exists, patchset_count > 1, or maintainer has interacted
|
||||
|
||||
new:
|
||||
no review, no maintainer interaction, recently opened
|
||||
```
|
||||
|
||||
Every stage output should include `reasons`:
|
||||
|
||||
```json
|
||||
{
|
||||
"stage": "needs-rebase",
|
||||
"color": "yellow",
|
||||
"reasons": [
|
||||
"merge check failed",
|
||||
"base branch changed after latest patchset"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Data Contracts
|
||||
|
||||
The role-aware extension should accept local JSON first. This keeps tests deterministic and avoids changing existing GitLink network behavior.
|
||||
|
||||
### Owner Digest Input
|
||||
|
||||
Recommended minimal JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"repository": "Gitlink/gitlink-cli",
|
||||
"period": {
|
||||
"start": "2026-06-09",
|
||||
"end": "2026-06-16"
|
||||
},
|
||||
"pull_requests": [
|
||||
{
|
||||
"number": 123,
|
||||
"title": "feat: add export flow",
|
||||
"author": "contributor-a",
|
||||
"url": "https://www.gitlink.org.cn/org/repo/pulls/123",
|
||||
"status": "open",
|
||||
"stage": "needs-rebase",
|
||||
"color": "yellow",
|
||||
"reasons": ["merge check failed"],
|
||||
"review_rounds": 2,
|
||||
"patchset_count": 3,
|
||||
"last_activity_at": "2026-06-16T10:30:00+08:00"
|
||||
}
|
||||
],
|
||||
"milestones": [],
|
||||
"contributors": []
|
||||
}
|
||||
```
|
||||
|
||||
### Owner Digest Output
|
||||
|
||||
Recommended output:
|
||||
|
||||
```json
|
||||
{
|
||||
"repository": "Gitlink/gitlink-cli",
|
||||
"period_label": "2026-06-09 to 2026-06-16",
|
||||
"stage_counts": {
|
||||
"near-ready": 3,
|
||||
"needs-rebase": 2,
|
||||
"major-changes": 1,
|
||||
"new": 4
|
||||
},
|
||||
"top_actions": [
|
||||
"Review 4 new PRs",
|
||||
"Ask 2 contributors to rebase",
|
||||
"Merge 3 near-ready PRs"
|
||||
],
|
||||
"doc_url": "https://example.feishu.cn/wiki/...",
|
||||
"dry_run": true
|
||||
}
|
||||
```
|
||||
|
||||
### Contributor Event Input
|
||||
|
||||
Recommended minimal event JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "repo-pr-123-review-456",
|
||||
"event_type": "review_comment",
|
||||
"repository": "Gitlink/gitlink-cli",
|
||||
"pr": {
|
||||
"number": 123,
|
||||
"title": "feat: add export flow",
|
||||
"url": "https://www.gitlink.org.cn/org/repo/pulls/123",
|
||||
"author": "contributor-a"
|
||||
},
|
||||
"actor": "maintainer-a",
|
||||
"recipient_gitlink_user": "contributor-a",
|
||||
"summary": "Maintainer requested changes in the export options.",
|
||||
"required_action": "Update the PR and push a new patchset.",
|
||||
"created_at": "2026-06-16T10:30:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
### Contributor Notification Output
|
||||
|
||||
Recommended output:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "repo-pr-123-review-456",
|
||||
"recipient_gitlink_user": "contributor-a",
|
||||
"recipient_feishu_id": "",
|
||||
"delivery_mode": "dry-run",
|
||||
"card_title": "PR feedback received",
|
||||
"required_action": "Update the PR and push a new patchset.",
|
||||
"dry_run": true
|
||||
}
|
||||
```
|
||||
|
||||
If `recipient_feishu_id` is empty, direct personal delivery must not be attempted.
|
||||
|
||||
## Owner Digest Card
|
||||
|
||||
Owner digest cards should be compact and action-oriented.
|
||||
|
||||
Recommended sections:
|
||||
|
||||
```text
|
||||
1. Repository and period
|
||||
2. Review queue by stage
|
||||
3. Near-ready PRs
|
||||
4. PRs needing rebase
|
||||
5. High-risk or major-change PRs
|
||||
6. New contributors
|
||||
7. Stale PRs
|
||||
8. Milestone progress
|
||||
9. Link to Feishu Wiki / Doc full report
|
||||
```
|
||||
|
||||
Example card semantics:
|
||||
|
||||
```text
|
||||
Header: GitLink Owner Digest
|
||||
Green section: 3 PRs close to merge
|
||||
Yellow section: 2 PRs need rebase
|
||||
Orange section: 1 PR needs major changes
|
||||
Grey section: 4 new/unreviewed PRs
|
||||
Button: Open Feishu report
|
||||
Button: Open GitLink PR queue
|
||||
```
|
||||
|
||||
The owner card should cap inline PR rows. A full report belongs in Feishu Doc / Wiki.
|
||||
|
||||
Recommended card limits:
|
||||
|
||||
```text
|
||||
maximum stage groups shown: 5
|
||||
maximum PR rows per stage: 3
|
||||
maximum total inline PR rows: 10
|
||||
always include full report link when available
|
||||
```
|
||||
|
||||
If the owner digest exceeds the inline limits, the card should say how many rows are hidden and link to the Doc / Wiki report.
|
||||
|
||||
## Contributor Notification Card
|
||||
|
||||
Contributor cards should be immediate and specific.
|
||||
|
||||
Recommended sections:
|
||||
|
||||
```text
|
||||
1. PR title and repository
|
||||
2. Event type
|
||||
3. Reviewer or actor
|
||||
4. Required action
|
||||
5. Short feedback summary
|
||||
6. Link to PR
|
||||
7. Link to Feishu reference doc if relevant
|
||||
```
|
||||
|
||||
Example event mapping:
|
||||
|
||||
| Event | Card Intent |
|
||||
| --- | --- |
|
||||
| review comment | Read maintainer feedback |
|
||||
| rejected review | Modify PR according to requested changes |
|
||||
| approved review | Wait for merge or owner decision |
|
||||
| merged | Contribution accepted |
|
||||
| needs rebase | Rebase branch before further review |
|
||||
| conflict | Resolve merge conflict |
|
||||
|
||||
Contributor delivery requires identity mapping:
|
||||
|
||||
```text
|
||||
GitLink username -> Feishu open_id / union_id / email
|
||||
```
|
||||
|
||||
Until that mapping exists, the CLI should generate dry-run records instead of attempting direct personal delivery.
|
||||
|
||||
## Identity Mapping
|
||||
|
||||
Identity mapping is a separate concern from PR analysis.
|
||||
|
||||
Supported mapping sources, in priority order:
|
||||
|
||||
```text
|
||||
1. explicit local mapping file
|
||||
2. Bitable mapping table
|
||||
3. email match from GitLink user profile and Feishu directory
|
||||
4. no mapping, dry-run only
|
||||
```
|
||||
|
||||
First implementation should only support the local file:
|
||||
|
||||
```yaml
|
||||
contributors:
|
||||
contributor-a:
|
||||
feishu_open_id: ou_xxx
|
||||
display_name: Contributor A
|
||||
contributor-b:
|
||||
email: contributor-b@example.com
|
||||
```
|
||||
|
||||
Validation rules:
|
||||
|
||||
```text
|
||||
mapping file is optional
|
||||
missing mapping downgrades to dry-run
|
||||
mapping values are redacted in logs
|
||||
the CLI does not call Feishu directory APIs in the first pass
|
||||
```
|
||||
|
||||
## Feishu Docs / Wiki
|
||||
|
||||
Feishu Docs and Wiki should be treated as the long-form project artifact.
|
||||
|
||||
Recommended generated content:
|
||||
|
||||
```text
|
||||
project overview
|
||||
README summary
|
||||
contribution guide summary
|
||||
review policy
|
||||
milestone plan
|
||||
daily or weekly owner digest archive
|
||||
PR stage table
|
||||
high-risk change notes
|
||||
```
|
||||
|
||||
README export should not replace the repository README. It should produce a Feishu-readable version for maintainers and contributors.
|
||||
|
||||
Recommended command shape:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +readme-doc \
|
||||
--owner <owner> \
|
||||
--repo <repo> \
|
||||
--wiki-url "<wiki_url>" \
|
||||
--send
|
||||
```
|
||||
|
||||
Permission boundary:
|
||||
|
||||
```text
|
||||
The owner configures Feishu app scopes and document permissions.
|
||||
The CLI never changes Feishu document permissions automatically.
|
||||
The CLI prints permission diagnostics when write access fails.
|
||||
```
|
||||
|
||||
This matches the current `+doc-export` boundary and avoids hidden permission changes.
|
||||
|
||||
## README and Knowledge Base Export
|
||||
|
||||
The README export should be deterministic and conservative.
|
||||
|
||||
Recommended sections:
|
||||
|
||||
```text
|
||||
1. Project title
|
||||
2. Short repository summary
|
||||
3. Quick start
|
||||
4. Contribution workflow
|
||||
5. Review policy
|
||||
6. Current milestones
|
||||
7. Current owner digest link
|
||||
8. Source repository links
|
||||
```
|
||||
|
||||
The command should accept local files before remote reads:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +readme-doc \
|
||||
--from-readme README.md \
|
||||
--from-contributing CONTRIBUTING.md \
|
||||
--wiki-url "<wiki_url>" \
|
||||
--format markdown
|
||||
```
|
||||
|
||||
Later remote mode can use GitLink repository file APIs:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +readme-doc \
|
||||
--owner <owner> \
|
||||
--repo <repo> \
|
||||
--ref master \
|
||||
--wiki-url "<wiki_url>"
|
||||
```
|
||||
|
||||
Doc write behavior:
|
||||
|
||||
```text
|
||||
preview by default
|
||||
--send required for document writes
|
||||
append or update target must be explicit
|
||||
do not change sharing settings
|
||||
return permission diagnostics on 403
|
||||
```
|
||||
|
||||
## Milestones and Gantt
|
||||
|
||||
Gantt-style planning belongs to milestone tracking, not raw notification cards.
|
||||
|
||||
Recommended data model:
|
||||
|
||||
```text
|
||||
milestone_id
|
||||
milestone_title
|
||||
start_date
|
||||
due_date
|
||||
status
|
||||
linked_issues
|
||||
linked_prs
|
||||
owner
|
||||
progress_percent
|
||||
risk_level
|
||||
```
|
||||
|
||||
Feishu implementation options:
|
||||
|
||||
```text
|
||||
Doc / Wiki: milestone narrative and current status.
|
||||
Bitable records: structured milestone rows.
|
||||
Bitable Gantt view: created manually by owner at first.
|
||||
Later OpenAPI sync: update milestone rows after table IDs are configured.
|
||||
```
|
||||
|
||||
The first implementation should only generate milestone-ready records and Doc content. Automatic Bitable view creation should remain out of scope until real Bitable writes are implemented.
|
||||
|
||||
Recommended Bitable milestone fields:
|
||||
|
||||
```text
|
||||
milestone_key
|
||||
repository
|
||||
title
|
||||
owner
|
||||
start_date
|
||||
due_date
|
||||
status
|
||||
progress_percent
|
||||
risk_level
|
||||
linked_prs
|
||||
linked_issues
|
||||
last_updated_at
|
||||
```
|
||||
|
||||
Manual Gantt setup:
|
||||
|
||||
```text
|
||||
1. Owner creates a Bitable table using generated schema.
|
||||
2. Owner imports generated milestone records.
|
||||
3. Owner creates a Gantt view from start_date and due_date.
|
||||
4. Later CLI sync updates rows, not views.
|
||||
```
|
||||
|
||||
## Feishu AI Summary Fit
|
||||
|
||||
The CLI should not depend on a private Feishu AI summary API for the first implementation.
|
||||
|
||||
Instead, the CLI should generate structured Feishu Docs and cards that are easy for Feishu-side summary, daily report, weekly report, and knowledge-base features to consume.
|
||||
|
||||
Practical split:
|
||||
|
||||
```text
|
||||
gitlink-cli: collect, normalize, stage, render, send.
|
||||
Feishu: summarize, archive, search, collaborate, display.
|
||||
Owner: configure permissions, choose digest schedule, tune stage rules.
|
||||
```
|
||||
|
||||
## Configuration File
|
||||
|
||||
A future config file should keep project policy out of command-line flags.
|
||||
|
||||
Recommended path:
|
||||
|
||||
```text
|
||||
.gitlink-feishu.yaml
|
||||
```
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```yaml
|
||||
repository: Gitlink/gitlink-cli
|
||||
|
||||
owner_digest:
|
||||
enabled: true
|
||||
cadence: weekly
|
||||
webhook_env: FEISHU_WEBHOOK_URL
|
||||
doc_url_env: FEISHU_PROJECT_DOC_URL
|
||||
inline_limit: 10
|
||||
|
||||
contributor_notifications:
|
||||
enabled: true
|
||||
delivery: dry-run
|
||||
identity_mapping: .gitlink-feishu-users.yaml
|
||||
debounce_window: 10m
|
||||
|
||||
pr_stage_rules:
|
||||
near_ready:
|
||||
color: green
|
||||
require_approved: true
|
||||
require_no_conflicts: true
|
||||
needs_rebase:
|
||||
color: yellow
|
||||
require_mergeable: false
|
||||
major_changes:
|
||||
color: orange
|
||||
min_review_rounds: 2
|
||||
min_changed_files: 20
|
||||
|
||||
docs:
|
||||
wiki_url_env: FEISHU_PROJECT_WIKI_URL
|
||||
readme_sources:
|
||||
- README.md
|
||||
- CONTRIBUTING.md
|
||||
|
||||
milestones:
|
||||
enabled: true
|
||||
records_only: true
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
```text
|
||||
environment variable names may be stored
|
||||
secret values must not be stored
|
||||
unknown config keys should warn, not crash
|
||||
invalid stage colors should fail validation
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase A: Role-Aware Dry Run
|
||||
|
||||
Add local outputs only:
|
||||
|
||||
```text
|
||||
owner digest model
|
||||
contributor event model
|
||||
PR stage model
|
||||
default stage color rules
|
||||
milestone record model
|
||||
README-to-doc preview model
|
||||
```
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
feishu +owner-digest --from-workflow-json
|
||||
feishu +pr-stage-report --from-pr-json
|
||||
feishu +contributor-events --from-event-json
|
||||
feishu +readme-doc --from-readme
|
||||
```
|
||||
|
||||
No GitLink writes. No Feishu writes by default.
|
||||
|
||||
Acceptance:
|
||||
|
||||
```text
|
||||
owner digest JSON is stable
|
||||
PR stage classification is deterministic
|
||||
card color is derived from stage
|
||||
missing optional fields do not panic
|
||||
large input is capped in card preview
|
||||
all tests use fixtures
|
||||
```
|
||||
|
||||
### Phase B: Owner Digest Send
|
||||
|
||||
Enable bot cards for aggregated owner summaries:
|
||||
|
||||
```text
|
||||
feishu +owner-digest --send
|
||||
```
|
||||
|
||||
Use custom bot webhook, same safety model as current `+notify`.
|
||||
|
||||
Acceptance:
|
||||
|
||||
```text
|
||||
--send is required for webhook delivery
|
||||
--send without webhook URL fails
|
||||
--send --dry-run fails
|
||||
webhook URL is redacted
|
||||
mock HTTP tests cover 200, 400, 429, and 500
|
||||
```
|
||||
|
||||
### Phase C: Contributor Direct Notifications
|
||||
|
||||
Add contributor delivery after identity mapping exists:
|
||||
|
||||
```text
|
||||
GitLink username -> Feishu user ID
|
||||
```
|
||||
|
||||
Supported delivery modes:
|
||||
|
||||
```text
|
||||
custom group bot mention
|
||||
self-built app IM message
|
||||
dry-run only if identity mapping is missing
|
||||
```
|
||||
|
||||
Acceptance:
|
||||
|
||||
```text
|
||||
missing identity mapping downgrades to dry-run
|
||||
direct message mode requires explicit --send
|
||||
recipient IDs are redacted in logs
|
||||
event_id/state prevents duplicate delivery
|
||||
debounce behavior is covered by tests
|
||||
```
|
||||
|
||||
### Phase D: Docs / Wiki Project Space
|
||||
|
||||
Extend experimental document export:
|
||||
|
||||
```text
|
||||
README summary
|
||||
owner digest archive
|
||||
milestone page
|
||||
PR stage table
|
||||
```
|
||||
|
||||
Keep all document writes behind `--send`.
|
||||
|
||||
Acceptance:
|
||||
|
||||
```text
|
||||
README preview renders markdown
|
||||
DocX/Wiki write requires app credentials and --send
|
||||
403 errors include permission diagnostics
|
||||
document permission changes are not attempted
|
||||
```
|
||||
|
||||
### Phase E: Milestone / Gantt Data
|
||||
|
||||
Generate milestone-ready Bitable records first.
|
||||
|
||||
Real Bitable sync can follow only after:
|
||||
|
||||
```text
|
||||
tenant token flow
|
||||
table IDs
|
||||
unique keys
|
||||
upsert behavior
|
||||
permission diagnostics
|
||||
partial failure handling
|
||||
```
|
||||
|
||||
Acceptance:
|
||||
|
||||
```text
|
||||
milestone records include stable unique keys
|
||||
records are usable for manual Bitable import
|
||||
no Bitable OpenAPI calls happen without explicit send behavior
|
||||
Gantt view creation remains manual in this phase
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Do not implement in the first role-aware extension:
|
||||
|
||||
```text
|
||||
automatic GitLink merge
|
||||
automatic GitLink review
|
||||
automatic GitLink comments
|
||||
automatic Feishu permission changes
|
||||
automatic Bitable view creation
|
||||
direct dependency on BotBuilder
|
||||
notification spam for every owner-visible event
|
||||
```
|
||||
|
||||
## Design Verdict
|
||||
|
||||
This direction is stronger than a raw PR event notifier.
|
||||
|
||||
The product should be:
|
||||
|
||||
```text
|
||||
Owner: Feishu digest and knowledge-base workspace.
|
||||
Contributor: immediate personal feedback loop.
|
||||
Project: Docs/Wiki for long-form context, Bitable/Gantt for milestone tracking.
|
||||
```
|
||||
|
||||
The current implementation already supports the lowest-risk part:
|
||||
|
||||
```text
|
||||
workflow JSON -> Feishu card / weekly report / Doc link / Bitable-ready records
|
||||
```
|
||||
|
||||
The next useful design step is to add role-aware models and dry-run outputs before adding new Feishu or GitLink network behavior.
|
||||
|
|
@ -0,0 +1,440 @@
|
|||
# Feishu Export Technical Plan
|
||||
|
||||
## Latest Master Anchors
|
||||
|
||||
Current command registration:
|
||||
|
||||
```text
|
||||
shortcuts/register.go
|
||||
```
|
||||
|
||||
Add imports:
|
||||
|
||||
```go
|
||||
github.com/gitlink-org/gitlink-cli/shortcuts/feishu
|
||||
```
|
||||
|
||||
Add group:
|
||||
|
||||
```go
|
||||
"feishu": feishu.Shortcuts(),
|
||||
```
|
||||
|
||||
Add description:
|
||||
|
||||
```go
|
||||
"feishu": "Export GitLink workflow data to Feishu cards and Bitable-ready records",
|
||||
```
|
||||
|
||||
Shortcut mounting:
|
||||
|
||||
```text
|
||||
shortcuts/common/runner.go
|
||||
```
|
||||
|
||||
Important behavior:
|
||||
|
||||
- Boolean flags are read from Cobra as strings in `RuntimeContext.Args`.
|
||||
- A bool flag with default `true` cannot tell whether the user explicitly set it. Therefore `--send` should be the positive action flag, and `--dry-run` should remain a preview indicator.
|
||||
- If implementation must detect explicit `--dry-run`, it needs a command-specific Cobra command instead of the generic shortcut runner. Avoid that for the first implementation.
|
||||
|
||||
## Send Flag Decision
|
||||
|
||||
Use this rule:
|
||||
|
||||
```text
|
||||
--send=false or omitted: preview only.
|
||||
--send=true and dry-run=false: send.
|
||||
--send=true and dry-run=true: error.
|
||||
```
|
||||
|
||||
Since generic shortcut flags cannot detect whether `--dry-run` was explicitly passed, set `--dry-run` default to `false` in Feishu commands and treat preview as `!Send`. This avoids a default conflict where `--send` would always collide with default `--dry-run=true`.
|
||||
|
||||
Effective mode:
|
||||
|
||||
```go
|
||||
Preview := !opts.Send
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
```go
|
||||
if opts.Send && opts.DryRun {
|
||||
return error
|
||||
}
|
||||
if opts.Send && opts.WebhookURL == "" {
|
||||
return error
|
||||
}
|
||||
```
|
||||
|
||||
User-facing meaning:
|
||||
|
||||
```text
|
||||
No --send: preview.
|
||||
--dry-run: preview and forbid send.
|
||||
--send: real bot send.
|
||||
--send --dry-run: invalid.
|
||||
```
|
||||
|
||||
## Output Strategy
|
||||
|
||||
Do not extend `internal/output` in the first implementation.
|
||||
|
||||
Reason:
|
||||
|
||||
- Global output supports `json`, `yaml`, and generic `table`.
|
||||
- Workflow uses local renderers for markdown.
|
||||
- Feishu needs markdown for weekly reports and schema docs.
|
||||
|
||||
Implement local Feishu render helpers:
|
||||
|
||||
```go
|
||||
func renderJSON(w io.Writer, value any) error
|
||||
func renderMarkdown(w io.Writer, value any) error
|
||||
func renderTable(w io.Writer, value any) error
|
||||
func normalizeFormat(format string, defaultFormat string) string
|
||||
```
|
||||
|
||||
Default formats:
|
||||
|
||||
```text
|
||||
+bot-test: json
|
||||
+notify: json
|
||||
+weekly-report: markdown
|
||||
+bitable-schema: markdown
|
||||
+bitable-records: json
|
||||
```
|
||||
|
||||
## Workflow JSON Input
|
||||
|
||||
First implementation only supports local JSON files.
|
||||
|
||||
Expected source:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json > report.json
|
||||
```
|
||||
|
||||
Mapping should accept both shapes if found:
|
||||
|
||||
1. Raw `RepoReportResult`.
|
||||
2. Envelope-like object with `data`.
|
||||
|
||||
Fields to use:
|
||||
|
||||
```text
|
||||
repository
|
||||
health.health_score
|
||||
health.risk_level
|
||||
issue_summary.total
|
||||
issue_summary.high_risk
|
||||
issue_summary.missing_info
|
||||
pr_summary.total
|
||||
pr_summary.high_risk
|
||||
pr_summary.review_focus
|
||||
recommendations
|
||||
risk_level
|
||||
report_score
|
||||
source
|
||||
```
|
||||
|
||||
Do not import unexported workflow readers. Use JSON mapping to avoid changing workflow internals.
|
||||
|
||||
## Feishu Signature
|
||||
|
||||
Use Feishu custom bot signing:
|
||||
|
||||
```text
|
||||
string_to_sign = timestamp + "\n" + secret
|
||||
sign = base64(hmac_sha256(string_to_sign, secret))
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- `timestamp` is Unix seconds and serialized as a string.
|
||||
- Empty secret means no timestamp/sign.
|
||||
- Tests use fixed timestamp.
|
||||
- Errors must be redacted.
|
||||
|
||||
## Feishu Card Shape
|
||||
|
||||
Use custom bot interactive card payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {
|
||||
"tag": "plain_text",
|
||||
"content": "GitLink Project Activity"
|
||||
}
|
||||
},
|
||||
"elements": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Envelope with signature:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "1710000000",
|
||||
"sign": "redacted in logs",
|
||||
"msg_type": "interactive",
|
||||
"card": {}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Client
|
||||
|
||||
Use package-local client:
|
||||
|
||||
```go
|
||||
type Client struct {
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
```
|
||||
|
||||
Use `httptest.Server` for all tests.
|
||||
|
||||
Do not use `internal/client.Client` for Feishu because it appends GitLink `.json` suffixes and injects GitLink auth.
|
||||
|
||||
## Naming
|
||||
|
||||
CLI flags:
|
||||
|
||||
```text
|
||||
prs
|
||||
--include issues,prs,contributors,health
|
||||
--tables issues,prs,contributors,reports
|
||||
```
|
||||
|
||||
User-facing labels:
|
||||
|
||||
```text
|
||||
Pull Requests
|
||||
```
|
||||
|
||||
Internal model names:
|
||||
|
||||
```text
|
||||
PullRequestSummary
|
||||
NewPullRequests
|
||||
MergedPullRequests
|
||||
```
|
||||
|
||||
Do not use `pulls` in new user-facing flags.
|
||||
|
||||
## Bitable Scope
|
||||
|
||||
First implementation:
|
||||
|
||||
```text
|
||||
+bitable-schema
|
||||
+bitable-records
|
||||
```
|
||||
|
||||
No Feishu OpenAPI calls.
|
||||
|
||||
No environment variables for app tokens or table IDs.
|
||||
|
||||
No tenant token.
|
||||
|
||||
No create/update/upsert.
|
||||
|
||||
The records command outputs local JSON only:
|
||||
|
||||
```go
|
||||
type BitableRecordsOutput struct {
|
||||
Repository string `json:"repository"`
|
||||
Tables []BitableTableRecords `json:"tables"`
|
||||
}
|
||||
|
||||
type BitableTableRecords struct {
|
||||
Table string `json:"table"`
|
||||
Records []BitableRecord `json:"records"`
|
||||
}
|
||||
|
||||
type BitableRecord struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Fields map[string]any `json:"fields"`
|
||||
}
|
||||
```
|
||||
|
||||
## Feishu Docs Scope
|
||||
|
||||
Official Feishu Open Platform docs show that cloud document integration belongs to the self-built app flow, not the custom bot flow.
|
||||
|
||||
Experimental command:
|
||||
|
||||
```text
|
||||
feishu +doc-export
|
||||
```
|
||||
|
||||
Inputs:
|
||||
|
||||
```text
|
||||
--from-workflow-json
|
||||
--folder-token
|
||||
--document-id optional later
|
||||
--wiki-url optional later
|
||||
--wiki-node-token optional later
|
||||
--title
|
||||
--send
|
||||
```
|
||||
|
||||
Environment:
|
||||
|
||||
```text
|
||||
FEISHU_APP_ID
|
||||
FEISHU_APP_SECRET
|
||||
```
|
||||
|
||||
API flow:
|
||||
|
||||
```text
|
||||
1. POST /open-apis/auth/v3/tenant_access_token/internal
|
||||
2. Optional: GET /open-apis/wiki/v2/spaces/get_node?token=<wiki_node_token>
|
||||
3. POST /open-apis/docx/v1/documents
|
||||
4. POST /open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Default remains preview only.
|
||||
- `--send` is required before creating or updating a document.
|
||||
- The app must have both application scopes and document/folder-level permission.
|
||||
- The command should return a document ID and URL for `+notify --doc-url`.
|
||||
- If `--wiki-url` or `--wiki-node-token` is provided, resolve the wiki node first and use the underlying `docx` object token when possible.
|
||||
- Mock all HTTP tests.
|
||||
- Do not implement document sharing or permission changes in the first doc export pass.
|
||||
|
||||
Stable product flow:
|
||||
|
||||
```text
|
||||
workflow +repo-report -> feishu +weekly-report -> feishu +notify --doc-url -> feishu +bitable-records
|
||||
```
|
||||
|
||||
Experimental product flow:
|
||||
|
||||
```text
|
||||
workflow +repo-report -> feishu +doc-export --wiki-url -> feishu +notify --doc-url -> feishu +bitable-records
|
||||
```
|
||||
|
||||
DocX export should remain clearly marked as experimental until tenant permissions, scopes, and document-write behavior are stable.
|
||||
|
||||
## Role-Based Collaboration Extension
|
||||
|
||||
The next design layer is role-aware delivery, documented in:
|
||||
|
||||
```text
|
||||
feishu-export-design/ROLE_BASED_COLLABORATION.md
|
||||
```
|
||||
|
||||
Core split:
|
||||
|
||||
```text
|
||||
Owner / maintainer: periodic aggregated digest, not per-event spam.
|
||||
Contributor: immediate personal feedback for PR comments, reviews, rebase needs, and merge results.
|
||||
```
|
||||
|
||||
Owner-facing features should build on aggregated workflow and PR data:
|
||||
|
||||
```text
|
||||
daily digest
|
||||
weekly report
|
||||
review queue summary
|
||||
PR stage color report
|
||||
milestone status
|
||||
Feishu Doc / Wiki full report link
|
||||
```
|
||||
|
||||
Contributor-facing features need a separate event model:
|
||||
|
||||
```text
|
||||
review comment received
|
||||
changes requested
|
||||
needs rebase
|
||||
conflict detected
|
||||
approved
|
||||
merged
|
||||
closed or refused
|
||||
```
|
||||
|
||||
PR stage colors should be derived from explainable inputs such as review status, patchset count, mergeability, requested changes, and stale activity:
|
||||
|
||||
```text
|
||||
blue: new / unreviewed
|
||||
grey: active review
|
||||
green: near ready or merged
|
||||
yellow: needs rebase
|
||||
orange: major changes requested
|
||||
red: blocked
|
||||
```
|
||||
|
||||
The first role-aware extension should stay local and dry-run by default:
|
||||
|
||||
```text
|
||||
feishu +owner-digest --from-workflow-json
|
||||
feishu +pr-stage-report --from-pr-json
|
||||
feishu +contributor-events --from-event-json
|
||||
feishu +readme-doc --from-readme
|
||||
```
|
||||
|
||||
Do not add real-time contributor delivery until identity mapping is designed:
|
||||
|
||||
```text
|
||||
GitLink username -> Feishu open_id / union_id / email
|
||||
```
|
||||
|
||||
Feishu Docs / Wiki should hold long-form project context:
|
||||
|
||||
```text
|
||||
README summary
|
||||
contribution guide summary
|
||||
owner digest archive
|
||||
milestone plan
|
||||
PR stage table
|
||||
```
|
||||
|
||||
The owner configures Feishu permissions. The CLI must not change document permissions automatically.
|
||||
|
||||
## Tests To Add
|
||||
|
||||
```text
|
||||
shortcuts/feishu/options_test.go
|
||||
shortcuts/feishu/redact_test.go
|
||||
shortcuts/feishu/signer_test.go
|
||||
shortcuts/feishu/card_test.go
|
||||
shortcuts/feishu/client_test.go
|
||||
shortcuts/feishu/mapper_test.go
|
||||
shortcuts/feishu/schema_test.go
|
||||
shortcuts/feishu/bitable_test.go
|
||||
shortcuts/feishu/commands_test.go
|
||||
```
|
||||
|
||||
Minimum assertions:
|
||||
|
||||
```text
|
||||
No --send means no HTTP.
|
||||
--send means HTTP in mock tests.
|
||||
--send --dry-run errors.
|
||||
Missing webhook URL with --send errors.
|
||||
Errors redact webhook URL and secret.
|
||||
Workflow JSON can be loaded from fixture.
|
||||
Missing fields do not panic.
|
||||
Weekly report markdown is stable.
|
||||
Bitable schema JSON is parseable.
|
||||
Bitable records JSON is parseable.
|
||||
```
|
||||
|
||||
## Baseline Verification
|
||||
|
||||
Current latest master passes with:
|
||||
|
||||
```powershell
|
||||
$env:GOPROXY='https://goproxy.cn,direct'; go test ./...
|
||||
```
|
||||
|
||||
Keep this as the baseline before implementation.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
# Feishu Export Task Completion
|
||||
|
||||
## Branch
|
||||
|
||||
```text
|
||||
feat/feishu-export-clean
|
||||
```
|
||||
|
||||
## Implemented Commands
|
||||
|
||||
```text
|
||||
gitlink-cli feishu +bot-test
|
||||
gitlink-cli feishu +notify
|
||||
gitlink-cli feishu +weekly-report
|
||||
gitlink-cli feishu +owner-digest
|
||||
gitlink-cli feishu +contributor-digest
|
||||
gitlink-cli feishu +bitable-schema
|
||||
gitlink-cli feishu +bitable-records
|
||||
gitlink-cli feishu +bitable-sync
|
||||
gitlink-cli feishu +doc-export
|
||||
gitlink-cli feishu +task-preview
|
||||
gitlink-cli feishu +task-create
|
||||
```
|
||||
|
||||
## Implemented Behavior
|
||||
|
||||
- Added a `feishu` shortcut group.
|
||||
- Added local preview by default.
|
||||
- Added explicit `--send` for real Feishu custom bot delivery.
|
||||
- Added `--send` and `--dry-run` conflict validation.
|
||||
- Added webhook URL redaction in command output.
|
||||
- Added Feishu custom bot interactive card builders.
|
||||
- Added Feishu custom bot signing helper.
|
||||
- Added injectable HTTP webhook client.
|
||||
- Added workflow JSON input support for `RepoReportInput`, `RepoReportResult`, and envelope-like `data`.
|
||||
- Added project activity card generation from workflow JSON.
|
||||
- Added weekly report rendering from workflow JSON.
|
||||
- Added role-aware owner digest rendering and optional custom bot sending.
|
||||
- Added role-oriented contributor digest rendering and optional custom bot sending.
|
||||
- Added `--doc-url` support for notification cards.
|
||||
- Added experimental `feishu +doc-export` for Feishu DocX / Wiki export.
|
||||
- Added self-built app tenant token acquisition.
|
||||
- Added Wiki node resolution.
|
||||
- Added DocX block creation client.
|
||||
- Added document export preview and explicit `--send` behavior.
|
||||
- Added Bitable dry-run schema generation.
|
||||
- Added Bitable-ready dry-run records.
|
||||
- Expanded Bitable records to `reports`, `issues`, `prs`, `contributors`, and `tasks`.
|
||||
- Added experimental `feishu +bitable-sync` with `unique_key` search-before-update, create fallback, and no-delete behavior.
|
||||
- Added local task candidate generation through `feishu +task-preview`.
|
||||
- Added experimental `feishu +task-create` using Feishu Open Platform Task API.
|
||||
- Registered the new shortcut group in `shortcuts/register.go`.
|
||||
- Updated shortcut registration tests.
|
||||
|
||||
## Feishu Smoke Checks
|
||||
|
||||
Custom bot send was tested against a real Feishu custom bot webhook.
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
HTTP status: 200
|
||||
Feishu code: 0
|
||||
Message: success
|
||||
```
|
||||
|
||||
Webhook output was redacted.
|
||||
|
||||
A second notification card was sent with a Feishu Wiki URL as the report entry link.
|
||||
|
||||
The 2026-06-26 implementation smoke in the current shell did not have Feishu environment variables available, so real `--send` calls were not repeated in that shell. Public GitLink read smoke and local preview commands passed; see `reports/FEISHU_SMOKE_20260626.md`.
|
||||
|
||||
## Open Platform Checks
|
||||
|
||||
Self-built app authentication was checked with the Feishu Open Platform tenant token endpoint.
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
tenant_access_token: acquired
|
||||
expire: 7199 seconds
|
||||
```
|
||||
|
||||
The supplied Feishu Wiki URL was resolved through Wiki OpenAPI.
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
wiki node: resolved
|
||||
object type: docx
|
||||
object token: present
|
||||
```
|
||||
|
||||
No document content was modified in this check.
|
||||
|
||||
The first real DocX block write attempt reached the DocX block endpoint but Feishu rejected the write:
|
||||
|
||||
```text
|
||||
HTTP status: 403
|
||||
Feishu code: 1770032
|
||||
Message: forBidden
|
||||
```
|
||||
|
||||
Interpretation:
|
||||
|
||||
```text
|
||||
The app credentials are valid and the Wiki node is readable, but the app does not currently have write permission on the target Wiki-backed DocX page or lacks the required document scope approval.
|
||||
```
|
||||
|
||||
The command now reports a permission hint for this case.
|
||||
|
||||
Additional experimental Open Platform paths added after the original smoke:
|
||||
|
||||
```text
|
||||
bitable-sync: mock HTTP tested for tenant token, search, and create.
|
||||
task-create: mock HTTP tested for tenant token and task create.
|
||||
```
|
||||
|
||||
## Knowledge Base Design Update
|
||||
|
||||
Added official-docs alignment notes:
|
||||
|
||||
```text
|
||||
feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md
|
||||
```
|
||||
|
||||
Design now treats Feishu Knowledge Base / Wiki pages as a project showcase and reference target:
|
||||
|
||||
```text
|
||||
workflow JSON -> DocX/Wiki report -> bot card with doc URL -> Bitable dry-run records
|
||||
```
|
||||
|
||||
After scope review, DocX / Wiki export is explicitly experimental and not part of the stable clean workflow.
|
||||
|
||||
Stable path:
|
||||
|
||||
```text
|
||||
workflow JSON -> bot card / weekly report / owner digest / contributor digest / Bitable dry-run records / task preview
|
||||
```
|
||||
|
||||
Experimental path:
|
||||
|
||||
```text
|
||||
workflow JSON -> DocX/Wiki export / Bitable sync / Task create through self-built app OpenAPI
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
Commands run:
|
||||
|
||||
```bash
|
||||
go test ./shortcuts/feishu
|
||||
go test ./shortcuts/workflow
|
||||
go test ./shortcuts
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
passed
|
||||
```
|
||||
|
||||
## Explicitly Not Implemented
|
||||
|
||||
```text
|
||||
BotBuilder integration
|
||||
Feishu Robot Assistant workflows
|
||||
Feishu approval creation
|
||||
callback server
|
||||
button callbacks
|
||||
GitLink remote writes
|
||||
GitLink comments
|
||||
Issue closure
|
||||
code merge actions
|
||||
direct GitLink webhook creation
|
||||
Feishu Base/table/view creation
|
||||
document permission modification
|
||||
```
|
||||
|
||||
Note: experimental `doc-export`, `bitable-sync`, and `task-create` can attempt Open Platform writes when explicitly invoked with `--send`, but they remain outside the stable custom-bot export path.
|
||||
|
||||
## Next Engineering Step
|
||||
|
||||
For stable delivery, continue validating custom bot delivery, weekly reports, and Bitable dry-run output.
|
||||
|
||||
For experimental DocX/Wiki export, complete the Feishu document permission setup and rerun:
|
||||
|
||||
```text
|
||||
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url <wiki_url> --send --format table
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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.
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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", " ")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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), " ")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"},
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
---
|
||||
name: gitlink-feishu
|
||||
version: 1.0.0
|
||||
description: "Export GitLink workflow JSON to Feishu custom bot cards, digests, Bitable-ready records, and experimental Open Platform validation commands."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli feishu --help"
|
||||
---
|
||||
|
||||
# gitlink-feishu
|
||||
|
||||
Use this skill when a user needs to export GitLink workflow analysis into Feishu.
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable path:
|
||||
|
||||
```text
|
||||
workflow JSON -> Feishu bot card / weekly report / Bitable dry-run records
|
||||
workflow JSON -> owner digest / contributor digest / task preview
|
||||
```
|
||||
|
||||
Experimental path:
|
||||
|
||||
```text
|
||||
workflow JSON -> Feishu DocX / Wiki export / Bitable sync / Task create
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
Workflow JSON should usually come from:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json > report.json
|
||||
```
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Preview first.
|
||||
- Use `--send` only when the user explicitly wants a Feishu network write.
|
||||
- Never use BotBuilder or Robot Assistant workflows.
|
||||
- Do not write to GitLink resources.
|
||||
- Do not print webhook URLs, app secrets, access tokens, or table tokens.
|
||||
- Treat `+bitable-schema`, `+bitable-records`, and `+task-preview` as local dry-run commands only.
|
||||
- Treat `+doc-export` as experimental because it uses self-built app OpenAPI and document write permissions.
|
||||
- Treat `+bitable-sync` and `+task-create` as experimental because they use self-built app OpenAPI and resource permissions.
|
||||
|
||||
## Preview Flow
|
||||
|
||||
Preview a card:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +notify --from-workflow-json report.json --format json
|
||||
```
|
||||
|
||||
Render a weekly report:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
|
||||
```
|
||||
|
||||
Preview owner and contributor digests:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown
|
||||
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown
|
||||
```
|
||||
|
||||
Generate Bitable schemas:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +bitable-schema --format markdown
|
||||
```
|
||||
|
||||
Generate Bitable-ready records:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
|
||||
```
|
||||
|
||||
Preview task candidates:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown
|
||||
```
|
||||
|
||||
## Send Flow
|
||||
|
||||
Custom bot commands need:
|
||||
|
||||
```text
|
||||
FEISHU_WEBHOOK_URL
|
||||
FEISHU_WEBHOOK_SECRET optional
|
||||
```
|
||||
|
||||
Send a card:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
|
||||
```
|
||||
|
||||
Send a weekly report card:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table
|
||||
```
|
||||
|
||||
Send owner and contributor digest cards:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +owner-digest --from-workflow-json report.json --send --format table
|
||||
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --send --format table
|
||||
```
|
||||
|
||||
## Experimental Doc Export
|
||||
|
||||
DocX / Wiki export needs:
|
||||
|
||||
```text
|
||||
FEISHU_APP_ID
|
||||
FEISHU_APP_SECRET
|
||||
```
|
||||
|
||||
Preview only:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --format markdown
|
||||
```
|
||||
|
||||
Write to Feishu:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --send --format table
|
||||
```
|
||||
|
||||
If Feishu returns `1770032: forBidden`, the app token is valid but the app cannot write to the target DocX/Wiki page or folder.
|
||||
|
||||
## Experimental Bitable Sync And Task Create
|
||||
|
||||
Open Platform validation commands need:
|
||||
|
||||
```text
|
||||
FEISHU_APP_ID
|
||||
FEISHU_APP_SECRET
|
||||
FEISHU_BASE_APP_TOKEN for bitable-sync
|
||||
FEISHU_REPORT_TABLE_ID / FEISHU_ISSUE_TABLE_ID / FEISHU_PR_TABLE_ID for selected tables
|
||||
```
|
||||
|
||||
Preview Bitable sync:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +bitable-sync --from-workflow-json report.json --tables reports,issues,prs,tasks --format table
|
||||
```
|
||||
|
||||
Write Bitable records:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +bitable-sync --from-workflow-json report.json --tables reports,issues,prs,tasks --send --format table
|
||||
```
|
||||
|
||||
Create Feishu tasks:
|
||||
|
||||
```bash
|
||||
gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No GitLink remote writes.
|
||||
- No GitLink comments, issue closure, merge, or webhook creation.
|
||||
- No Bitable real writes in the stable path.
|
||||
- No Feishu task creation in the stable path.
|
||||
- No BotBuilder integration.
|
||||
- No automatic Feishu permission changes.
|
||||
|
||||
Loading…
Reference in New Issue