From d7812df1af49519f9eb84def218bd3d5a9fdf02f Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Sat, 27 Jun 2026 10:41:52 +0800 Subject: [PATCH] feat(feishu): add Open Platform readiness diagnostics --- .local/feishu-gitlink.env.example.ps1 | 1 + README.zh-CN.md | 11 + docs/FEISHU_CAPABILITY_LAYERS.md | 50 +++ docs/FEISHU_ENVIRONMENT.md | 28 +- docs/FEISHU_OPENAPI_INVENTORY.md | 31 +- ...EISHU_API_COLLECTION_CHECKLIST_20260626.md | 12 +- reports/FEISHU_LOCAL_TESTING_GUIDE.md | 65 ++- reports/FEISHU_PERMISSION_MATRIX.md | 4 + reports/FEISHU_SMOKE_20260626.md | 17 +- scripts/feishu-gitlink-env-check.ps1 | 2 +- scripts/feishu-gitlink-setup.ps1 | 4 +- scripts/feishu-gitlink-smoke.ps1 | 25 +- shortcuts/feishu/diagnostics.go | 394 ++++++++++++++++++ shortcuts/feishu/feishu.go | 87 ++++ shortcuts/feishu/feishu_test.go | 99 ++++- 15 files changed, 778 insertions(+), 52 deletions(-) create mode 100644 shortcuts/feishu/diagnostics.go diff --git a/.local/feishu-gitlink.env.example.ps1 b/.local/feishu-gitlink.env.example.ps1 index 61d98a5..202c7a9 100644 --- a/.local/feishu-gitlink.env.example.ps1 +++ b/.local/feishu-gitlink.env.example.ps1 @@ -10,6 +10,7 @@ $env:FEISHU_APP_SECRET="" $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="" diff --git a/README.zh-CN.md b/README.zh-CN.md index 8f66dd5..cc48e55 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -582,6 +582,17 @@ gitlink-cli feishu +owner-digest --from-workflow-json report.zh-CN.json --lang z 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 和目标资源权限。 diff --git a/docs/FEISHU_CAPABILITY_LAYERS.md b/docs/FEISHU_CAPABILITY_LAYERS.md index a264741..b43b89c 100644 --- a/docs/FEISHU_CAPABILITY_LAYERS.md +++ b/docs/FEISHU_CAPABILITY_LAYERS.md @@ -75,6 +75,52 @@ 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. @@ -120,6 +166,10 @@ 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 diff --git a/docs/FEISHU_ENVIRONMENT.md b/docs/FEISHU_ENVIRONMENT.md index b04fe31..bfed7d0 100644 --- a/docs/FEISHU_ENVIRONMENT.md +++ b/docs/FEISHU_ENVIRONMENT.md @@ -42,8 +42,8 @@ $env:FEISHU_WEBHOOK_SECRET="REDACTED" | Name | Purpose | Required | Used by | Sensitive | How to obtain | | --- | --- | --- | --- | --- | --- | -| `FEISHU_APP_ID` | Self-built app ID | Required for Open Platform `--send` | `+doc-export`, `+bitable-sync`, `+task-create` | Yes | Feishu Open Platform app page | -| `FEISHU_APP_SECRET` | Self-built app secret | Required for Open Platform `--send` | same as above | Yes | Feishu Open Platform app credentials | +| `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: @@ -56,10 +56,10 @@ $env:FEISHU_APP_SECRET="REDACTED" | Name | Purpose | Required | Used by | Sensitive | How to obtain | | --- | --- | --- | --- | --- | --- | -| `FEISHU_WIKI_URL` | Existing Wiki page URL | Optional target | `+doc-export` | Can expose workspace/resource ID | Copy from Feishu Wiki | -| `FEISHU_WIKI_NODE_TOKEN` | Existing Wiki node token | Optional target | `+doc-export` | Yes | Parsed from Wiki URL or API | -| `FEISHU_FOLDER_TOKEN` | Folder token for creating a new DocX | Optional target | `+doc-export` | Yes | Feishu Drive folder URL / Open Platform docs | -| `FEISHU_DOCUMENT_ID` | Existing DocX document ID for append | Optional target | `+doc-export` | Yes | Existing Feishu DocX URL or Open Platform docs | +| `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: @@ -87,12 +87,12 @@ go run . feishu +notify --from-workflow-json .local\report.zh-CN.json --lang zh- | Name | Purpose | Required | Used by | Sensitive | How to obtain | | --- | --- | --- | --- | --- | --- | -| `FEISHU_BASE_APP_TOKEN` | Base app token | Required for `+bitable-sync --send` | `+bitable-sync` | Yes | Feishu Base URL / Open Platform docs | -| `FEISHU_REPORT_TABLE_ID` | Reports table ID | Required when syncing `reports` | `+bitable-sync` | Yes | Base table settings / API | -| `FEISHU_ISSUE_TABLE_ID` | Issues table ID | Required when syncing `issues` | `+bitable-sync` | Yes | Base table settings / API | -| `FEISHU_PR_TABLE_ID` | Pull request table ID | Required when syncing `prs` | `+bitable-sync` | Yes | Base table settings / API | -| `FEISHU_CONTRIBUTOR_TABLE_ID` | Contributors table ID | Optional | `+bitable-sync` | Yes | Base table settings / API | -| `FEISHU_TASK_TABLE_ID` | Task-candidate table ID | Optional | `+bitable-sync` | Yes | Base table settings / API | +| `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: @@ -109,8 +109,8 @@ $env:FEISHU_TASK_TABLE_ID="REDACTED" | Name | Purpose | Required | Used by | Sensitive | How to obtain | | --- | --- | --- | --- | --- | --- | -| `FEISHU_TASK_PROJECT_ID` | Optional task project target | Optional | `+task-create` | Yes | Feishu Task project settings / API | -| `FEISHU_TASK_SECTION_ID` | Optional task section target | Optional | `+task-create` | Yes | Feishu Task section settings / API | +| `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: diff --git a/docs/FEISHU_OPENAPI_INVENTORY.md b/docs/FEISHU_OPENAPI_INVENTORY.md index 1071a74..d1c8c2e 100644 --- a/docs/FEISHU_OPENAPI_INVENTORY.md +++ b/docs/FEISHU_OPENAPI_INVENTORY.md @@ -56,11 +56,11 @@ https://www.feishu.cn/feishu-cli | --- | --- | --- | --- | --- | --- | | 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` | `+doc-export`, `+bitable-sync`, `+task-create` | Implemented experimental | Tenant token | No token cache yet | -| Wiki node resolution | `GET /wiki/v2/spaces/get_node?token=...` | `+doc-export` | Implemented experimental | Wiki metadata read | Used to resolve Wiki node to DocX object token | +| 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-sync` | Implemented experimental | Existing Base table | Searches by `unique_key` field | +| 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 | @@ -137,6 +137,10 @@ Current commands: +doc-export +bitable-sync +task-create ++app-check ++doc-check ++bitable-check ++task-check ``` Inputs: @@ -157,7 +161,8 @@ Does not print the raw token. Next hardening: ```text -Add +app-check. +`+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. ``` @@ -220,7 +225,8 @@ must be handled by the owner/admin outside the CLI. Next hardening: ```text -Add +app-check diagnostics for DocX/Wiki scopes. +`+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. ``` @@ -240,6 +246,7 @@ Current commands: ```text +bitable-schema +bitable-records ++bitable-check +bitable-sync ``` @@ -272,6 +279,9 @@ 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. @@ -317,7 +327,8 @@ Current records are summary buckets, not full row-level PR/Issue/CI records. Next hardening: ```text -Add table/field validation before writes. +`+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. @@ -335,6 +346,7 @@ Current commands: ```text +task-preview ++task-check +task-create ``` @@ -352,6 +364,8 @@ 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. @@ -386,7 +400,9 @@ 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. -Add scope diagnostics through +app-check. +`+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 @@ -589,6 +605,7 @@ GitLink real data validation: [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. diff --git a/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md b/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md index 0063c3a..92098ec 100644 --- a/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md +++ b/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md @@ -25,6 +25,7 @@ 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. ``` @@ -35,7 +36,7 @@ Test enterprise permissions: intentionally broad for validation; production shou | --- | --- | --- | --- | | 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` | Add future `+app-check` | +| 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 | @@ -57,6 +58,10 @@ Test enterprise permissions: intentionally broad for validation; production shou | `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 | @@ -75,6 +80,7 @@ Test enterprise permissions: intentionally broad for validation; production shou 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 @@ -157,6 +163,10 @@ Executed on 2026-06-26 after the API inventory update: | `+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 | diff --git a/reports/FEISHU_LOCAL_TESTING_GUIDE.md b/reports/FEISHU_LOCAL_TESTING_GUIDE.md index f5c1240..40d3131 100644 --- a/reports/FEISHU_LOCAL_TESTING_GUIDE.md +++ b/reports/FEISHU_LOCAL_TESTING_GUIDE.md @@ -45,7 +45,30 @@ This file is ignored and must not be committed. A tracked empty example is avail The checker prints only redacted values. -## 3. Configure GitLink Test Repository Manually If Needed +## 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" @@ -58,7 +81,7 @@ If the current workflow command cannot filter specific PR IDs, keep the PR IDs i $env:GITLINK_TEST_PR_IDS="1,2,3" ``` -## 4. Run Scripted Smoke Tests +## 5. Run Scripted Smoke Tests Preview only: @@ -94,7 +117,7 @@ reports/feishu-real-smoke-terminal.log The terminal log is ignored and must not be committed after real runs. -## 5. Generate Workflow Report JSON Manually +## 6. Generate Workflow Report JSON Manually ```bash gitlink-cli workflow +repo-report \ @@ -105,13 +128,13 @@ gitlink-cli workflow +repo-report \ Windows PowerShell redirection may produce UTF-16 with BOM. The Feishu workflow JSON reader supports UTF-8 and UTF-16 BOM inputs. -## 6. Preview Feishu Notify Card +## 7. Preview Feishu Notify Card ```bash gitlink-cli feishu +notify --from-workflow-json report.json --format json ``` -## 7. Send Feishu Notify Card +## 8. Send Feishu Notify Card ```bash gitlink-cli feishu +notify --from-workflow-json report.json --send --format table @@ -124,50 +147,50 @@ FEISHU_WEBHOOK_URL FEISHU_WEBHOOK_SECRET optional ``` -## 8. Render Weekly Report +## 9. Render Weekly Report ```bash gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown ``` -## 9. Send Weekly Report +## 10. Send Weekly Report ```bash gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table ``` -## 10. Generate Owner Digest +## 11. Generate Owner Digest ```bash gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown ``` -## 11. Send Owner Digest +## 12. Send Owner Digest ```bash gitlink-cli feishu +owner-digest --from-workflow-json report.json --send --format table ``` -## 12. Generate Contributor Digest +## 13. Generate Contributor Digest ```bash gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown ``` -## 13. Send Contributor Digest +## 14. Send Contributor Digest ```bash gitlink-cli feishu +contributor-digest --from-workflow-json report.json --send --format table ``` -## 14. Generate Bitable-Ready Records +## 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 ``` -## 15. Preview Bitable Sync +## 16. Preview Bitable Sync ```bash gitlink-cli feishu +bitable-sync \ @@ -176,7 +199,7 @@ gitlink-cli feishu +bitable-sync \ --format table ``` -## 16. Execute Bitable Sync +## 17. Execute Bitable Sync ```bash gitlink-cli feishu +bitable-sync \ @@ -199,7 +222,7 @@ FEISHU_CONTRIBUTOR_TABLE_ID optional FEISHU_TASK_TABLE_ID optional ``` -## 17. Preview DocX / Wiki Export +## 18. Preview DocX / Wiki Export ```bash gitlink-cli feishu +doc-export \ @@ -208,7 +231,7 @@ gitlink-cli feishu +doc-export \ --format markdown ``` -## 18. Execute DocX / Wiki Export +## 19. Execute DocX / Wiki Export ```bash gitlink-cli feishu +doc-export \ @@ -218,13 +241,13 @@ gitlink-cli feishu +doc-export \ --format table ``` -## 19. Preview Feishu Tasks +## 20. Preview Feishu Tasks ```bash gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown ``` -## 20. Create Feishu Tasks +## 21. Create Feishu Tasks ```bash gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table @@ -239,12 +262,12 @@ FEISHU_TASK_PROJECT_ID optional FEISHU_TASK_SECTION_ID optional ``` -## 21. Image Evidence +## 22. Image Evidence Image evidence is deferred for this round. Do not add screenshots or image files to the upload. -## 22. Run Go Tests +## 23. Run Go Tests ```bash gofmt -w shortcuts/feishu @@ -254,7 +277,7 @@ go test ./shortcuts go test ./... ``` -## 23. Capture Evidence +## 24. Capture Evidence Capture terminal logs and command output only. diff --git a/reports/FEISHU_PERMISSION_MATRIX.md b/reports/FEISHU_PERMISSION_MATRIX.md index 4642ec7..5e0c7a8 100644 --- a/reports/FEISHU_PERMISSION_MATRIX.md +++ b/reports/FEISHU_PERMISSION_MATRIX.md @@ -11,6 +11,10 @@ GitLink write permission is `No` for every implemented command in this branch. | 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 | diff --git a/reports/FEISHU_SMOKE_20260626.md b/reports/FEISHU_SMOKE_20260626.md index 6dedeea..bc47d3a 100644 --- a/reports/FEISHU_SMOKE_20260626.md +++ b/reports/FEISHU_SMOKE_20260626.md @@ -104,6 +104,10 @@ The workflow command does not currently filter the report by explicit PR IDs, so | `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 | @@ -200,6 +204,8 @@ module. It was not fixed in this smoke run to avoid unrelated locale churn. | `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 @@ -208,11 +214,12 @@ module. It was not fixed in this smoke run to avoid unrelated locale churn. 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. Feishu task creation does not yet map project/section placement into the request body; this is a next-stage capability boundary. -5. Feishu-side task dedupe/search is not implemented; avoid repeated real task-create runs unless duplicates are acceptable. -6. No Feishu callback server is implemented. -7. No GitLink write operation is implemented. -8. Image evidence is deferred and is not part of this upload. +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 diff --git a/scripts/feishu-gitlink-env-check.ps1 b/scripts/feishu-gitlink-env-check.ps1 index e1b0f5f..45f94a6 100644 --- a/scripts/feishu-gitlink-env-check.ps1 +++ b/scripts/feishu-gitlink-env-check.ps1 @@ -68,7 +68,7 @@ if ($Layer -eq "stable" -or $Layer -eq "all") { 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") -Optional @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN") + $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") } diff --git a/scripts/feishu-gitlink-setup.ps1 b/scripts/feishu-gitlink-setup.ps1 index 476762a..fb331e9 100644 --- a/scripts/feishu-gitlink-setup.ps1 +++ b/scripts/feishu-gitlink-setup.ps1 @@ -112,12 +112,13 @@ if (Missing @("FEISHU_APP_ID", "FEISHU_APP_SECRET")) { $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")) { +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")) { @@ -165,6 +166,7 @@ $vars = @( "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", diff --git a/scripts/feishu-gitlink-smoke.ps1 b/scripts/feishu-gitlink-smoke.ps1 index 7604a38..48bc361 100644 --- a/scripts/feishu-gitlink-smoke.ps1 +++ b/scripts/feishu-gitlink-smoke.ps1 @@ -269,8 +269,12 @@ try { $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 @@ -306,7 +310,26 @@ try { } if ($runOpenPlatform) { - 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"))) { + 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" diff --git a/shortcuts/feishu/diagnostics.go b/shortcuts/feishu/diagnostics.go new file mode 100644 index 0000000..541dd3a --- /dev/null +++ b/shortcuts/feishu/diagnostics.go @@ -0,0 +1,394 @@ +package feishu + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type DiagnosticOutput struct { + Mode string `json:"mode"` + Remote bool `json:"remote"` + Layer string `json:"layer"` + Summary DiagnosticSummary `json:"summary"` + Checks []DiagnosticCheck `json:"checks"` + Warnings []string `json:"warnings,omitempty"` +} + +type DiagnosticSummary struct { + Passed int `json:"passed"` + Warned int `json:"warned"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` +} + +type DiagnosticCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Required bool `json:"required"` + Target string `json:"target,omitempty"` + Value string `json:"value,omitempty"` + Detail string `json:"detail,omitempty"` + Hint string `json:"hint,omitempty"` +} + +func runFeishuAppCheck(ctx *common.RuntimeContext) error { + output := DiagnosticOutput{ + Mode: "check", + Remote: parseBool(ctx.Arg("remote")), + Layer: "app", + Warnings: []string{ + "Diagnostic commands never write Feishu resources or GitLink resources.", + "Use --remote to call Feishu OpenAPI read/check endpoints.", + }, + } + webhookURL := firstNonEmpty(ctx.Arg("webhook-url"), os.Getenv("FEISHU_WEBHOOK_URL")) + webhookSecret := firstNonEmpty(ctx.Arg("secret"), os.Getenv("FEISHU_WEBHOOK_SECRET")) + appID := firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")) + appSecret := firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET")) + + if strings.TrimSpace(webhookURL) == "" { + output.addCheck(warnCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", "missing", "stable webhook send commands need FEISHU_WEBHOOK_URL")) + } else if err := validateWebhookURL(webhookURL); err != nil { + output.addCheck(failCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", redactWebhookURL(webhookURL), err.Error(), "copy the full custom bot webhook URL from the Feishu group bot settings")) + } else { + output.addCheck(passCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", redactWebhookURL(webhookURL), "configured")) + } + if strings.TrimSpace(webhookSecret) == "" { + output.addCheck(warnCheck("custom bot signing secret", "FEISHU_WEBHOOK_SECRET", "missing", "only required when the custom bot enables signature verification")) + } else { + output.addCheck(passCheck("custom bot signing secret", "FEISHU_WEBHOOK_SECRET", redactToken(webhookSecret), "configured")) + } + output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", appID, "required for DocX, Bitable, and Task OpenAPI validation")) + output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", appSecret, "required for tenant_access_token")) + if output.Remote && output.Summary.Failed == 0 { + output.remoteTenantToken(appID, appSecret) + } else if output.Remote { + output.addCheck(skipCheck("tenant_access_token", "Feishu OpenAPI", "skipped because app credentials are incomplete")) + } + return finishDiagnostic(ctx, output) +} + +func runFeishuDocCheck(ctx *common.RuntimeContext) error { + opts := DocExportOptions{ + AppID: firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")), + AppSecret: firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET")), + FolderToken: firstNonEmpty(ctx.Arg("folder-token"), os.Getenv("FEISHU_FOLDER_TOKEN"), os.Getenv("FEISHU_DOC_FOLDER_TOKEN")), + DocumentID: firstNonEmpty(ctx.Arg("document-id"), os.Getenv("FEISHU_DOCUMENT_ID")), + WikiURL: firstNonEmpty(ctx.Arg("wiki-url"), os.Getenv("FEISHU_WIKI_URL")), + WikiNodeToken: firstNonEmpty(ctx.Arg("wiki-node-token"), os.Getenv("FEISHU_WIKI_NODE_TOKEN")), + } + if opts.WikiNodeToken == "" && opts.WikiURL != "" { + opts.WikiNodeToken = wikiNodeTokenFromURL(opts.WikiURL) + } + if opts.DocumentID == "" && opts.WikiURL != "" { + opts.DocumentID = docxTokenFromURL(opts.WikiURL) + } + output := DiagnosticOutput{ + Mode: "check", + Remote: parseBool(ctx.Arg("remote")), + Layer: "docx", + Warnings: []string{ + "Doc check does not append blocks or create documents.", + "Actual DocX/Wiki writes still require --send on +doc-export.", + }, + } + output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for DocX/Wiki OpenAPI")) + output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token")) + + targets := 0 + if opts.DocumentID != "" { + targets++ + output.addCheck(passCheck("existing document target", "FEISHU_DOCUMENT_ID", redactToken(opts.DocumentID), "configured for append path")) + } + if opts.WikiNodeToken != "" { + targets++ + output.addCheck(passCheck("wiki node target", "FEISHU_WIKI_NODE_TOKEN", redactToken(opts.WikiNodeToken), "configured or parsed from wiki URL")) + } + if opts.FolderToken != "" { + targets++ + output.addCheck(passCheck("folder target", "FEISHU_FOLDER_TOKEN", redactToken(opts.FolderToken), "configured for create path")) + } + if opts.WikiURL != "" { + output.addCheck(passCheck("wiki url", "FEISHU_WIKI_URL", redactResourceURL(opts.WikiURL), "configured")) + } + if targets == 0 { + output.addCheck(failCheck("docx target", "DocX/Wiki", "missing", "no document, wiki, or folder target configured", "set FEISHU_DOCUMENT_ID, FEISHU_WIKI_URL, FEISHU_WIKI_NODE_TOKEN, or FEISHU_FOLDER_TOKEN")) + } + if output.Remote && output.Summary.Failed == 0 { + token, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret) + if ok && opts.WikiNodeToken != "" { + output.remoteWikiNode(token.Value, opts.WikiNodeToken) + } + if ok && opts.WikiNodeToken == "" { + output.addCheck(skipCheck("wiki node read", "Feishu Wiki", "skipped because no wiki node token was configured")) + } + if ok && opts.DocumentID != "" { + output.addCheck(skipCheck("document edit permission", "Feishu DocX", "not checked without writing blocks")) + } + if ok && opts.FolderToken != "" { + output.addCheck(skipCheck("folder create permission", "Feishu Drive", "not checked without creating a document")) + } + } else if output.Remote { + output.addCheck(skipCheck("remote docx check", "Feishu OpenAPI", "skipped because required config is incomplete")) + } + return finishDiagnostic(ctx, output) +} + +func runFeishuBitableCheck(ctx *common.RuntimeContext) error { + opts, err := bitableSyncOptionsFromContext(ctx) + if err != nil { + return err + } + output := DiagnosticOutput{ + Mode: "check", + Remote: parseBool(ctx.Arg("remote")), + Layer: "bitable", + Warnings: []string{ + "Bitable check does not create, update, or delete records.", + "Remote mode searches a sentinel unique_key to verify table access and the unique_key field.", + }, + } + output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for Base/Bitable OpenAPI")) + output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token")) + output.addCheck(requiredSecretCheck("base app token", "FEISHU_BASE_APP_TOKEN", opts.BaseAppToken, "required for Bitable tables")) + + for _, table := range opts.Tables { + envName := tableEnvName(table) + tableID := opts.TableIDs[table] + output.addCheck(requiredSecretCheck(table+" table id", envName, tableID, "required for "+table+" sync")) + fields := schemaForTable(table).Fields + output.addCheck(passCheck(table+" expected fields", table, strings.Join(fieldNames(fields), ","), "schema expected by +bitable-sync")) + } + if output.Remote && output.Summary.Failed == 0 { + token, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret) + if ok { + client := NewOpenAPIClient(http.DefaultClient) + checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for _, table := range opts.Tables { + _, err := client.SearchBitableRecord(checkCtx, token.Value, opts.BaseAppToken, opts.TableIDs[table], "__gitlink_cli_check__") + if err != nil { + output.addCheck(failCheck("remote bitable table search", table, redactToken(opts.TableIDs[table]), sanitizeDiagnostic(diagnoseOpenAPIError(err, "bitable", table), opts.AppID, opts.AppSecret, opts.BaseAppToken, opts.TableIDs[table]), "grant Base scopes, share the Base with the app, and ensure unique_key exists")) + continue + } + output.addCheck(passCheck("remote bitable table search", table, redactToken(opts.TableIDs[table]), "table accessible and unique_key search completed")) + } + } + } else if output.Remote { + output.addCheck(skipCheck("remote bitable check", "Feishu Base", "skipped because required config is incomplete")) + } + return finishDiagnostic(ctx, output) +} + +func runFeishuTaskCheck(ctx *common.RuntimeContext) error { + opts, err := taskCreateOptionsFromContext(&common.RuntimeContext{Args: map[string]string{ + "app-id": ctx.Arg("app-id"), + "app-secret": ctx.Arg("app-secret"), + "task-project-id": ctx.Arg("task-project-id"), + "task-section-id": ctx.Arg("task-section-id"), + }}) + if err != nil { + return err + } + output := DiagnosticOutput{ + Mode: "check", + Remote: parseBool(ctx.Arg("remote")), + Layer: "task", + Warnings: []string{ + "Task check does not create tasks.", + "Current +task-create only sends basic summary and description.", + }, + } + output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for Task OpenAPI")) + output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token")) + if opts.TaskProjectID == "" { + output.addCheck(warnCheck("task project id", "FEISHU_TASK_PROJECT_ID", "missing", "currently collected for future placement; not mapped into +task-create request body")) + } else { + output.addCheck(passCheck("task project id", "FEISHU_TASK_PROJECT_ID", redactToken(opts.TaskProjectID), "configured but not yet mapped into +task-create request body")) + } + if opts.TaskSectionID == "" { + output.addCheck(warnCheck("task section id", "FEISHU_TASK_SECTION_ID", "missing", "currently collected for future placement; not mapped into +task-create request body")) + } else { + output.addCheck(passCheck("task section id", "FEISHU_TASK_SECTION_ID", redactToken(opts.TaskSectionID), "configured but not yet mapped into +task-create request body")) + } + output.addCheck(warnCheck("task dedupe", "Feishu Task", "not implemented", "dedupe is local unique_key only; no Feishu-side task search/linking")) + if output.Remote && output.Summary.Failed == 0 { + if _, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret); ok { + output.addCheck(skipCheck("remote task create permission", "Feishu Task", "not checked without creating a task")) + } else { + output.addCheck(skipCheck("remote task create permission", "Feishu Task", "not checked because tenant_access_token acquisition failed")) + } + } else if output.Remote { + output.addCheck(skipCheck("remote task check", "Feishu Task", "skipped because app credentials are incomplete")) + } + return finishDiagnostic(ctx, output) +} + +func finishDiagnostic(ctx *common.RuntimeContext, output DiagnosticOutput) error { + if err := renderDiagnosticOutput(os.Stdout, output, formatOrDefault(ctx, "table")); err != nil { + return err + } + if output.Summary.Failed > 0 { + return fmt.Errorf("Feishu %s check failed: %d failed check(s)", output.Layer, output.Summary.Failed) + } + return nil +} + +func (o *DiagnosticOutput) remoteTenantToken(appID, appSecret string) (TenantToken, bool) { + client := NewOpenAPIClient(http.DefaultClient) + checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + token, err := client.TenantAccessToken(checkCtx, appID, appSecret) + if err != nil { + o.addCheck(failCheck("tenant_access_token", "Feishu OpenAPI", "failed", sanitizeDiagnostic(diagnoseOpenAPIError(err, "tenant token", "self-built app"), appID, appSecret), "verify app_id/app_secret and app availability")) + return TenantToken{}, false + } + o.addCheck(passCheck("tenant_access_token", "Feishu OpenAPI", fmt.Sprintf("expire=%d", token.Expire), "acquired")) + return token, true +} + +func (o *DiagnosticOutput) remoteWikiNode(token string, wikiNodeToken string) { + client := NewOpenAPIClient(http.DefaultClient) + checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + node, err := client.GetWikiNode(checkCtx, token, wikiNodeToken) + if err != nil { + o.addCheck(failCheck("wiki node read", "Feishu Wiki", redactToken(wikiNodeToken), sanitizeDiagnostic(diagnoseOpenAPIError(err, "docx", "wiki node"), wikiNodeToken, token), "grant Wiki/DocX scopes and make the target Wiki node visible to the app")) + return + } + detail := "obj_type=" + firstNonEmpty(node.ObjType, "unknown") + if node.Title != "" { + detail += "; title=" + node.Title + } + o.addCheck(passCheck("wiki node read", "Feishu Wiki", redactToken(wikiNodeToken), detail)) +} + +func (o *DiagnosticOutput) addCheck(check DiagnosticCheck) { + o.Checks = append(o.Checks, check) + switch check.Status { + case "pass": + o.Summary.Passed++ + case "warn": + o.Summary.Warned++ + case "fail": + o.Summary.Failed++ + case "skip": + o.Summary.Skipped++ + } +} + +func requiredSecretCheck(name, target, value, hint string) DiagnosticCheck { + if strings.TrimSpace(value) == "" { + return failCheck(name, target, "missing", "required value is not configured", hint) + } + return passCheck(name, target, redactToken(value), "configured") +} + +func passCheck(name, target, value, detail string) DiagnosticCheck { + return DiagnosticCheck{Name: name, Status: "pass", Target: target, Value: value, Detail: detail} +} + +func warnCheck(name, target, value, hint string) DiagnosticCheck { + return DiagnosticCheck{Name: name, Status: "warn", Target: target, Value: value, Hint: hint} +} + +func failCheck(name, target, value, detail, hint string) DiagnosticCheck { + return DiagnosticCheck{Name: name, Status: "fail", Required: true, Target: target, Value: value, Detail: detail, Hint: hint} +} + +func skipCheck(name, target, detail string) DiagnosticCheck { + return DiagnosticCheck{Name: name, Status: "skip", Target: target, Detail: detail} +} + +func renderDiagnosticOutput(w io.Writer, output DiagnosticOutput, format string) error { + switch normalizeFormat(format) { + case "markdown": + return writeDiagnosticMarkdown(w, output) + case "json": + return writeJSON(w, output) + default: + return writeDiagnosticTable(w, output) + } +} + +func writeDiagnosticTable(w io.Writer, output DiagnosticOutput) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintf(tw, "LAYER\tREMOTE\tPASS\tWARN\tFAIL\tSKIP\n%s\t%t\t%d\t%d\t%d\t%d\n\n", output.Layer, output.Remote, output.Summary.Passed, output.Summary.Warned, output.Summary.Failed, output.Summary.Skipped); err != nil { + return err + } + if _, err := fmt.Fprintln(tw, "CHECK\tSTATUS\tTARGET\tVALUE\tDETAIL\tHINT"); err != nil { + return err + } + for _, check := range output.Checks { + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", check.Name, check.Status, check.Target, check.Value, oneLine(check.Detail), oneLine(check.Hint)); err != nil { + return err + } + } + return tw.Flush() +} + +func writeDiagnosticMarkdown(w io.Writer, output DiagnosticOutput) error { + if _, err := fmt.Fprintf(w, "# Feishu %s Check\n\n", titleWord(output.Layer)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "- Remote: `%t`\n- Passed: `%d`\n- Warned: `%d`\n- Failed: `%d`\n- Skipped: `%d`\n\n", output.Remote, output.Summary.Passed, output.Summary.Warned, output.Summary.Failed, output.Summary.Skipped); err != nil { + return err + } + if _, err := fmt.Fprintln(w, "| Check | Status | Target | Value | Detail | Hint |\n| --- | --- | --- | --- | --- | --- |"); err != nil { + return err + } + for _, check := range output.Checks { + if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s | %s | %s |\n", check.Name, check.Status, check.Target, check.Value, oneLine(check.Detail), oneLine(check.Hint)); err != nil { + return err + } + } + return nil +} + +func tableEnvName(table string) string { + switch table { + case "reports": + return "FEISHU_REPORT_TABLE_ID" + case "issues": + return "FEISHU_ISSUE_TABLE_ID" + case "prs": + return "FEISHU_PR_TABLE_ID" + case "contributors": + return "FEISHU_CONTRIBUTOR_TABLE_ID" + case "tasks": + return "FEISHU_TASK_TABLE_ID" + default: + return "FEISHU_TABLE_ID" + } +} + +func fieldNames(fields []BitableField) []string { + names := make([]string, 0, len(fields)) + for _, field := range fields { + names = append(names, field.Name) + } + return names +} + +func sanitizeDiagnostic(message string, values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + message = strings.ReplaceAll(message, value, redactToken(value)) + } + return message +} + +func oneLine(value string) string { + return strings.Join(strings.Fields(value), " ") +} diff --git a/shortcuts/feishu/feishu.go b/shortcuts/feishu/feishu.go index 6ca5ad7..d5d4512 100644 --- a/shortcuts/feishu/feishu.go +++ b/shortcuts/feishu/feishu.go @@ -26,6 +26,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { newWeeklyReportShortcut(), newOwnerDigestShortcut(), newContributorDigestShortcut(), + newAppCheckShortcut(), + newDocCheckShortcut(), + newBitableCheckShortcut(), + newTaskCheckShortcut(), newDocExportShortcut(), newBitableSchemaShortcut(), newBitableRecordsShortcut(), @@ -35,6 +39,73 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } } +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", @@ -299,6 +370,22 @@ func runContributorDigest(ctx *common.RuntimeContext) error { 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 { diff --git a/shortcuts/feishu/feishu_test.go b/shortcuts/feishu/feishu_test.go index 666216a..d191b14 100644 --- a/shortcuts/feishu/feishu_test.go +++ b/shortcuts/feishu/feishu_test.go @@ -20,7 +20,7 @@ func TestShortcutsExposeExpectedCommands(t *testing.T) { for _, shortcut := range Shortcuts() { got[shortcut.Name] = true } - for _, name := range []string{"bot-test", "notify", "weekly-report", "owner-digest", "contributor-digest", "doc-export", "bitable-schema", "bitable-records", "bitable-sync", "task-preview", "task-create"} { + 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) } @@ -316,6 +316,103 @@ func TestBitableSyncMockHTTP(t *testing.T) { } } +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",