diff --git a/README.md b/README.md index e5e4318..e967337 100644 --- a/README.md +++ b/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, diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..46f95f2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -550,6 +550,42 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --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 直接调用: diff --git a/docs/FEISHU_CAPABILITY_LAYERS.md b/docs/FEISHU_CAPABILITY_LAYERS.md new file mode 100644 index 0000000..d5a513f --- /dev/null +++ b/docs/FEISHU_CAPABILITY_LAYERS.md @@ -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. +``` diff --git a/docs/FEISHU_ENVIRONMENT.md b/docs/FEISHU_ENVIRONMENT.md new file mode 100644 index 0000000..71ecabc --- /dev/null +++ b/docs/FEISHU_ENVIRONMENT.md @@ -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. +``` diff --git a/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md b/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md index d26508d..ea9feb9 100644 --- a/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md +++ b/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md @@ -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 diff --git a/docs/PR_REVIEW_NOTES_DRAFT.md b/docs/PR_REVIEW_NOTES_DRAFT.md new file mode 100644 index 0000000..7f4dbe5 --- /dev/null +++ b/docs/PR_REVIEW_NOTES_DRAFT.md @@ -0,0 +1,12 @@ +# PR Review Notes Draft + +This file will be finalized after implementation and smoke testing. + +Reviewer questions to be filled: + +- Should webhook export remain the stable main path? +- Should DocX/Wiki write remain experimental? +- Should Bitable sync enter the stable surface after more validation? +- Should Feishu Task creation belong in gitlink-cli? +- What official GitLink authorization model should be used for future GitLink write actions? +- Should future Feishu card callbacks be implemented in gitlink-cli or a separate service? diff --git a/docs/PR_VISUAL_GUIDE.md b/docs/PR_VISUAL_GUIDE.md new file mode 100644 index 0000000..3d09734 --- /dev/null +++ b/docs/PR_VISUAL_GUIDE.md @@ -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 +``` diff --git a/docs/feishu-bitable-schema.md b/docs/feishu-bitable-schema.md index 29584c3..da58060 100644 --- a/docs/feishu-bitable-schema.md +++ b/docs/feishu-bitable-schema.md @@ -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`. + diff --git a/docs/feishu-integration.md b/docs/feishu-integration.md index 58d537d..2114dee 100644 --- a/docs/feishu-integration.md +++ b/docs/feishu-integration.md @@ -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 +``` diff --git a/docs/feishu-security.md b/docs/feishu-security.md index 6ac40f5..92fe94f 100644 --- a/docs/feishu-security.md +++ b/docs/feishu-security.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 ``` diff --git a/docs/images/.gitkeep b/docs/images/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/images/.gitkeep @@ -0,0 +1 @@ + diff --git a/reports/FEISHU_LOCAL_TESTING_GUIDE.md b/reports/FEISHU_LOCAL_TESTING_GUIDE.md new file mode 100644 index 0000000..40d739b --- /dev/null +++ b/reports/FEISHU_LOCAL_TESTING_GUIDE.md @@ -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. diff --git a/reports/FEISHU_PERMISSION_MATRIX.md b/reports/FEISHU_PERMISSION_MATRIX.md new file mode 100644 index 0000000..2671e4b --- /dev/null +++ b/reports/FEISHU_PERMISSION_MATRIX.md @@ -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 | diff --git a/reports/FEISHU_SMOKE_20260626.md b/reports/FEISHU_SMOKE_20260626.md new file mode 100644 index 0000000..ab760e1 --- /dev/null +++ b/reports/FEISHU_SMOKE_20260626.md @@ -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. diff --git a/reports/FEISHU_TASK_COMPLETION.md b/reports/FEISHU_TASK_COMPLETION.md index 8c46bb9..449e3b3 100644 --- a/reports/FEISHU_TASK_COMPLETION.md +++ b/reports/FEISHU_TASK_COMPLETION.md @@ -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 diff --git a/shortcuts/feishu/bitable.go b/shortcuts/feishu/bitable.go index 3370395..b93bce8 100644 --- a/shortcuts/feishu/bitable.go +++ b/shortcuts/feishu/bitable.go @@ -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 } diff --git a/shortcuts/feishu/bitable_sync.go b/shortcuts/feishu/bitable_sync.go new file mode 100644 index 0000000..19bf2f5 --- /dev/null +++ b/shortcuts/feishu/bitable_sync.go @@ -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() +} diff --git a/shortcuts/feishu/digest.go b/shortcuts/feishu/digest.go new file mode 100644 index 0000000..a937bc2 --- /dev/null +++ b/shortcuts/feishu/digest.go @@ -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 +} diff --git a/shortcuts/feishu/doc_export.go b/shortcuts/feishu/doc_export.go index afba168..bb71cbd 100644 --- a/shortcuts/feishu/doc_export.go +++ b/shortcuts/feishu/doc_export.go @@ -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 diff --git a/shortcuts/feishu/feishu.go b/shortcuts/feishu/feishu.go index 4b242b9..413e47f 100644 --- a/shortcuts/feishu/feishu.go +++ b/shortcuts/feishu/feishu.go @@ -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": diff --git a/shortcuts/feishu/feishu_test.go b/shortcuts/feishu/feishu_test.go index 8e8829b..ae69dfb 100644 --- a/shortcuts/feishu/feishu_test.go +++ b/shortcuts/feishu/feishu_test.go @@ -20,7 +20,7 @@ func TestShortcutsExposeExpectedCommands(t *testing.T) { for _, shortcut := range Shortcuts() { got[shortcut.Name] = true } - for _, name := range []string{"bot-test", "notify", "weekly-report", "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) { diff --git a/shortcuts/feishu/openapi.go b/shortcuts/feishu/openapi.go index ce25e4d..d14491e 100644 --- a/shortcuts/feishu/openapi.go +++ b/shortcuts/feishu/openapi.go @@ -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) +} diff --git a/shortcuts/feishu/options.go b/shortcuts/feishu/options.go index c67f056..62e7209 100644 --- a/shortcuts/feishu/options.go +++ b/shortcuts/feishu/options.go @@ -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 diff --git a/shortcuts/feishu/task.go b/shortcuts/feishu/task.go new file mode 100644 index 0000000..2a06b91 --- /dev/null +++ b/shortcuts/feishu/task.go @@ -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 +} diff --git a/skills/gitlink-feishu/SKILL.md b/skills/gitlink-feishu/SKILL.md index 81efc03..040e1f5 100644 --- a/skills/gitlink-feishu/SKILL.md +++ b/skills/gitlink-feishu/SKILL.md @@ -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 --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 "