feat(feishu): add layered collaboration integration

This commit is contained in:
whzy 2026-06-26 15:53:47 +08:00
parent bcdab0b436
commit 8bcc8607f5
25 changed files with 2727 additions and 190 deletions

View File

@ -650,6 +650,42 @@ Safety:
- `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.
### Feishu Collaboration Export
`feishu` turns `workflow +repo-report` JSON into Feishu collaboration outputs.
Stable usage:
```bash
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --format json > report.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
```
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)
### Dataset
`dataset` manages and queries GitLink research datasets (title, description,

View File

@ -550,6 +550,42 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --ye
```
> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404待平台上线后即可生效。
### 飞书协作导出
`feishu``workflow +repo-report` JSON 转成飞书协作内容。
稳定用法:
```bash
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --format json > report.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
```
实验性开放平台用法:
```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 写操作。飞书卡片按钮仅用于跳转。开放平台能力必须显式传 `--send`,并要求自建应用具备对应资源权限。是否在正式部署中启用这些实验能力,由 GitLink 维护者和部署管理员决定。
详细文档:
- [飞书集成](./docs/feishu-integration.md)
- [飞书能力分层](./docs/FEISHU_CAPABILITY_LAYERS.md)
- [飞书环境变量](./docs/FEISHU_ENVIRONMENT.md)
- [飞书权限矩阵](./reports/FEISHU_PERMISSION_MATRIX.md)
### Raw API
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:

View File

@ -0,0 +1,202 @@
# 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 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.
```
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 +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.
Guarantee Bitable upsert if unique_key is missing from the target table.
Treat Open Platform writes as stable zero-config behavior.
```
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.
```

118
docs/FEISHU_ENVIRONMENT.md Normal file
View File

@ -0,0 +1,118 @@
# Feishu Environment Variables
Date: 2026-06-26
Do not commit real values. Use a local shell profile, CI secret store, or test terminal session.
## 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` | `+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 |
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-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 |
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"
```
## Base / Bitable Variables
| 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 |
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-create` | Yes | Feishu Task project settings / API |
| `FEISHU_TASK_SECTION_ID` | Optional task section target | Optional | `+task-create` | Yes | Feishu Task section settings / API |
Current limitation:
```text
The experimental task create command creates task candidates through the Task API.
Project/section placement may require additional Feishu Task identifiers and scopes.
If placement fails, record the Open Platform error in the smoke report.
```
## 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 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.
```

View File

@ -240,13 +240,19 @@ The app credentials and Wiki read path can work, but document writes still requi
### Current Bitable Usage
Current Bitable implementation is local-only.
Current Bitable implementation has two surfaces:
```text
stable dry-run records
experimental Open Platform sync
```
Implemented:
```text
feishu +bitable-schema
feishu +bitable-records
feishu +bitable-sync
```
Current tables:
@ -256,6 +262,7 @@ reports
issues
prs
contributors
tasks
```
Current records:
@ -264,21 +271,28 @@ Current records:
reports: one row per workflow report
issues: summary bucket rows
prs: summary bucket rows
contributors: reserved, empty unless workflow JSON contains contributor details
contributors: role-oriented contributor digest row
tasks: task candidates derived from workflow recommendations and risk buckets
```
Not implemented:
Experimental real-write behavior:
```text
Bitable record search by unique_key
Bitable record create
Bitable record update
create-only fallback when search fails
no record deletion
```
Still not implemented:
```text
real Bitable API write
create Base
create table
create field
create view
batch create records
update records
upsert records
search before update
Gantt / Kanban / Calendar / Gallery / Dashboard creation
field permission setup
```
@ -286,7 +300,7 @@ field permission setup
Conclusion:
```text
Current Bitable support is useful as schema and data-shape proof, but not yet useful as an actual project management cockpit.
Current Bitable support is useful as schema and data-shape proof. The experimental sync path can validate writes into an existing Base, but it does not yet create views or a full project management cockpit.
```
To support project management, the next Bitable model must become row-level instead of summary-only:
@ -842,8 +856,8 @@ No:
```text
no callback server
no GitLink write actions
no real Bitable writes
no task creation
no default real Bitable writes
no default task creation
no permission management
```
@ -1122,8 +1136,8 @@ audit log
### Avoid
```text
claiming real Bitable sync before API writes exist
claiming Feishu task creation before Task API exists in code
claiming stable real Bitable sync before more real-enterprise validation
claiming stable Feishu task creation before more real-enterprise validation
claiming card callback support before server and validation exist
claiming Feishu-triggered GitLink write actions before gateway exists
putting real secrets, IDs, table IDs, chat IDs, open IDs, or document tokens in repo

View File

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

42
docs/PR_VISUAL_GUIDE.md Normal file
View File

@ -0,0 +1,42 @@
# PR Visual Guide
Date: 2026-06-26
This file lists the manual screenshots to capture after local and real smoke testing.
Do not fabricate screenshots. If a capability is not available in the test enterprise, keep the placeholder and record the failure in `reports/FEISHU_SMOKE_20260626.md`.
| Screenshot | Expected path | Capture note |
| --- | --- | --- |
| Feishu bot card in test group | `docs/images/feishu-bot-card.png` | Capture after `+bot-test --send` or `+notify --send` |
| Weekly report card | `docs/images/feishu-weekly-report.png` | Capture after `+weekly-report --send` |
| Owner digest card | `docs/images/feishu-owner-digest.png` | Capture after `+owner-digest --send` |
| Contributor digest card | `docs/images/feishu-contributor-digest.png` | Capture after `+contributor-digest --send` |
| Bitable records preview | `docs/images/feishu-bitable-preview.png` | Capture terminal output or JSON preview |
| Bitable Base after sync | `docs/images/feishu-bitable-sync.png` | Capture only if real sync succeeds |
| DocX / Wiki report | `docs/images/feishu-docx-wiki.png` | Capture only if real document write succeeds |
| Feishu task list | `docs/images/feishu-task-create.png` | Capture only if real task creation succeeds |
| Terminal smoke test summary | `docs/images/feishu-smoke-terminal.png` | Redact IDs and tokens |
| Redacted env check | `docs/images/feishu-env-redacted.png` | Show presence/absence only |
Suggested capture commands:
```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
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format table
```
Manual redaction checklist:
```text
webhook URL
app secret
tenant_access_token
Base app token
table IDs
Wiki node token
folder token
GitLink token
open_id / union_id
```

View File

@ -1,14 +1,17 @@
# Feishu Bitable Dry-Run Schema
`gitlink-cli feishu` currently generates Bitable schema and records locally.
`gitlink-cli feishu` generates Bitable schema and records locally.
It does not call Feishu Bitable OpenAPI.
`+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
@ -20,6 +23,7 @@ issues
prs
contributors
reports
tasks
```
## Record Semantics
@ -34,35 +38,34 @@ Current behavior:
reports: one summary row per repo report
issues: summary buckets by issue type and priority
prs: summary buckets by change type and risk
contributors: reserved schema; records are empty unless workflow JSON later includes contributor details
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
Bitable OpenAPI create
Bitable OpenAPI batch create
Bitable update
Bitable upsert
pagination
batch create
Base creation
table creation
view creation
field creation
person/open_id mapping
```
Real Bitable writes require a separate design for:
```text
app authentication
table IDs
record unique keys
search-before-update
pagination
partial failure handling
rate limits
permission diagnostics
```
Real Bitable writes require existing Base app and table IDs. The target tables should include a text field named `unique_key`.

View File

@ -8,7 +8,9 @@ The stable command path is intentionally narrow:
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
@ -17,11 +19,22 @@ workflow JSON -> Bitable schema / records dry-run
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
```
`feishu +doc-export` exists as an experimental command. It uses Feishu self-built app OpenAPI and is not part of the clean first-path workflow.
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
@ -31,7 +44,10 @@ gitlink-cli feishu +bitable-records
- Webhook URLs are redacted in command output.
- Secrets and tokens are never intentionally printed.
- The stable commands do not write to GitLink resources.
- Bitable commands are dry-run only and do not call Bitable OpenAPI.
- `+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
@ -102,6 +118,24 @@ Send a weekly summary card:
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:
@ -116,8 +150,59 @@ Generate Bitable-ready records:
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:
@ -164,10 +249,7 @@ feishu-export-design/ROLE_BASED_COLLABORATION.md
Environment:
```powershell
$env:FEISHU_APP_ID="cli_xxx"
$env:FEISHU_APP_SECRET="..."
```
See `docs/FEISHU_ENVIRONMENT.md` for all Open Platform variables.
Preview:
@ -195,3 +277,14 @@ Required Feishu setup:
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
```

View File

@ -15,8 +15,15 @@ Supported environment variables:
```text
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET
FEISHU_APP_ID experimental doc-export only
FEISHU_APP_SECRET experimental doc-export only
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.
@ -31,6 +38,8 @@ The stable surface uses Feishu custom bot webhooks:
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.
@ -42,13 +51,22 @@ 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 Bitable records.
They do not call Feishu OpenAPI and cannot create, update, or upsert remote Feishu resources.
## Experimental Surface
`feishu +doc-export` is experimental. It uses:
These commands are experimental:
```text
feishu +doc-export
feishu +bitable-sync
feishu +task-create
```
They use:
```text
app_id
@ -56,9 +74,15 @@ app_secret
tenant_access_token
Wiki OpenAPI
DocX OpenAPI
Bitable OpenAPI
Task OpenAPI
```
It should not be treated as part of the stable clean export path. If used, grant the self-built app only the minimum required document permissions.
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
@ -70,6 +94,7 @@ GitLink remote writes
GitLink comments
Issue closure
merge actions
real Bitable writes
Feishu card callback execution
GitLink write actions from Feishu
```

1
docs/images/.gitkeep Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1,179 @@
# Feishu Local Testing Guide
Date: 2026-06-26
This guide verifies the layered Feishu integration without committing secrets.
## 1. Configure GitLink Test Repository
```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"
```
## 2. Generate Workflow Report JSON
```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.
## 3. Preview Feishu Notify Card
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --format json
```
## 4. 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
```
## 5. Render Weekly Report
```bash
gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
```
## 6. Send Weekly Report
```bash
gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table
```
## 7. Generate Owner Digest
```bash
gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown
```
## 8. Send Owner Digest
```bash
gitlink-cli feishu +owner-digest --from-workflow-json report.json --send --format table
```
## 9. Generate Contributor Digest
```bash
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown
```
## 10. Send Contributor Digest
```bash
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --send --format table
```
## 11. 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
```
## 12. Preview Bitable Sync
```bash
gitlink-cli feishu +bitable-sync \
--from-workflow-json report.json \
--tables reports,issues,prs,contributors,tasks \
--format table
```
## 13. 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
```
## 14. Preview DocX / Wiki Export
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "$FEISHU_WIKI_URL" \
--format markdown
```
## 15. Execute DocX / Wiki Export
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "$FEISHU_WIKI_URL" \
--send \
--format table
```
## 16. Preview Feishu Tasks
```bash
gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown
```
## 17. 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
```
## 18. Run Go Tests
```bash
gofmt -w shortcuts/feishu
go test ./shortcuts/feishu
go test ./shortcuts/workflow
go test ./shortcuts
go test ./...
```
## 19. Capture Evidence
Capture terminal logs and screenshots listed in `docs/PR_VISUAL_GUIDE.md`.
Do not capture raw secrets. Redact webhook URLs, app secrets, app tokens, table IDs, Wiki node tokens, folder tokens, GitLink tokens, tenant tokens, open IDs, and union IDs.

View File

@ -0,0 +1,20 @@
# 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; real if webhook env exists | 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 passed | 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 passed | Card is summary-level |
| Owner digest | `feishu +owner-digest` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit and preview passed | Role-oriented, not personalized |
| Contributor digest | `feishu +contributor-digest` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit and preview passed | Role-oriented, not open_id routed |
| 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 | Local candidates only |
| DocX / Wiki export | `feishu +doc-export` | Experimental Open Platform | No | Yes for `--send` | Yes | No | No | No | No | Mocked; real depends on env | mock passed | 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 | Mocked; real depends on env | mock passed | Requires existing tables and `unique_key` field |
| Task create | `feishu +task-create` | Experimental Open Platform | No | Yes for `--send` | No | No | Yes | No | No | Mocked; real depends on env | mock passed | Dedupe is local unique_key only |
| GitLink action gateway | not implemented | Future planning | No | Planned | No | No | No | Planned | Yes | No | not implemented | Requires official authorization model |

View File

@ -0,0 +1,138 @@
# Feishu Smoke Report
Date: 2026-06-26
## Branch
```text
feat/feishu-export-clean
```
## Commit
```text
working tree smoke before final implementation commit; base HEAD before this implementation: bcdab0b
```
## Test Environment
```text
Local OS: Windows / PowerShell
Repository: gitlink-cli-feishu-clean
Feishu test enterprise: available only when local environment variables are configured
Real GitLink repo data: public Gitlink/gitlink-cli workflow report generated through gitlink-cli
Previous 3 GitLink PR IDs: not available in current shell; GITLINK_TEST_PR_IDS was not set
```
## Redacted Environment Presence
This section must record presence only, not raw values:
| Variable | Present? |
| --- | --- |
| `FEISHU_WEBHOOK_URL` | missing |
| `FEISHU_WEBHOOK_SECRET` | missing |
| `FEISHU_APP_ID` | missing |
| `FEISHU_APP_SECRET` | missing |
| `FEISHU_WIKI_URL` | missing |
| `FEISHU_BASE_APP_TOKEN` | missing |
| `FEISHU_REPORT_TABLE_ID` | missing |
| `FEISHU_ISSUE_TABLE_ID` | missing |
| `FEISHU_PR_TABLE_ID` | missing |
| `FEISHU_TASK_PROJECT_ID` | missing |
| `GITLINK_OWNER` | missing |
| `GITLINK_REPO` | missing |
| `GITLINK_TEST_PR_IDS` | missing |
## Commands Run So Far
```bash
go run . feishu --help
go run . feishu +owner-digest --help
go run . feishu +bitable-sync --help
go run . feishu +task-create --help
go run . feishu +owner-digest --from-workflow-json shortcuts/workflow/testdata/repo_report.json --format table
go run . feishu +contributor-digest --from-workflow-json shortcuts/workflow/testdata/repo_report.json --format table
go run . feishu +bitable-records --from-workflow-json shortcuts/workflow/testdata/repo_report.json --tables reports,issues,prs,contributors,tasks --format table
go run . feishu +bitable-sync --from-workflow-json shortcuts/workflow/testdata/repo_report.json --tables reports,tasks --format table
go run . feishu +task-preview --from-workflow-json shortcuts/workflow/testdata/repo_report.json --format table
$report = Join-Path $env:TEMP 'gitlink-feishu-report-20260626.json'
go run . workflow +repo-report --owner Gitlink --repo gitlink-cli --format json | Set-Content -Encoding utf8 $report
go run . feishu +notify --from-workflow-json $report --format table
go run . feishu +owner-digest --from-workflow-json $report --format table
go run . feishu +contributor-digest --from-workflow-json $report --format table
go run . feishu +bitable-records --from-workflow-json $report --tables reports,issues,prs,contributors,tasks --format table
go run . feishu +task-preview --from-workflow-json $report --format table
go run . feishu +bitable-sync --from-workflow-json $report --tables reports,issues,prs,tasks --format table
go run . feishu +doc-export --from-workflow-json $report --format table
gofmt -w shortcuts/feishu
go test ./shortcuts/feishu
go test ./shortcuts/workflow
go test ./shortcuts
go test ./...
```
## Outputs Summary
| Step | Result | Notes |
| --- | --- | --- |
| `feishu --help` | pass | new owner/contributor digest, bitable sync, task preview/create commands visible |
| owner digest preview | pass | role summary generated |
| contributor digest preview | pass | role summary generated |
| bitable records preview | pass | reports/issues/prs/contributors/tasks generated |
| bitable sync preview | pass | preview only, no OpenAPI call |
| task preview | pass | task candidates generated |
| Feishu unit/mock tests | pass | `go test ./shortcuts/feishu` |
| workflow tests | pass | `go test ./shortcuts/workflow` |
| shortcuts tests | pass | `go test ./shortcuts` |
| full repository tests | pass | `go test ./...` |
| public GitLink repo report | pass | `Gitlink/gitlink-cli` report generated in temp directory |
| public GitLink notify preview | pass | preview only, no webhook call |
| public GitLink owner digest | pass | risk/score summary generated |
| public GitLink contributor digest | pass | role-oriented summary generated |
| public GitLink Bitable records | pass | reports/issues/prs/contributors/tasks generated |
| public GitLink Bitable sync preview | pass | preview only, table IDs missing by design |
| public GitLink DocX/Wiki preview | pass | preview only, no Open Platform call |
## Real Feishu Webhook Result
```text
not executed: FEISHU_WEBHOOK_URL was not present in the current shell.
```
## DocX / Wiki Result
```text
not executed: FEISHU_APP_ID, FEISHU_APP_SECRET, and document target variables were not present in the current shell.
Preview passed with public GitLink report.
```
## Bitable Sync Result
```text
not executed: FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_BASE_APP_TOKEN, and table IDs were not present in the current shell.
Preview passed with public GitLink report.
```
## Task Creation Result
```text
not executed: FEISHU_APP_ID and FEISHU_APP_SECRET were not present in the current shell.
Task preview passed with public GitLink report.
```
## Failure Diagnostics
```text
None from local preview, public GitLink read smoke, and unit/mock tests.
Real Open Platform failures must be recorded with endpoint category, HTTP status or Feishu code when available, redacted target type, likely reason, and required permission.
```
## Screenshots Or Terminal Logs
Expected screenshot paths are listed in `docs/PR_VISUAL_GUIDE.md`.
Do not fabricate screenshots.

View File

@ -12,8 +12,14 @@ feat/feishu-export-clean
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
@ -29,6 +35,8 @@ gitlink-cli feishu +bitable-records
- 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.
@ -37,6 +45,10 @@ gitlink-cli feishu +bitable-records
- 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.
@ -56,6 +68,8 @@ 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.
@ -95,6 +109,13 @@ The app credentials are valid and the Wiki node is readable, but the app does no
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:
@ -114,13 +135,13 @@ After scope review, DocX / Wiki export is explicitly experimental and not part o
Stable path:
```text
workflow JSON -> bot card / weekly report / Bitable dry-run records
workflow JSON -> bot card / weekly report / owner digest / contributor digest / Bitable dry-run records / task preview
```
Experimental path:
```text
workflow JSON -> DocX/Wiki export through self-built app OpenAPI
workflow JSON -> DocX/Wiki export / Bitable sync / Task create through self-built app OpenAPI
```
## Tests
@ -145,7 +166,6 @@ passed
```text
BotBuilder integration
Feishu Robot Assistant workflows
Feishu task creation
Feishu approval creation
callback server
button callbacks
@ -154,14 +174,11 @@ GitLink comments
Issue closure
code merge actions
direct GitLink webhook creation
real Bitable OpenAPI writes
Bitable create/update/upsert
Feishu Base/table/view creation
document permission modification
DocX content write
```
Note: experimental `doc-export` can attempt DocX block writes when explicitly invoked with `--send`, but it remains outside the stable clean export path.
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

View File

@ -6,6 +6,7 @@ import (
"sort"
"strings"
"text/tabwriter"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
@ -28,14 +29,16 @@ type BitableField struct {
}
type BitableRecords struct {
DryRun bool `json:"dry_run"`
Tables map[string][]BitableRecord `json:"tables"`
Schema []BitableTableSchema `json:"schema"`
Notes []string `json:"notes,omitempty"`
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 {
Fields map[string]interface{} `json:"fields"`
UniqueKey string `json:"unique_key"`
Fields map[string]interface{} `json:"fields"`
}
func BuildBitableSchema(tables []string) BitableSchema {
@ -46,27 +49,31 @@ func BuildBitableSchema(tables []string) BitableSchema {
return result
}
func BuildBitableRecords(report workflow.RepoReportResult, tables []string) BitableRecords {
func BuildBitableRecords(report workflow.RepoReportResult, tables []string, docURL string) BitableRecords {
tables = normalizeTables(tables)
result := BitableRecords{
DryRun: true,
Tables: map[string][]BitableRecord{},
Schema: BuildBitableSchema(tables).Tables,
DryRun: true,
Repository: report.Repository,
Tables: map[string][]BitableRecord{},
Schema: BuildBitableSchema(tables).Tables,
Notes: []string{
"Dry-run only: this command does not call Feishu Bitable OpenAPI.",
"Use these records to validate table shape before adding app authentication and upsert behavior.",
"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)
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] = []BitableRecord{}
result.Tables[table] = contributorRecords(report)
case "tasks":
result.Tables[table] = taskRecords(report, docURL)
}
}
return result
@ -76,7 +83,7 @@ func normalizeTables(tables []string) []string {
if len(tables) == 0 {
tables = parseList(defaultTables)
}
allowed := map[string]bool{"issues": true, "prs": true, "contributors": true, "reports": true}
allowed := map[string]bool{"reports": true, "issues": true, "prs": true, "contributors": true, "tasks": true}
seen := map[string]bool{}
result := []string{}
for _, table := range tables {
@ -97,146 +104,272 @@ func schemaForTable(table string) BitableTableSchema {
case "issues":
return BitableTableSchema{
Name: "issues",
Description: "Issue summary buckets from workflow repo report output.",
Fields: []BitableField{
{Name: "repository", Type: "text"},
{Name: "bucket_type", Type: "single_select", Description: "type or priority"},
{Name: "bucket", Type: "text"},
{Name: "count", Type: "number"},
{Name: "high_risk_total", Type: "number"},
{Name: "missing_info_total", Type: "number"},
},
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 buckets from workflow repo report output.",
Fields: []BitableField{
{Name: "repository", Type: "text"},
{Name: "bucket_type", Type: "single_select", Description: "change_type or risk"},
{Name: "bucket", Type: "text"},
{Name: "count", Type: "number"},
{Name: "high_risk_total", Type: "number"},
{Name: "review_focus", Type: "multi_text"},
},
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: "Reserved table for contributor activity once workflow JSON includes contributor data.",
Fields: []BitableField{
{Name: "repository", Type: "text"},
{Name: "login", Type: "text"},
{Name: "role", Type: "single_select"},
{Name: "activity_count", Type: "number"},
},
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: []BitableField{
{Name: "repository", Type: "text"},
{Name: "report_score", Type: "number"},
{Name: "risk_level", Type: "single_select"},
{Name: "health_score", Type: "number"},
{Name: "issues_total", Type: "number"},
{Name: "high_risk_issues", Type: "number"},
{Name: "prs_total", Type: "number"},
{Name: "high_risk_prs", Type: "number"},
{Name: "source", Type: "text"},
{Name: "recommendations", Type: "multi_text"},
},
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 reportRecords(report workflow.RepoReportResult) []BitableRecord {
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
}
return []BitableRecord{{
Fields: map[string]interface{}{
"repository": report.Repository,
"report_score": report.ReportScore,
"risk_level": report.RiskLevel,
"health_score": healthScore,
"issues_total": report.IssueSummary.Total,
"high_risk_issues": report.IssueSummary.HighRisk,
"prs_total": report.PRSummary.Total,
"high_risk_prs": report.PRSummary.HighRisk,
"source": report.Source,
"recommendations": report.Recommendations,
},
}}
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{}
records = appendCountMapRecords(records, report.Repository, "type", report.IssueSummary.ByType, map[string]interface{}{
"high_risk_total": report.IssueSummary.HighRisk,
"missing_info_total": report.IssueSummary.MissingInfo,
})
records = appendCountMapRecords(records, report.Repository, "priority", report.IssueSummary.ByPriority, map[string]interface{}{
"high_risk_total": report.IssueSummary.HighRisk,
"missing_info_total": report.IssueSummary.MissingInfo,
})
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 {
records = append(records, BitableRecord{Fields: map[string]interface{}{
"repository": report.Repository,
"bucket_type": "summary",
"bucket": "total",
"count": report.IssueSummary.Total,
"high_risk_total": report.IssueSummary.HighRisk,
"missing_info_total": report.IssueSummary.MissingInfo,
}})
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{}
records = appendCountMapRecords(records, report.Repository, "change_type", report.PRSummary.ByType, map[string]interface{}{
"high_risk_total": report.PRSummary.HighRisk,
"review_focus": report.PRSummary.ReviewFocus,
})
records = appendCountMapRecords(records, report.Repository, "risk", report.PRSummary.ByRisk, map[string]interface{}{
"high_risk_total": report.PRSummary.HighRisk,
"review_focus": report.PRSummary.ReviewFocus,
})
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 {
records = append(records, BitableRecord{Fields: map[string]interface{}{
"repository": report.Repository,
"bucket_type": "summary",
"bucket": "total",
"count": report.PRSummary.Total,
"high_risk_total": report.PRSummary.HighRisk,
"review_focus": report.PRSummary.ReviewFocus,
}})
fields := prRecordFields(report, "summary", "total", report.PRSummary.Total)
records = append(records, BitableRecord{UniqueKey: fields["unique_key"].(string), Fields: fields})
}
return records
}
func appendCountMapRecords(records []BitableRecord, repository string, bucketType string, values map[string]int, extras map[string]interface{}) []BitableRecord {
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)
for _, key := range keys {
fields := map[string]interface{}{
"repository": repository,
"bucket_type": bucketType,
"bucket": key,
"count": values[key],
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"
}
for extraKey, extraValue := range extras {
fields[extraKey] = extraValue
}
records = append(records, BitableRecord{Fields: fields})
cleaned = append(cleaned, part)
}
return records
return strings.Join(cleaned, ":")
}
func renderBitableSchema(w io.Writer, schema BitableSchema, format string) error {
@ -311,11 +444,7 @@ func writeRecordsMarkdown(w io.Writer, records BitableRecords) error {
if _, err := fmt.Fprintln(w); err != nil {
return err
}
tableNames := make([]string, 0, len(records.Tables))
for table := range records.Tables {
tableNames = append(tableNames, table)
}
sort.Strings(tableNames)
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 {
@ -330,12 +459,7 @@ func writeRecordsTable(w io.Writer, records BitableRecords) error {
if _, err := fmt.Fprintln(tw, "TABLE\tRECORDS"); err != nil {
return err
}
tableNames := make([]string, 0, len(records.Tables))
for table := range records.Tables {
tableNames = append(tableNames, table)
}
sort.Strings(tableNames)
for _, table := range tableNames {
for _, table := range sortedTableNames(records.Tables) {
if _, err := fmt.Fprintf(tw, "%s\t%d\n", table, len(records.Tables[table])); err != nil {
return err
}
@ -343,6 +467,11 @@ func writeRecordsTable(w io.Writer, records BitableRecords) error {
return tw.Flush()
}
func joinStrings(values []string) string {
return strings.Join(values, ", ")
func sortedTableNames(records map[string][]BitableRecord) []string {
tableNames := make([]string, 0, len(records))
for table := range records {
tableNames = append(tableNames, table)
}
sort.Strings(tableNames)
return tableNames
}

View File

@ -0,0 +1,226 @@
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}
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, record.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, record.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, record.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 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()
}

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

@ -0,0 +1,292 @@
package feishu
import (
"fmt"
"io"
"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"`
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,
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,
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, _ string) Card {
return buildDigestCard(digest, firstNonEmpty(title, "GitLink owner digest: "+digest.Repository), "owner")
}
func BuildContributorDigestCard(digest RoleDigest, title string, _ string) Card {
return buildDigestCard(digest, firstNonEmpty(title, "GitLink contributor digest: "+digest.Repository), "contributor")
}
func buildDigestCard(digest RoleDigest, title string, role string) Card {
elements := []interface{}{
div(fmt.Sprintf("**Repository**\n%s", escapeMD(digest.Repository))),
fields([]fieldValue{
{Label: "Report score", Value: fmt.Sprintf("%d", digest.ReportScore)},
{Label: "Risk level", Value: digest.RiskLevel},
{Label: "Issues", Value: fmt.Sprintf("%d", digest.IssueTotal)},
{Label: "Pull requests", Value: fmt.Sprintf("%d", digest.PRTotal)},
}),
fields([]fieldValue{
{Label: "High-risk issues", Value: fmt.Sprintf("%d", digest.IssueHighRisk)},
{Label: "Missing-info issues", Value: fmt.Sprintf("%d", digest.IssueMissingInfo)},
{Label: "High-risk PRs", Value: fmt.Sprintf("%d", digest.PRHighRisk)},
{Label: "Review focus", Value: fmt.Sprintf("%d", len(digest.ReviewFocus))},
}),
}
if digest.HealthScore != nil {
elements = append(elements, fields([]fieldValue{
{Label: "Health score", Value: fmt.Sprintf("%d", *digest.HealthScore)},
{Label: "Health risk", Value: digest.HealthRisk},
}))
}
if len(digest.AttentionItems) > 0 {
elements = append(elements, div("**Attention**\n"+bulletList(digest.AttentionItems, 5)))
}
if len(digest.NextSteps) > 0 {
elements = append(elements, div("**Suggested next steps**\n"+bulletList(digest.NextSteps, 5)))
}
if digest.RepositoryURL != "" {
elements = append(elements, actionButton("Open GitLink repository", digest.RepositoryURL))
}
if digest.DocURL != "" {
elements = append(elements, actionButton("Open Feishu report", digest.DocURL))
}
elements = append(elements, note(digest.BoundaryDescription))
template := templateForRisk(digest.RiskLevel)
if role == "contributor" && digest.PRSummaryNeedsAttention() {
template = "yellow"
}
return baseCard(title, template, elements)
}
func (d RoleDigest) PRSummaryNeedsAttention() bool {
return d.PRHighRisk > 0 || len(d.ReviewFocus) > 0
}
func renderDigest(w io.Writer, digest RoleDigest, format string) error {
switch normalizeFormat(format) {
case "markdown":
return writeDigestMarkdown(w, digest)
case "table":
return writeDigestTable(w, digest)
default:
return writeJSON(w, digest)
}
}
func writeDigestMarkdown(w io.Writer, digest RoleDigest) error {
if _, err := fmt.Fprintf(w, "# GitLink %s digest: %s\n\n", digest.Role, digest.Repository); err != nil {
return err
}
lines := []string{
fmt.Sprintf("- Report score: `%d`", digest.ReportScore),
fmt.Sprintf("- Risk level: `%s`", firstNonEmpty(digest.RiskLevel, "unknown")),
fmt.Sprintf("- Issues: `%d` total, `%d` high risk, `%d` missing info", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo),
fmt.Sprintf("- Pull requests: `%d` total, `%d` high risk", digest.PRTotal, digest.PRHighRisk),
}
if digest.HealthScore != nil {
lines = append(lines, fmt.Sprintf("- Health score: `%d`; health risk: `%s`", *digest.HealthScore, firstNonEmpty(digest.HealthRisk, "unknown")))
}
if digest.RepositoryURL != "" {
lines = append(lines, "- GitLink repository: "+digest.RepositoryURL)
}
if digest.DocURL != "" {
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 {
if _, err := fmt.Fprint(w, "\n## Attention\n\n"+bulletList(digest.AttentionItems, 8)+"\n"); err != nil {
return err
}
}
if len(digest.NextSteps) > 0 {
if _, err := fmt.Fprint(w, "\n## Suggested next steps\n\n"+bulletList(digest.NextSteps, 8)+"\n"); err != nil {
return err
}
}
_, err := fmt.Fprintf(w, "\n> %s\n", digest.BoundaryDescription)
return err
}
func writeDigestTable(w io.Writer, digest RoleDigest) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "ROLE\tREPOSITORY\tRISK\tSCORE\tISSUES\tHIGH_RISK_ISSUES\tPRS\tHIGH_RISK_PRS\tATTENTION"); err != nil {
return err
}
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\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,
len(digest.AttentionItems),
); err != nil {
return err
}
return tw.Flush()
}
func digestHealth(report workflow.RepoReportResult) (*int, string) {
if report.Health == nil {
return nil, ""
}
score := report.Health.HealthScore
return &score, report.Health.RiskLevel
}
func gitlinkRepoURL(repository string) string {
repository = strings.Trim(strings.TrimSpace(repository), "/")
if repository == "" || repository == "local" {
return ""
}
if strings.Contains(repository, "://") {
return repository
}
if !strings.Contains(repository, "/") {
return ""
}
return "https://www.gitlink.org.cn/" + repository
}
func limitStrings(values []string, limit int) []string {
if limit <= 0 || len(values) <= limit {
return values
}
return values[:limit]
}
func uniqueDigestStrings(values []string) []string {
seen := map[string]bool{}
result := []string{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}

View File

@ -40,6 +40,7 @@ type DocExportOutput struct {
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 {
@ -54,7 +55,7 @@ func docExportOptionsFromContext(ctx *common.RuntimeContext) (DocExportOptions,
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_DOC_FOLDER_TOKEN")),
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")),
@ -99,13 +100,13 @@ func exportDocOrPreview(ctx *common.RuntimeContext, opts DocExportOptions, repor
TargetType: docTargetType(opts),
Operation: docOperation(opts),
Title: title,
DocumentID: opts.DocumentID,
DocumentURL: firstNonEmpty(opts.WikiURL),
DocumentID: redactToken(opts.DocumentID),
DocumentURL: redactResourceURL(firstNonEmpty(opts.WikiURL)),
BlockCount: len(blocks),
Preview: markdown,
}
if opts.WikiNodeToken != "" {
output.WikiNodeToken = opts.WikiNodeToken
output.WikiNodeToken = redactToken(opts.WikiNodeToken)
}
if !opts.Send {
return renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "markdown"))
@ -114,6 +115,8 @@ func exportDocOrPreview(ctx *common.RuntimeContext, opts DocExportOptions, repor
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
@ -122,36 +125,45 @@ func exportDocOrPreview(ctx *common.RuntimeContext, opts DocExportOptions, repor
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 = documentID
output.DocumentID = redactToken(documentID)
output.WikiNode = &WikiNodeSummary{
NodeType: node.NodeType,
ObjType: node.ObjType,
Title: node.Title,
}
if output.DocumentURL == "" {
output.DocumentURL = node.URL
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 = created.DocumentID
output.DocumentURL = created.URL
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 {
@ -239,6 +251,11 @@ func writeDocExportMarkdown(w io.Writer, output DocExportOutput) error {
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

View File

@ -13,7 +13,7 @@ import (
const (
defaultInclude = "issues,prs,contributors,health"
defaultTables = "issues,prs,contributors,reports"
defaultTables = "reports,issues,prs,contributors,tasks"
defaultLang = "en"
)
@ -24,9 +24,14 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
newBotTestShortcut(),
newNotifyShortcut(),
newWeeklyReportShortcut(),
newOwnerDigestShortcut(),
newContributorDigestShortcut(),
newDocExportShortcut(),
newBitableSchemaShortcut(),
newBitableRecordsShortcut(),
newBitableSyncShortcut(),
newTaskPreviewShortcut(),
newTaskCreateShortcut(),
}
}
@ -73,6 +78,34 @@ func newWeeklyReportShortcut() *common.Shortcut {
}
}
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",
@ -99,7 +132,7 @@ func newBitableSchemaShortcut() *common.Shortcut {
Name: "bitable-schema",
Description: "Generate a dry-run Feishu Bitable schema",
Flags: []common.Flag{
{Name: "tables", Usage: "Comma-separated tables: issues,prs,contributors,reports", Default: defaultTables},
{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,
@ -112,13 +145,70 @@ func newBitableRecordsShortcut() *common.Shortcut {
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: issues,prs,contributors,reports", Default: defaultTables},
{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"},
@ -175,6 +265,40 @@ func runWeeklyReport(ctx *common.RuntimeContext) error {
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"))
}
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"))
}
func runDocExport(ctx *common.RuntimeContext) error {
opts, err := docExportOptionsFromContext(ctx)
if err != nil {
@ -197,10 +321,45 @@ func runBitableRecords(ctx *common.RuntimeContext) error {
if err != nil {
return err
}
records := BuildBitableRecords(report, parseList(firstNonEmpty(ctx.Arg("tables"), defaultTables)))
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 := BuildTaskCandidates(report, ctx.Arg("doc-url"))
return renderTaskOutput(os.Stdout, TaskOutput{Mode: "preview", DryRun: true, Tasks: 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 := BuildTaskCandidates(report, ctx.Arg("doc-url"))
return createTasksOrPreview(ctx, opts, tasks)
}
func normalizeLang(lang string) string {
switch strings.TrimSpace(lang) {
case "zh-CN":

View File

@ -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", "doc-export", "bitable-schema", "bitable-records"} {
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"} {
if !got[name] {
t.Fatalf("Shortcuts missing %s", name)
}
@ -54,6 +54,16 @@ func TestRedactWebhookURL(t *testing.T) {
}
}
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")
@ -155,13 +165,183 @@ func TestBitableSchemaAndRecords(t *testing.T) {
if len(schema.Tables) != 3 {
t.Fatalf("schema table count = %d", len(schema.Tables))
}
records := BuildBitableRecords(report, parseList("issues,prs,reports"))
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)
}
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))
}
}
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 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 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 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 TestWikiNodeTokenFromURL(t *testing.T) {

View File

@ -46,6 +46,21 @@ 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
@ -185,6 +200,156 @@ func (c OpenAPIClient) CreateBlocks(ctx context.Context, tenantToken string, doc
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 == "" {
@ -234,9 +399,44 @@ func redactOpenAPIPath(path string) 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)
}

View File

@ -71,6 +71,39 @@ func redactWebhookURL(raw string) string {
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

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

@ -0,0 +1,313 @@
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 {
tasks := []TaskCandidate{}
repoURL := gitlinkRepoURL(report.Repository)
for i, recommendation := range report.Recommendations {
title := firstNonEmpty(recommendation, "Review workflow recommendation")
tasks = append(tasks, TaskCandidate{
UniqueKey: stableKey("task", report.Repository, "recommendation", fmt.Sprintf("%d", i+1)),
Title: title,
Description: "Workflow recommendation from gitlink-cli repo report.",
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: fmt.Sprintf("Triage %d high-risk GitLink issues", report.IssueSummary.HighRisk),
Description: "High-risk issue bucket from workflow report. Review GitLink issues before routine work.",
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: fmt.Sprintf("Request missing information for %d issues", report.IssueSummary.MissingInfo),
Description: "Some issues need reproduction steps, logs, version details, or command output.",
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: fmt.Sprintf("Review %d high-risk pull requests", report.PRSummary.HighRisk),
Description: "High-risk PR bucket from workflow report. Check review focus and merge readiness.",
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: "Review PR focus areas",
Description: strings.Join(limitStrings(report.PRSummary.ReviewFocus, 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: "Review GitLink workflow report",
Description: "No high-risk task candidates were detected. Keep a regular owner review cadence.",
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 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 _, err := fmt.Fprintln(tw, "KEY\tPRIORITY\tSOURCE\tTITLE"); err != nil {
return err
}
for _, task := range output.Tasks {
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s/%s\t%s\n", task.UniqueKey, task.Priority, task.SourceType, task.SourceKey, task.Title); err != nil {
return err
}
}
return tw.Flush()
}
func titleWord(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return strings.ToUpper(value[:1]) + value[1:]
}
func priorityForRisk(risk string) string {
switch strings.ToLower(strings.TrimSpace(risk)) {
case "critical", "high":
return "high"
case "medium":
return "medium"
default:
return "low"
}
}
func appendPath(base string, path string) string {
if strings.TrimSpace(base) == "" {
return ""
}
return strings.TrimRight(base, "/") + "/" + strings.Trim(path, "/")
}
func dedupeTasks(tasks []TaskCandidate) []TaskCandidate {
seen := map[string]bool{}
result := []TaskCandidate{}
for _, task := range tasks {
if task.UniqueKey == "" || seen[task.UniqueKey] {
continue
}
seen[task.UniqueKey] = true
result = append(result, task)
}
return result
}

View File

@ -1,7 +1,7 @@
---
name: gitlink-feishu
version: 1.0.0
description: "Export GitLink workflow JSON to Feishu custom bot cards, weekly reports, and Bitable-ready dry-run records."
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"]
@ -18,12 +18,13 @@ 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
workflow JSON -> Feishu DocX / Wiki export / Bitable sync / Task create
```
## Inputs
@ -41,8 +42,9 @@ gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json >
- 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` and `+bitable-records` as local dry-run commands only.
- 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
@ -58,6 +60,13 @@ Render a weekly report:
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
@ -70,6 +79,12 @@ Generate Bitable-ready records:
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:
@ -91,6 +106,13 @@ Send a weekly report card:
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:
@ -114,11 +136,41 @@ gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wik
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.