feat(feishu): add layered collaboration integration
This commit is contained in:
parent
bcdab0b436
commit
8bcc8607f5
36
README.md
36
README.md
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 直接调用:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
```
|
||||
|
|
@ -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.
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
# PR Review Notes Draft
|
||||
|
||||
This file will be finalized after implementation and smoke testing.
|
||||
|
||||
Reviewer questions to be filled:
|
||||
|
||||
- Should webhook export remain the stable main path?
|
||||
- Should DocX/Wiki write remain experimental?
|
||||
- Should Bitable sync enter the stable surface after more validation?
|
||||
- Should Feishu Task creation belong in gitlink-cli?
|
||||
- What official GitLink authorization model should be used for future GitLink write actions?
|
||||
- Should future Feishu card callbacks be implemented in gitlink-cli or a separate service?
|
||||
|
|
@ -0,0 +1,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
|
||||
```
|
||||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue