From a4f3954b2cbbcd2810abaaf67f3543c3c9b4709c Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Mon, 15 Jun 2026 14:07:58 +0800 Subject: [PATCH 01/16] docs: design feishu export workflow --- .../CODEX_CLEAN_TASK_CHAIN.md | 740 ++++++++++++++++++ feishu-export-design/EVALUATION.md | 97 +++ feishu-export-design/TECHNICAL_PLAN.md | 303 +++++++ 3 files changed, 1140 insertions(+) create mode 100644 feishu-export-design/CODEX_CLEAN_TASK_CHAIN.md create mode 100644 feishu-export-design/EVALUATION.md create mode 100644 feishu-export-design/TECHNICAL_PLAN.md diff --git a/feishu-export-design/CODEX_CLEAN_TASK_CHAIN.md b/feishu-export-design/CODEX_CLEAN_TASK_CHAIN.md new file mode 100644 index 0000000..4dfb058 --- /dev/null +++ b/feishu-export-design/CODEX_CLEAN_TASK_CHAIN.md @@ -0,0 +1,740 @@ +# Codex Clean Task Chain: GitLink CLI Feishu Export + +## Goal + +Add a safe `feishu` shortcut module to `gitlink-cli`. + +This task only implements: + +```text +GitLink workflow JSON + -> Feishu card preview + -> Feishu bot send with explicit --send + -> weekly report + -> Bitable schema + -> Bitable-ready records + -> Agent Skill +``` + +## Boundaries + +Do not implement: + +```text +Feishu tasks +Feishu approval +callback server +button callbacks +GitLink remote writes +GitLink comments +Issue closure +code merge actions +direct GitLink webhook creation +real Bitable OpenAPI writes +Feishu Base creation +Feishu table creation +Feishu view creation +open_id person mapping +``` + +## Hard Rules + +```text +1. Target repository: gitlink-cli. +2. Language: Go. +3. Do not add Ruby, Rails, Python, or Node services. +4. First implementation consumes local workflow JSON only. +5. Default behavior is local preview. +6. Real Feishu bot sending requires explicit --send. +7. If --send and --dry-run are both set, return an error. +8. Tests must use local fixtures or mock HTTP servers. +9. Tests must not depend on a real Feishu tenant, bot, token, or webhook. +10. Logs and errors must not print full webhook URLs or secrets. +11. Do not add non-engineering packaging language to code, docs, comments, or reports. +``` + +## Phase 0: Repository Scan + +### Objective + +Scan the current codebase and record implementation anchors. + +### Read + +```text +cmd/root.go +shortcuts/register.go +shortcuts/common/runner.go +shortcuts/common/types.go +shortcuts/workflow/workflow.go +shortcuts/workflow/repo_report.go +shortcuts/workflow/pr_summary.go +shortcuts/workflow/render.go +shortcuts/workflow/testdata/ +internal/output/ +skills/ +docs/ +examples/ +go.mod +Makefile +.github/ +``` + +### Create + +```text +reports/FEISHU_IMPLEMENTATION_SCAN.md +``` + +### Required Content + +```text +Repository anchors +Reusable workflow data and renderers +Reusable output behavior +Shortcut registration pattern +Test fixtures +Current test baseline +Implementation landing points +``` + +### Verification + +```bash +go test ./shortcuts/workflow +go test ./shortcuts +``` + +Use this when the default Go proxy is unavailable: + +```powershell +$env:GOPROXY='https://goproxy.cn,direct' +``` + +## Phase 1: Feishu Command Skeleton + +### Objective + +Add a `feishu` shortcut group with five commands. All commands must compile and support `--help`. No command sends network traffic by default. + +### Add + +```text +shortcuts/feishu/ + feishu.go + options.go + redact.go + output.go +``` + +### Register + +Update: + +```text +shortcuts/register.go +``` + +Add: + +```text +feishu -> feishu.Shortcuts() +``` + +Description: + +```text +Export GitLink workflow data to Feishu cards and Bitable-ready records +``` + +### Commands + +```bash +gitlink-cli feishu +bot-test +gitlink-cli feishu +notify +gitlink-cli feishu +weekly-report +gitlink-cli feishu +bitable-schema +gitlink-cli feishu +bitable-records +``` + +Do not add: + +```text ++sync-bitable ++approval-create ++sync-tasks ++serve ++webhook-create +``` + +### Shared Flags + +```text +--owner +--repo +--since +--include issues,prs,contributors,health +--format json|table|markdown +--lang zh-CN|en-US +--dry-run +--from-workflow-json +``` + +### Bot Flags + +```text +--webhook-url +--secret +--send +``` + +### Bitable Preview Flags + +```text +--tables issues,prs,contributors,reports +``` + +### Environment Variables + +Read only: + +```text +FEISHU_WEBHOOK_URL +FEISHU_WEBHOOK_SECRET +``` + +Do not read in this task: + +```text +FEISHU_TENANT_ACCESS_TOKEN +FEISHU_APP_ID +FEISHU_APP_SECRET +FEISHU_BASE_APP_TOKEN +FEISHU_ISSUE_TABLE_ID +FEISHU_PR_TABLE_ID +FEISHU_CONTRIBUTOR_TABLE_ID +FEISHU_REPORT_TABLE_ID +``` + +### Verification + +```bash +gofmt -w shortcuts/feishu +go test ./shortcuts/feishu +go test ./shortcuts +``` + +Check help: + +```bash +go run . feishu --help +go run . feishu +bot-test --help +go run . feishu +notify --help +go run . feishu +weekly-report --help +go run . feishu +bitable-schema --help +go run . feishu +bitable-records --help +``` + +## Phase 2: Options and Redaction + +### Objective + +Implement safe option loading, validation, and redaction. + +### Files + +```text +shortcuts/feishu/options.go +shortcuts/feishu/redact.go +shortcuts/feishu/options_test.go +shortcuts/feishu/redact_test.go +``` + +### Options + +```go +type Options struct { + Owner string + Repo string + Since string + Include []string + Format string + Lang string + DryRun bool + Send bool + FromWorkflowJSON string + + WebhookURL string + WebhookSecret string + + Tables []string +} +``` + +### Functions + +```go +func LoadOptions(ctx *common.RuntimeContext) (Options, error) +func ValidateCommonOptions(opts Options) error +func ValidateBotOptions(opts Options) error +func ValidateWorkflowJSONOptions(opts Options) error +func ValidateBitableRecordOptions(opts Options) error +func NormalizeInclude(value string) ([]string, error) +func NormalizeTables(value string) ([]string, error) +func ValidateSendMode(opts Options) error +``` + +### Send Mode + +```text +Default: preview only. +--send: send Feishu bot message. +--send + --dry-run: error. +--send without webhook URL: error. +No --send: no HTTP request. +``` + +### Redaction + +```go +func MaskSecret(value string) string +func MaskWebhookURL(value string) string +func MaskToken(value string) string +func MaskError(err error) string +``` + +Rules: + +```text +secret -> *** +token -> *** +webhook URL -> scheme + host + final 4 path characters +empty string -> "" +short sensitive string -> *** +``` + +### Verification + +```bash +gofmt -w shortcuts/feishu +go test ./shortcuts/feishu +``` + +## Phase 3: Bot Signature + +### Objective + +Implement Feishu custom bot signing. + +### Files + +```text +shortcuts/feishu/signer.go +shortcuts/feishu/signer_test.go +``` + +### Functions + +```go +func BuildBotSign(timestamp int64, secret string) (string, error) +func BuildBotEnvelope(card map[string]any, secret string, now time.Time) (map[string]any, error) +``` + +### Behavior + +```text +Empty secret: no timestamp/sign. +Non-empty secret: add timestamp/sign. +Timestamp: Unix seconds. +Errors must not include secret. +Tests use fixed timestamps. +``` + +## Phase 4: Card Builders + +### Objective + +Build Feishu interactive card JSON without network calls. + +### Files + +```text +shortcuts/feishu/model.go +shortcuts/feishu/card.go +shortcuts/feishu/card_test.go +``` + +### Models + +```go +type ProjectActivity struct { + Repository string + Period string + IssueSummary SummaryBlock + PullRequestSummary SummaryBlock + ContributorSummary SummaryBlock + HealthSummary SummaryBlock + Risks []string + Recommendations []string +} + +type SummaryBlock struct { + Title string + Count int + Items []string +} + +type WeeklyReport struct { + Repository string + Period string + NewIssues int + ClosedIssues int + NewPullRequests int + MergedPullRequests int + ActiveContributors int + Risks []string + Recommendations []string + Markdown string +} +``` + +### Functions + +```go +func BuildBotTestCard(lang string) map[string]any +func BuildProjectActivityCard(activity ProjectActivity, lang string) map[string]any +func BuildWeeklyReportCard(report WeeklyReport, lang string) map[string]any +``` + +### Requirements + +```text +Use interactive card. +Support zh-CN and en-US text. +Handle empty data. +Do not include secrets or webhook URLs. +``` + +## Phase 5: Bot Webhook Client + +### Objective + +Send Feishu bot payloads through an injectable HTTP client. + +### Files + +```text +shortcuts/feishu/client.go +shortcuts/feishu/client_test.go +``` + +### API + +```go +type Client struct { + HTTPClient *http.Client +} + +func NewClient(httpClient *http.Client) *Client +func (c *Client) SendBotMessage(ctx context.Context, webhookURL string, payload any) error +``` + +### Behavior + +```text +POST JSON. +2xx means success. +Non-2xx returns redacted error. +Limit response body read size. +Network errors are redacted. +Never print full webhook URL. +``` + +### Tests + +Use `httptest.Server` for: + +```text +200 +400 +429 +500 +network error +redaction +``` + +Do not implement Bitable OpenAPI client in this phase. + +## Phase 6: Bot Test Command + +### Objective + +Preview or explicitly send a test card. + +### Commands + +Preview: + +```bash +go run . feishu +bot-test --format json +``` + +Send: + +```bash +go run . feishu +bot-test --webhook-url "$FEISHU_WEBHOOK_URL" --secret "$FEISHU_WEBHOOK_SECRET" --send +``` + +### Behavior + +```text +Default preview. +Preview prints JSON. +--send sends through mockable client. +--send requires webhook URL. +Output shows only redacted target. +No GitLink data read. +No GitLink write. +``` + +## Phase 7: Workflow JSON Mapper + +### Objective + +Read local workflow JSON and map it into Feishu models. + +### Files + +```text +shortcuts/feishu/mapper.go +shortcuts/feishu/mapper_test.go +shortcuts/feishu/testdata/ + repo_report.json + pr_summary.json + health.json +``` + +### Functions + +```go +func LoadWorkflowJSON(path string) (map[string]any, error) +func MapWorkflowToProjectActivity(data map[string]any, opts Options) (ProjectActivity, error) +func MapWorkflowToWeeklyReport(data map[string]any, opts Options) (WeeklyReport, error) +``` + +### Rules + +```text +Support workflow +repo-report JSON first. +Missing fields must not panic. +Unknown fields are ignored. +Do not duplicate workflow analysis logic. +Do not call GitLink remote APIs. +Do not change existing workflow behavior. +``` + +## Phase 8: Notify Command + +### Objective + +Create a project activity card from workflow JSON. + +### Commands + +Preview: + +```bash +go run . feishu +notify --from-workflow-json report.json --include issues,prs,contributors,health --format json +``` + +Send: + +```bash +go run . feishu +notify --from-workflow-json report.json --webhook-url "$FEISHU_WEBHOOK_URL" --secret "$FEISHU_WEBHOOK_SECRET" --send +``` + +### Behavior + +```text +Requires --from-workflow-json. +Uses include filter. +Default output is JSON preview. +--send sends a card. +No GitLink write. +``` + +## Phase 9: Weekly Report Command + +### Objective + +Create a weekly report from workflow JSON. + +### Commands + +```bash +go run . feishu +weekly-report --from-workflow-json report.json --format markdown +go run . feishu +weekly-report --from-workflow-json report.json --format json +go run . feishu +weekly-report --from-workflow-json report.json --webhook-url "$FEISHU_WEBHOOK_URL" --secret "$FEISHU_WEBHOOK_SECRET" --send +``` + +### Behavior + +```text +Default output is markdown. +JSON output returns structured WeeklyReport. +--send sends a Feishu card after local report generation. +No GitLink write. +``` + +## Phase 10: Bitable Schema + +### Objective + +Generate recommended Bitable schema. Do not connect to Feishu OpenAPI. + +### Files + +```text +shortcuts/feishu/schema.go +shortcuts/feishu/schema_test.go +``` + +### Command + +```bash +go run . feishu +bitable-schema --tables issues,prs,contributors,reports --format markdown +go run . feishu +bitable-schema --tables issues,prs,contributors,reports --format json +``` + +### Tables + +```text +Issues +Pull Requests +Contributors +Weekly Reports +``` + +### Rules + +```text +Do not create Base. +Do not create tables. +Do not create fields. +Do not create views. +Do not call Feishu OpenAPI. +``` + +## Phase 11: Bitable Records + +### Objective + +Generate Bitable-ready records from workflow JSON. Output only. + +### Files + +```text +shortcuts/feishu/bitable.go +shortcuts/feishu/bitable_test.go +``` + +### Command + +```bash +go run . feishu +bitable-records --from-workflow-json report.json --tables issues,prs,contributors,reports --format json +``` + +### Rules + +```text +Requires --from-workflow-json. +Only outputs records. +Does not read app token. +Does not read table IDs. +Does not call Feishu OpenAPI. +Does not create, update, or upsert. +``` + +## Phase 12: Skill and Docs + +### Add + +```text +skills/gitlink-feishu/SKILL.md +docs/feishu-integration.md +docs/feishu-security.md +docs/feishu-bitable-schema.md +examples/feishu/ + bot-test.md + notify.md + weekly-report.md + bitable-schema.md + bitable-records.md +``` + +### Content Rules + +```text +Describe engineering usage only. +Default to preview examples. +Show --send only for bot cards. +State that Bitable output is local-only in this task. +Do not include non-engineering packaging language. +``` + +## Phase 13: Final Verification + +### Commands + +```bash +gofmt -w shortcuts/feishu +go test ./shortcuts/feishu +go test ./shortcuts/workflow +go test ./shortcuts +``` + +If dependencies are already available or a temporary proxy is set: + +```bash +go test ./... +``` + +### Completion Report + +Create: + +```text +reports/FEISHU_TASK_COMPLETION.md +``` + +Include: + +```text +implemented commands +changed files +preview behavior +explicit send behavior +mock test coverage +redaction checks +test results +excluded capabilities +``` + +## Final Acceptance + +```text +feishu --help works. ++bot-test previews JSON. ++bot-test --send is covered by mock HTTP tests. ++notify reads workflow JSON and previews card JSON. ++notify --send is covered by mock HTTP tests. ++weekly-report reads workflow JSON and outputs markdown/json. ++bitable-schema outputs markdown/json. ++bitable-records outputs records JSON. +No output leaks full webhook URL or secret. +No Feishu send happens without --send. +No GitLink remote write exists. +No real Bitable write exists. +Targeted tests pass. +``` + diff --git a/feishu-export-design/EVALUATION.md b/feishu-export-design/EVALUATION.md new file mode 100644 index 0000000..50aa181 --- /dev/null +++ b/feishu-export-design/EVALUATION.md @@ -0,0 +1,97 @@ +# Feishu Export Design Evaluation + +## Current Baseline + +Working directory: + +```text +E:\GitLinkCLI-Competition\gitlink-cli-feishu-clean +``` + +Branch and base: + +```text +feat/feishu-export-clean +origin/master ef7a2c6 +``` + +Baseline tests: + +```powershell +$env:GOPROXY='https://goproxy.cn,direct'; go test ./... +``` + +Result: passed. + +The first default `go test ./...` attempt failed only because `proxy.golang.org` was unreachable for new dependencies. With a temporary Go proxy override, the latest master baseline is clean. + +## Judgment + +The v2 task chain is substantially better than the earlier version and is close to executable. It correctly narrows the feature into a safe one-way export module: + +```text +workflow JSON -> local preview +workflow JSON -> Feishu bot card +workflow JSON -> weekly report +workflow JSON -> Bitable schema +workflow JSON -> Bitable-ready records +``` + +The remaining design adjustments are: + +1. Keep the first implementation limited to local workflow JSON input. +2. Use `--send` as the only real Feishu bot send switch. +3. Use `prs` in flags and table keys, while displaying `Pull Requests` to users. +4. Replace any `sync-bitable` wording with `bitable-records`. +5. Do not implement real Bitable OpenAPI writes in this task. +6. Keep `doctor` out of the first implementation unless the other commands already exist. + +## What Is Complete + +The v2 task chain is complete enough for: + +- Command skeleton design. +- Safety options and redaction design. +- Feishu bot card and signature design. +- Mocked webhook client design. +- Workflow JSON mapping design. +- Local weekly report generation. +- Bitable schema generation. +- Bitable-ready record generation. +- Agent Skill and documentation outline. + +## What Is Not Closed Yet + +The following should not be implemented in the first pass: + +- Real Bitable record creation. +- Real Bitable record update. +- Bitable unique-key lookup through Feishu OpenAPI. +- Tenant token acquisition. +- Token cache behavior. +- Feishu Base/table/view creation. +- Any GitLink remote write. + +These require a separate authentication and data consistency design. + +## Practical Utility + +The design is useful in a real CLI workflow: + +1. A user generates a workflow report with the existing `workflow +repo-report` command. +2. The new `feishu` command consumes that JSON file. +3. The user previews a card, weekly report, schema, or records locally. +4. Only when `--send` is explicit does the CLI send a Feishu bot message. + +This produces a small, testable feature that is useful without requiring Feishu enterprise app credentials. + +## Implementation Scope Rating + +```text +Direction: strong +Safety boundary: strong +Repository fit: strong +First implementation size: acceptable after removing real Bitable writes +Direct executability: good after using the clean task chain in this directory +``` + diff --git a/feishu-export-design/TECHNICAL_PLAN.md b/feishu-export-design/TECHNICAL_PLAN.md new file mode 100644 index 0000000..7c1daa1 --- /dev/null +++ b/feishu-export-design/TECHNICAL_PLAN.md @@ -0,0 +1,303 @@ +# Feishu Export Technical Plan + +## Latest Master Anchors + +Current command registration: + +```text +shortcuts/register.go +``` + +Add imports: + +```go +github.com/gitlink-org/gitlink-cli/shortcuts/feishu +``` + +Add group: + +```go +"feishu": feishu.Shortcuts(), +``` + +Add description: + +```go +"feishu": "Export GitLink workflow data to Feishu cards and Bitable-ready records", +``` + +Shortcut mounting: + +```text +shortcuts/common/runner.go +``` + +Important behavior: + +- Boolean flags are read from Cobra as strings in `RuntimeContext.Args`. +- A bool flag with default `true` cannot tell whether the user explicitly set it. Therefore `--send` should be the positive action flag, and `--dry-run` should remain a preview indicator. +- If implementation must detect explicit `--dry-run`, it needs a command-specific Cobra command instead of the generic shortcut runner. Avoid that for the first implementation. + +## Send Flag Decision + +Use this rule: + +```text +--send=false or omitted: preview only. +--send=true and dry-run=false: send. +--send=true and dry-run=true: error. +``` + +Since generic shortcut flags cannot detect whether `--dry-run` was explicitly passed, set `--dry-run` default to `false` in Feishu commands and treat preview as `!Send`. This avoids a default conflict where `--send` would always collide with default `--dry-run=true`. + +Effective mode: + +```go +Preview := !opts.Send +``` + +Validation: + +```go +if opts.Send && opts.DryRun { + return error +} +if opts.Send && opts.WebhookURL == "" { + return error +} +``` + +User-facing meaning: + +```text +No --send: preview. +--dry-run: preview and forbid send. +--send: real bot send. +--send --dry-run: invalid. +``` + +## Output Strategy + +Do not extend `internal/output` in the first implementation. + +Reason: + +- Global output supports `json`, `yaml`, and generic `table`. +- Workflow uses local renderers for markdown. +- Feishu needs markdown for weekly reports and schema docs. + +Implement local Feishu render helpers: + +```go +func renderJSON(w io.Writer, value any) error +func renderMarkdown(w io.Writer, value any) error +func renderTable(w io.Writer, value any) error +func normalizeFormat(format string, defaultFormat string) string +``` + +Default formats: + +```text ++bot-test: json ++notify: json ++weekly-report: markdown ++bitable-schema: markdown ++bitable-records: json +``` + +## Workflow JSON Input + +First implementation only supports local JSON files. + +Expected source: + +```bash +gitlink-cli workflow +repo-report --owner --repo --format json > report.json +``` + +Mapping should accept both shapes if found: + +1. Raw `RepoReportResult`. +2. Envelope-like object with `data`. + +Fields to use: + +```text +repository +health.health_score +health.risk_level +issue_summary.total +issue_summary.high_risk +issue_summary.missing_info +pr_summary.total +pr_summary.high_risk +pr_summary.review_focus +recommendations +risk_level +report_score +source +``` + +Do not import unexported workflow readers. Use JSON mapping to avoid changing workflow internals. + +## Feishu Signature + +Use Feishu custom bot signing: + +```text +string_to_sign = timestamp + "\n" + secret +sign = base64(hmac_sha256(string_to_sign, secret)) +``` + +Implementation notes: + +- `timestamp` is Unix seconds and serialized as a string. +- Empty secret means no timestamp/sign. +- Tests use fixed timestamp. +- Errors must be redacted. + +## Feishu Card Shape + +Use custom bot interactive card payload: + +```json +{ + "msg_type": "interactive", + "card": { + "header": { + "title": { + "tag": "plain_text", + "content": "GitLink Project Activity" + } + }, + "elements": [] + } +} +``` + +Envelope with signature: + +```json +{ + "timestamp": "1710000000", + "sign": "redacted in logs", + "msg_type": "interactive", + "card": {} +} +``` + +## HTTP Client + +Use package-local client: + +```go +type Client struct { + HTTPClient *http.Client +} +``` + +Use `httptest.Server` for all tests. + +Do not use `internal/client.Client` for Feishu because it appends GitLink `.json` suffixes and injects GitLink auth. + +## Naming + +CLI flags: + +```text +prs +--include issues,prs,contributors,health +--tables issues,prs,contributors,reports +``` + +User-facing labels: + +```text +Pull Requests +``` + +Internal model names: + +```text +PullRequestSummary +NewPullRequests +MergedPullRequests +``` + +Do not use `pulls` in new user-facing flags. + +## Bitable Scope + +First implementation: + +```text ++bitable-schema ++bitable-records +``` + +No Feishu OpenAPI calls. + +No environment variables for app tokens or table IDs. + +No tenant token. + +No create/update/upsert. + +The records command outputs local JSON only: + +```go +type BitableRecordsOutput struct { + Repository string `json:"repository"` + Tables []BitableTableRecords `json:"tables"` +} + +type BitableTableRecords struct { + Table string `json:"table"` + Records []BitableRecord `json:"records"` +} + +type BitableRecord struct { + UniqueKey string `json:"unique_key"` + Fields map[string]any `json:"fields"` +} +``` + +## Tests To Add + +```text +shortcuts/feishu/options_test.go +shortcuts/feishu/redact_test.go +shortcuts/feishu/signer_test.go +shortcuts/feishu/card_test.go +shortcuts/feishu/client_test.go +shortcuts/feishu/mapper_test.go +shortcuts/feishu/schema_test.go +shortcuts/feishu/bitable_test.go +shortcuts/feishu/commands_test.go +``` + +Minimum assertions: + +```text +No --send means no HTTP. +--send means HTTP in mock tests. +--send --dry-run errors. +Missing webhook URL with --send errors. +Errors redact webhook URL and secret. +Workflow JSON can be loaded from fixture. +Missing fields do not panic. +Weekly report markdown is stable. +Bitable schema JSON is parseable. +Bitable records JSON is parseable. +``` + +## Baseline Verification + +Current latest master passes with: + +```powershell +$env:GOPROXY='https://goproxy.cn,direct'; go test ./... +``` + +Keep this as the baseline before implementation. + From c2a581eee969f523dba56108202c2ba4cc40416f Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Mon, 15 Jun 2026 15:05:48 +0800 Subject: [PATCH 02/16] feat: add feishu export workflow --- feishu-export-design/EVALUATION.md | 31 +- .../OFFICIAL_DOCS_ALIGNMENT.md | 258 +++++++++++++ feishu-export-design/TECHNICAL_PLAN.md | 57 ++- reports/FEISHU_TASK_COMPLETION.md | 136 +++++++ shortcuts/feishu/bitable.go | 348 ++++++++++++++++++ shortcuts/feishu/card.go | 199 ++++++++++ shortcuts/feishu/client.go | 76 ++++ shortcuts/feishu/feishu.go | 184 +++++++++ shortcuts/feishu/feishu_test.go | 138 +++++++ shortcuts/feishu/options.go | 113 ++++++ shortcuts/feishu/render.go | 118 ++++++ shortcuts/feishu/sign.go | 21 ++ shortcuts/feishu/workflow_input.go | 81 ++++ shortcuts/register.go | 3 + shortcuts/register_test.go | 2 +- 15 files changed, 1762 insertions(+), 3 deletions(-) create mode 100644 feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md create mode 100644 reports/FEISHU_TASK_COMPLETION.md create mode 100644 shortcuts/feishu/bitable.go create mode 100644 shortcuts/feishu/card.go create mode 100644 shortcuts/feishu/client.go create mode 100644 shortcuts/feishu/feishu.go create mode 100644 shortcuts/feishu/feishu_test.go create mode 100644 shortcuts/feishu/options.go create mode 100644 shortcuts/feishu/render.go create mode 100644 shortcuts/feishu/sign.go create mode 100644 shortcuts/feishu/workflow_input.go diff --git a/feishu-export-design/EVALUATION.md b/feishu-export-design/EVALUATION.md index 50aa181..a6917d1 100644 --- a/feishu-export-design/EVALUATION.md +++ b/feishu-export-design/EVALUATION.md @@ -46,6 +46,20 @@ The remaining design adjustments are: 5. Do not implement real Bitable OpenAPI writes in this task. 6. Keep `doctor` out of the first implementation unless the other commands already exist. +After checking Feishu Open Platform documentation and the BotBuilder shutdown notice, add one more adjustment: + +7. Add Feishu Docs export as the first real custom-app integration after the custom bot MVP. + +Reason: + +```text +Feishu custom bot = notification. +Feishu DocX = collaborative report artifact. +Bitable = structured tracking. +``` + +The previous design handled notification and Bitable dry-run, but did not use Feishu Docs. That makes the workflow less useful as a collaboration handoff. + ## What Is Complete The v2 task chain is complete enough for: @@ -72,6 +86,14 @@ The following should not be implemented in the first pass: - Feishu Base/table/view creation. - Any GitLink remote write. +The following should be designed before real Bitable writes: + +- `feishu +doc-export` +- app_id/app_secret loading +- tenant_access_token fetch and cache +- document folder permission diagnostics +- DocX create-document and create-block behavior + These require a separate authentication and data consistency design. ## Practical Utility @@ -85,6 +107,14 @@ The design is useful in a real CLI workflow: This produces a small, testable feature that is useful without requiring Feishu enterprise app credentials. +The stronger practical workflow is: + +```text +workflow JSON -> markdown report -> Feishu DocX -> Feishu bot card with doc link -> Bitable dry-run records +``` + +That path gives users both an immediate group notification and a persistent collaborative report. + ## Implementation Scope Rating ```text @@ -94,4 +124,3 @@ Repository fit: strong First implementation size: acceptable after removing real Bitable writes Direct executability: good after using the clean task chain in this directory ``` - diff --git a/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md b/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md new file mode 100644 index 0000000..22be782 --- /dev/null +++ b/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md @@ -0,0 +1,258 @@ +# Feishu Official Docs Alignment + +## Sources Checked + +- Custom bot usage guide: https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot +- Send message cards with custom bot: https://open.feishu.cn/document/feishu-cards/quick-start/send-message-cards-with-custom-bot?lang=zh-CN +- Custom app tenant access token: https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN +- Send message API: https://open.feishu.cn/document/server-docs/im-v1/message/create?lang=zh-CN +- Create DocX document: https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/create +- Create DocX blocks: https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document-block/create?lang=zh-CN +- Bitable create record: https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/create?lang=zh-CN +- Bitable batch create records: https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/batch_create?lang=zh-CN +- Docs token FAQ: https://open.feishu.cn/document/faq/trouble-shooting/how-to-get-docs-tokens +- Docs permission FAQ: https://open.feishu.cn/document/server-docs/docs/faq?lang=zh-CN + +## Important Product Boundary + +The BotBuilder shutdown notice does not affect this design if the implementation uses: + +```text +Feishu Open Platform custom bot webhooks +Feishu Open Platform custom app APIs +Feishu Docs / Bitable OpenAPI +``` + +Do not integrate: + +```text +botbuilder.feishu.cn +Feishu Robot Assistant workflows +``` + +## Correct Integration Modes + +### Mode A: Custom Group Bot Webhook + +Use this for the first working proof. + +Required inputs: + +```text +FEISHU_WEBHOOK_URL +FEISHU_WEBHOOK_SECRET optional +``` + +Capabilities: + +```text +Send one-way group notifications. +Send interactive card JSON to a group. +No tenant token. +No app_id/app_secret. +No user, tenant, document, or Bitable data access. +``` + +Fit in this project: + +```text +feishu +bot-test +feishu +notify +feishu +weekly-report --send +``` + +### Mode B: Open Platform Custom App + +Use this for real document and Bitable operations. + +Required inputs: + +```text +FEISHU_APP_ID +FEISHU_APP_SECRET +``` + +Token flow: + +```text +POST /open-apis/auth/v3/tenant_access_token/internal +request: app_id + app_secret +response: tenant_access_token, expire +``` + +Required implementation: + +```text +Token client +token cache with expiry +redacted errors +permission diagnostics +mocked HTTP tests +``` + +Fit in this project: + +```text +Phase 2: feishu +doc-export +Phase 3: feishu +bitable-sync or +bitable-upsert +Optional: app bot message send through im/v1/messages +``` + +### Mode C: Low-Code Alternatives + +Multidimensional table workflows, Aily, and AnyCross are valid migration choices for BotBuilder users, but they are not a good first implementation target inside `gitlink-cli`. + +Use them as documentation references only. + +## Recommended Product Flow + +The practical GitLink-to-Feishu workflow should be: + +```text +1. gitlink-cli workflow +repo-report --format json > report.json +2. gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown +3. gitlink-cli feishu +doc-export --from-workflow-json report.json --folder-token --send +4. gitlink-cli feishu +notify --from-workflow-json report.json --doc-url --send +5. gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json +6. Later: gitlink-cli feishu +bitable-sync --from-workflow-json report.json --send +``` + +Key point: + +```text +Card = notification. +Doc = collaboration artifact. +Bitable = structured tracking data. +``` + +The earlier design covered card and Bitable dry-run, but missed the document artifact. + +## Doc Export Requirements + +Add a later `feishu +doc-export` command. + +Inputs: + +```text +--from-workflow-json report.json +--folder-token +--document-id optional later +--wiki-url optional later +--wiki-node-token optional later +--title +--send +``` + +Environment: + +```text +FEISHU_APP_ID +FEISHU_APP_SECRET +``` + +Behavior: + +```text +Default preview only. +--send creates or updates a Feishu DocX document. +Create document first. +Then create blocks under the document root block. +Return document_id and URL. +No document operation without --send. +``` + +Permission notes: + +```text +The app must have required DocX/Drive application scopes. +The target folder or document must grant the app document permission. +folder_token/document_id/app_token must be read from URL or OpenAPI. +``` + +## Knowledge Base / Wiki Fit + +Knowledge Base pages are useful for project showcase and reference material. + +The supplied project page shape: + +```text +https://<tenant>.feishu.cn/wiki/<node_token> +``` + +Official API flow: + +```text +1. Get tenant_access_token with app_id/app_secret. +2. Resolve wiki node token with Wiki API. +3. If obj_type is docx, use obj_token as the DocX document target. +4. Export or append report blocks with DocX block APIs. +5. Send a Feishu bot card with the wiki/doc URL as the collaboration entry. +``` + +Design impact: + +```text +Add wiki-url/wiki-node-token support to doc-export. +Add --doc-url to notify/weekly-report card commands. +Keep Wiki operations behind --send. +Do not edit knowledge base permissions automatically. +``` + +This makes the project output more suitable for display: + +```text +Knowledge Base page = project homepage / reference index. +DocX report blocks = generated workflow report. +Bot card = notification and entry link. +Bitable records = structured data for later dashboards. +``` + +## Bitable Real Write Requirements + +Keep current `+bitable-schema` and `+bitable-records` as dry-run commands. + +Only add real write after the app auth layer exists. + +Required inputs: + +```text +FEISHU_APP_ID +FEISHU_APP_SECRET +FEISHU_BASE_APP_TOKEN +FEISHU_REPORT_TABLE_ID +FEISHU_ISSUE_TABLE_ID +FEISHU_PR_TABLE_ID +FEISHU_CONTRIBUTOR_TABLE_ID optional +``` + +Required behavior: + +```text +Fetch tenant_access_token. +Validate table IDs. +Create records or batch create records. +For update/upsert, search existing records first. +Do not call Bitable OpenAPI unless --send is explicit. +``` + +## Design Verdict + +Current design is reasonable as a first safe MVP: + +```text +custom bot send +workflow JSON local input +weekly report markdown +Bitable schema/records dry-run +mock tests +``` + +But it is incomplete for a "Feishu collaboration export" feature because it does not create or update Feishu Docs. + +Required design adjustment: + +```text +Add doc-export with DocX/Wiki support as the first Open Platform custom-app integration. +Keep real Bitable writes after doc-export. +Keep custom bot as the low-friction smoke test path. +``` diff --git a/feishu-export-design/TECHNICAL_PLAN.md b/feishu-export-design/TECHNICAL_PLAN.md index 7c1daa1..3969196 100644 --- a/feishu-export-design/TECHNICAL_PLAN.md +++ b/feishu-export-design/TECHNICAL_PLAN.md @@ -262,6 +262,62 @@ type BitableRecord struct { } ``` +## Feishu Docs Scope + +Official Feishu Open Platform docs show that cloud document integration belongs to the self-built app flow, not the custom bot flow. + +Add this after the custom bot MVP: + +```text +feishu +doc-export +``` + +Inputs: + +```text +--from-workflow-json +--folder-token +--document-id optional later +--wiki-url optional later +--wiki-node-token optional later +--title +--send +``` + +Environment: + +```text +FEISHU_APP_ID +FEISHU_APP_SECRET +``` + +API flow: + +```text +1. POST /open-apis/auth/v3/tenant_access_token/internal +2. Optional: GET /open-apis/wiki/v2/spaces/get_node?token=<wiki_node_token> +3. POST /open-apis/docx/v1/documents +4. POST /open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children +``` + +Implementation notes: + +- Default remains preview only. +- `--send` is required before creating or updating a document. +- The app must have both application scopes and document/folder-level permission. +- The command should return a document ID and URL for `+notify --doc-url`. +- If `--wiki-url` or `--wiki-node-token` is provided, resolve the wiki node first and use the underlying `docx` object token when possible. +- Mock all HTTP tests. +- Do not implement document sharing or permission changes in the first doc export pass. + +Recommended product flow: + +```text +workflow +repo-report -> feishu +doc-export --wiki-url -> feishu +notify --doc-url -> feishu +bitable-records +``` + +This should land before any real Bitable write because DocX export has clearer value and simpler consistency semantics than Bitable upsert. + ## Tests To Add ```text @@ -300,4 +356,3 @@ $env:GOPROXY='https://goproxy.cn,direct'; go test ./... ``` Keep this as the baseline before implementation. - diff --git a/reports/FEISHU_TASK_COMPLETION.md b/reports/FEISHU_TASK_COMPLETION.md new file mode 100644 index 0000000..9a77c99 --- /dev/null +++ b/reports/FEISHU_TASK_COMPLETION.md @@ -0,0 +1,136 @@ +# Feishu Export Task Completion + +## Branch + +```text +feat/feishu-export-clean +``` + +## Implemented Commands + +```text +gitlink-cli feishu +bot-test +gitlink-cli feishu +notify +gitlink-cli feishu +weekly-report +gitlink-cli feishu +bitable-schema +gitlink-cli feishu +bitable-records +``` + +## Implemented Behavior + +- Added a `feishu` shortcut group. +- Added local preview by default. +- Added explicit `--send` for real Feishu custom bot delivery. +- Added `--send` and `--dry-run` conflict validation. +- Added webhook URL redaction in command output. +- Added Feishu custom bot interactive card builders. +- Added Feishu custom bot signing helper. +- Added injectable HTTP webhook client. +- Added workflow JSON input support for `RepoReportInput`, `RepoReportResult`, and envelope-like `data`. +- Added project activity card generation from workflow JSON. +- Added weekly report rendering from workflow JSON. +- Added `--doc-url` support for notification cards. +- Added Bitable dry-run schema generation. +- Added Bitable-ready dry-run records. +- Registered the new shortcut group in `shortcuts/register.go`. +- Updated shortcut registration tests. + +## Feishu Smoke Checks + +Custom bot send was tested against a real Feishu custom bot webhook. + +Result: + +```text +HTTP status: 200 +Feishu code: 0 +Message: success +``` + +Webhook output was redacted. + +A second notification card was sent with a Feishu Wiki URL as the report entry link. + +## Open Platform Checks + +Self-built app authentication was checked with the Feishu Open Platform tenant token endpoint. + +Result: + +```text +tenant_access_token: acquired +expire: 7199 seconds +``` + +The supplied Feishu Wiki URL was resolved through Wiki OpenAPI. + +Result: + +```text +wiki node: resolved +object type: docx +object token: present +``` + +No document content was modified in this check. + +## Knowledge Base Design Update + +Added official-docs alignment notes: + +```text +feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md +``` + +Design now treats Feishu Knowledge Base / Wiki pages as a project showcase and reference target: + +```text +workflow JSON -> DocX/Wiki report -> bot card with doc URL -> Bitable dry-run records +``` + +## Tests + +Commands run: + +```bash +go test ./shortcuts/feishu +go test ./shortcuts/workflow +go test ./shortcuts +go test ./... +``` + +Result: + +```text +passed +``` + +## Explicitly Not Implemented + +```text +BotBuilder integration +Feishu Robot Assistant workflows +Feishu task creation +Feishu approval creation +callback server +button callbacks +GitLink remote writes +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 +``` + +## Next Engineering Step + +Add `feishu +doc-export` as the first self-built app integration: + +```text +app_id/app_secret -> tenant_access_token -> resolve Wiki node or create DocX -> write report blocks -> return doc URL +``` + diff --git a/shortcuts/feishu/bitable.go b/shortcuts/feishu/bitable.go new file mode 100644 index 0000000..3370395 --- /dev/null +++ b/shortcuts/feishu/bitable.go @@ -0,0 +1,348 @@ +package feishu + +import ( + "fmt" + "io" + "sort" + "strings" + "text/tabwriter" + + "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" +) + +type BitableSchema struct { + DryRun bool `json:"dry_run"` + Tables []BitableTableSchema `json:"tables"` +} + +type BitableTableSchema struct { + Name string `json:"name"` + Description string `json:"description"` + Fields []BitableField `json:"fields"` +} + +type BitableField struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` +} + +type BitableRecords struct { + DryRun bool `json:"dry_run"` + Tables map[string][]BitableRecord `json:"tables"` + Schema []BitableTableSchema `json:"schema"` + Notes []string `json:"notes,omitempty"` +} + +type BitableRecord struct { + Fields map[string]interface{} `json:"fields"` +} + +func BuildBitableSchema(tables []string) BitableSchema { + result := BitableSchema{DryRun: true} + for _, table := range normalizeTables(tables) { + result.Tables = append(result.Tables, schemaForTable(table)) + } + return result +} + +func BuildBitableRecords(report workflow.RepoReportResult, tables []string) BitableRecords { + tables = normalizeTables(tables) + result := BitableRecords{ + DryRun: true, + 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.", + }, + } + for _, table := range tables { + switch table { + case "reports": + result.Tables[table] = reportRecords(report) + case "issues": + result.Tables[table] = issueRecords(report) + case "prs": + result.Tables[table] = prRecords(report) + case "contributors": + result.Tables[table] = []BitableRecord{} + } + } + return result +} + +func normalizeTables(tables []string) []string { + if len(tables) == 0 { + tables = parseList(defaultTables) + } + allowed := map[string]bool{"issues": true, "prs": true, "contributors": true, "reports": true} + seen := map[string]bool{} + result := []string{} + for _, table := range tables { + if table == "pulls" { + table = "prs" + } + if !allowed[table] || seen[table] { + continue + } + seen[table] = true + result = append(result, table) + } + return result +} + +func schemaForTable(table string) BitableTableSchema { + switch table { + case "issues": + return BitableTableSchema{ + Name: "issues", + Description: "Issue summary 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"}, + }, + } + 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"}, + }, + } + 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"}, + }, + } + 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"}, + }, + } + } +} + +func reportRecords(report workflow.RepoReportResult) []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, + }, + }} +} + +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, + }) + 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, + }}) + } + return records +} + +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, + }) + 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, + }}) + } + return records +} + +func appendCountMapRecords(records []BitableRecord, repository string, bucketType string, values map[string]int, extras map[string]interface{}) []BitableRecord { + 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], + } + for extraKey, extraValue := range extras { + fields[extraKey] = extraValue + } + records = append(records, BitableRecord{Fields: fields}) + } + return records +} + +func renderBitableSchema(w io.Writer, schema BitableSchema, format string) error { + switch normalizeFormat(format) { + case "markdown": + return writeSchemaMarkdown(w, schema) + case "table": + return writeSchemaTable(w, schema) + default: + return writeJSON(w, schema) + } +} + +func renderBitableRecords(w io.Writer, records BitableRecords, format string) error { + switch normalizeFormat(format) { + case "markdown": + return writeRecordsMarkdown(w, records) + case "table": + return writeRecordsTable(w, records) + default: + return writeJSON(w, records) + } +} + +func writeSchemaMarkdown(w io.Writer, schema BitableSchema) error { + if _, err := fmt.Fprint(w, "# Feishu Bitable Schema\n\nDry run: `true`\n\n"); err != nil { + return err + } + for _, table := range schema.Tables { + if _, err := fmt.Fprintf(w, "## %s\n\n%s\n\n", table.Name, table.Description); err != nil { + return err + } + if _, err := fmt.Fprintln(w, "| Field | Type | Description |\n| --- | --- | --- |"); err != nil { + return err + } + for _, field := range table.Fields { + if _, err := fmt.Fprintf(w, "| %s | %s | %s |\n", field.Name, field.Type, field.Description); err != nil { + return err + } + } + if _, err := fmt.Fprintln(w); err != nil { + return err + } + } + return nil +} + +func writeSchemaTable(w io.Writer, schema BitableSchema) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "TABLE\tFIELD\tTYPE\tDESCRIPTION"); err != nil { + return err + } + for _, table := range schema.Tables { + for _, field := range table.Fields { + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", table.Name, field.Name, field.Type, field.Description); err != nil { + return err + } + } + } + return tw.Flush() +} + +func writeRecordsMarkdown(w io.Writer, records BitableRecords) error { + if _, err := fmt.Fprint(w, "# Feishu Bitable Records\n\nDry run: `true`\n\n"); err != nil { + return err + } + for _, note := range records.Notes { + if _, err := fmt.Fprintf(w, "- %s\n", note); err != nil { + return err + } + } + if _, err := fmt.Fprintln(w); err != nil { + return err + } + tableNames := make([]string, 0, len(records.Tables)) + for table := range records.Tables { + tableNames = append(tableNames, table) + } + sort.Strings(tableNames) + for _, table := range tableNames { + rows := records.Tables[table] + if _, err := fmt.Fprintf(w, "## %s\n\nRecords: `%d`\n\n", table, len(rows)); err != nil { + return err + } + } + return nil +} + +func writeRecordsTable(w io.Writer, records BitableRecords) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "TABLE\tRECORDS"); err != nil { + return err + } + tableNames := make([]string, 0, len(records.Tables)) + for table := range records.Tables { + tableNames = append(tableNames, table) + } + sort.Strings(tableNames) + for _, table := range tableNames { + if _, err := fmt.Fprintf(tw, "%s\t%d\n", table, len(records.Tables[table])); err != nil { + return err + } + } + return tw.Flush() +} + +func joinStrings(values []string) string { + return strings.Join(values, ", ") +} diff --git a/shortcuts/feishu/card.go b/shortcuts/feishu/card.go new file mode 100644 index 0000000..7ca41f6 --- /dev/null +++ b/shortcuts/feishu/card.go @@ -0,0 +1,199 @@ +package feishu + +import ( + "fmt" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" +) + +type Card map[string]interface{} + +type WebhookPayload struct { + Timestamp string `json:"timestamp,omitempty"` + Sign string `json:"sign,omitempty"` + MsgType string `json:"msg_type"` + Card Card `json:"card,omitempty"` + Content map[string]string `json:"content,omitempty"` +} + +func NewInteractivePayload(card Card) WebhookPayload { + return WebhookPayload{ + MsgType: "interactive", + Card: card, + } +} + +func BuildBotTestCard(title, message, lang string) Card { + title = firstNonEmpty(title, "GitLink Feishu integration test") + message = firstNonEmpty(message, "gitlink-cli can build and send Feishu custom bot cards.") + return baseCard(title, "blue", []interface{}{ + div("**Status**\nReady"), + div(message), + note("Generated by gitlink-cli feishu +bot-test."), + }) +} + +func BuildWorkflowCard(report workflow.RepoReportResult, include []string, title string, lang string, docURL string) Card { + title = firstNonEmpty(title, reportTitle(report, lang)) + elements := []interface{}{ + div(fmt.Sprintf("**Repository**\n%s", escapeMD(report.Repository))), + fields([]fieldValue{ + {Label: "Report score", Value: fmt.Sprintf("%d", report.ReportScore)}, + {Label: "Risk level", Value: report.RiskLevel}, + {Label: "Source", Value: report.Source}, + }), + } + if hasItem(include, "health") { + healthScore := "N/A" + healthRisk := "N/A" + if report.Health != nil { + healthScore = fmt.Sprintf("%d", report.Health.HealthScore) + healthRisk = report.Health.RiskLevel + } + elements = append(elements, fields([]fieldValue{ + {Label: "Health score", Value: healthScore}, + {Label: "Health risk", Value: healthRisk}, + })) + } + if hasItem(include, "issues") { + elements = append(elements, fields([]fieldValue{ + {Label: "Issues", Value: fmt.Sprintf("%d", report.IssueSummary.Total)}, + {Label: "High risk issues", Value: fmt.Sprintf("%d", report.IssueSummary.HighRisk)}, + {Label: "Missing info", Value: fmt.Sprintf("%d", report.IssueSummary.MissingInfo)}, + })) + } + if hasItem(include, "prs") { + elements = append(elements, fields([]fieldValue{ + {Label: "Pull requests", Value: fmt.Sprintf("%d", report.PRSummary.Total)}, + {Label: "High risk PRs", Value: fmt.Sprintf("%d", report.PRSummary.HighRisk)}, + })) + if len(report.PRSummary.ReviewFocus) > 0 { + elements = append(elements, div("**Review focus**\n"+bulletList(report.PRSummary.ReviewFocus, 4))) + } + } + if len(report.Recommendations) > 0 { + elements = append(elements, div("**Recommendations**\n"+bulletList(report.Recommendations, 5))) + } + if strings.TrimSpace(docURL) != "" { + elements = append(elements, actionButton("Open Feishu report", docURL)) + } + elements = append(elements, note("Preview is read-only. Bitable records are generated locally by +bitable-records.")) + return baseCard(title, templateForRisk(report.RiskLevel), elements) +} + +func reportTitle(report workflow.RepoReportResult, lang string) string { + if lang == "zh-CN" { + return "GitLink workflow report: " + report.Repository + } + return "GitLink workflow report: " + report.Repository +} + +func baseCard(title string, template string, elements []interface{}) Card { + return Card{ + "config": map[string]interface{}{ + "wide_screen_mode": true, + }, + "header": map[string]interface{}{ + "template": template, + "title": map[string]interface{}{ + "tag": "plain_text", + "content": title, + }, + }, + "elements": elements, + } +} + +func div(content string) map[string]interface{} { + return map[string]interface{}{ + "tag": "div", + "text": map[string]interface{}{ + "tag": "lark_md", + "content": content, + }, + } +} + +type fieldValue struct { + Label string + Value string +} + +func fields(values []fieldValue) map[string]interface{} { + result := make([]interface{}, 0, len(values)) + for _, value := range values { + result = append(result, map[string]interface{}{ + "is_short": true, + "text": map[string]interface{}{ + "tag": "lark_md", + "content": fmt.Sprintf("**%s**\n%s", value.Label, escapeMD(value.Value)), + }, + }) + } + return map[string]interface{}{ + "tag": "div", + "fields": result, + } +} + +func note(content string) map[string]interface{} { + return map[string]interface{}{ + "tag": "note", + "elements": []interface{}{ + map[string]interface{}{ + "tag": "plain_text", + "content": content, + }, + }, + } +} + +func actionButton(text string, url string) map[string]interface{} { + return map[string]interface{}{ + "tag": "action", + "actions": []interface{}{ + map[string]interface{}{ + "tag": "button", + "text": map[string]interface{}{ + "tag": "plain_text", + "content": text, + }, + "type": "primary", + "url": strings.TrimSpace(url), + }, + }, + } +} + +func bulletList(values []string, limit int) string { + if limit <= 0 || limit > len(values) { + limit = len(values) + } + lines := make([]string, 0, limit) + for _, value := range values[:limit] { + lines = append(lines, "- "+escapeMD(value)) + } + return strings.Join(lines, "\n") +} + +func templateForRisk(risk string) string { + switch strings.ToLower(strings.TrimSpace(risk)) { + case "critical": + return "red" + case "high": + return "orange" + case "medium": + return "yellow" + default: + return "green" + } +} + +func escapeMD(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "N/A" + } + return strings.ReplaceAll(value, "\n", " ") +} diff --git a/shortcuts/feishu/client.go b/shortcuts/feishu/client.go new file mode 100644 index 0000000..842833b --- /dev/null +++ b/shortcuts/feishu/client.go @@ -0,0 +1,76 @@ +package feishu + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +type WebhookClient struct { + URL string + Secret string + HTTP *http.Client + Now func() time.Time +} + +type WebhookResponse struct { + StatusCode int `json:"status_code"` + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Body string `json:"body,omitempty"` +} + +func (c WebhookClient) Send(ctx context.Context, payload WebhookPayload) (*WebhookResponse, error) { + if c.HTTP == nil { + c.HTTP = http.DefaultClient + } + now := time.Now + if c.Now != nil { + now = c.Now + } + if c.Secret != "" { + ts := timestampSeconds(now()) + payload.Timestamp = strconv.FormatInt(ts, 10) + payload.Sign = SignCustomBotRequest(ts, c.Secret) + } + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + result := &WebhookResponse{StatusCode: resp.StatusCode, Body: string(respBody)} + var decoded struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(respBody, &decoded); err == nil { + result.Code = decoded.Code + result.Message = decoded.Msg + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return result, fmt.Errorf("Feishu webhook returned HTTP %d", resp.StatusCode) + } + if decoded.Code != 0 { + return result, fmt.Errorf("Feishu webhook returned code %d: %s", decoded.Code, decoded.Msg) + } + return result, nil +} diff --git a/shortcuts/feishu/feishu.go b/shortcuts/feishu/feishu.go new file mode 100644 index 0000000..c58f537 --- /dev/null +++ b/shortcuts/feishu/feishu.go @@ -0,0 +1,184 @@ +package feishu + +import ( + "fmt" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" + "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" +) + +const ( + defaultInclude = "issues,prs,contributors,health" + defaultTables = "issues,prs,contributors,reports" + defaultLang = "en" +) + +// Shortcuts returns Feishu export shortcuts. Commands default to local preview; +// network delivery requires an explicit --send flag. +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + return []*common.Shortcut{ + newBotTestShortcut(), + newNotifyShortcut(), + newWeeklyReportShortcut(), + newBitableSchemaShortcut(), + newBitableRecordsShortcut(), + } +} + +func newBotTestShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "bot-test", + Description: "Preview or send a Feishu custom bot test card", + Flags: append(deliveryFlags(), + common.Flag{Name: "title", Usage: "Card title", Default: "GitLink Feishu integration test"}, + common.Flag{Name: "message", Usage: "Card message", Default: "gitlink-cli can build and send Feishu custom bot cards."}, + common.Flag{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang}, + ), + Run: runBotTest, + } +} + +func newNotifyShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "notify", + Description: "Preview or send a Feishu card from workflow JSON", + Flags: append(deliveryFlags(), + common.Flag{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true}, + common.Flag{Name: "include", Usage: "Comma-separated sections: issues,prs,contributors,health", Default: defaultInclude}, + common.Flag{Name: "title", Usage: "Override card title"}, + common.Flag{Name: "doc-url", Usage: "Feishu DocX or Wiki URL to include in the card"}, + common.Flag{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang}, + ), + Run: runNotify, + } +} + +func newWeeklyReportShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "weekly-report", + Description: "Render a weekly report from workflow JSON and optionally send it to Feishu", + Flags: append(deliveryFlags(), + common.Flag{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true}, + common.Flag{Name: "include", Usage: "Comma-separated sections: issues,prs,contributors,health", Default: defaultInclude}, + common.Flag{Name: "title", Usage: "Override card title"}, + common.Flag{Name: "doc-url", Usage: "Feishu DocX or Wiki URL to include in the card"}, + common.Flag{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang}, + ), + Run: runWeeklyReport, + } +} + +func newBitableSchemaShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "bitable-schema", + Description: "Generate a dry-run Feishu Bitable schema", + Flags: []common.Flag{ + {Name: "tables", Usage: "Comma-separated tables: issues,prs,contributors,reports", Default: defaultTables}, + {Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang}, + }, + Run: runBitableSchema, + } +} + +func newBitableRecordsShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "bitable-records", + Description: "Generate dry-run Feishu Bitable-ready records from workflow JSON", + Flags: []common.Flag{ + {Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true}, + {Name: "tables", Usage: "Comma-separated tables: issues,prs,contributors,reports", Default: defaultTables}, + {Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang}, + }, + Run: runBitableRecords, + } +} + +func deliveryFlags() []common.Flag { + return []common.Flag{ + {Name: "webhook-url", Usage: "Feishu custom bot webhook URL. Defaults to FEISHU_WEBHOOK_URL"}, + {Name: "secret", Usage: "Feishu custom bot signing secret. Defaults to FEISHU_WEBHOOK_SECRET"}, + {Name: "send", Usage: "Send to Feishu. Without --send, commands only preview locally", Bool: true, Default: "false"}, + {Name: "dry-run", Usage: "Force local preview. Cannot be combined with --send", Bool: true, Default: "false"}, + } +} + +func runBotTest(ctx *common.RuntimeContext) error { + opts, err := deliveryOptionsFromContext(ctx) + if err != nil { + return err + } + card := BuildBotTestCard(ctx.Arg("title"), ctx.Arg("message"), normalizeLang(ctx.Arg("lang"))) + payload := NewInteractivePayload(card) + return deliverOrPreview(ctx, opts, payload, "") +} + +func runNotify(ctx *common.RuntimeContext) error { + opts, err := deliveryOptionsFromContext(ctx) + if err != nil { + return err + } + report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang"))) + if err != nil { + return err + } + include := parseList(firstNonEmpty(ctx.Arg("include"), defaultInclude)) + card := BuildWorkflowCard(report, include, firstNonEmpty(ctx.Arg("title"), ""), normalizeLang(ctx.Arg("lang")), ctx.Arg("doc-url")) + payload := NewInteractivePayload(card) + return deliverOrPreview(ctx, opts, payload, "") +} + +func runWeeklyReport(ctx *common.RuntimeContext) error { + opts, err := deliveryOptionsFromContext(ctx) + if err != nil { + return err + } + report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang"))) + if err != nil { + return err + } + if opts.Send { + include := parseList(firstNonEmpty(ctx.Arg("include"), defaultInclude)) + card := BuildWorkflowCard(report, include, firstNonEmpty(ctx.Arg("title"), "GitLink weekly workflow report"), normalizeLang(ctx.Arg("lang")), ctx.Arg("doc-url")) + return deliverOrPreview(ctx, opts, NewInteractivePayload(card), "") + } + rendered, err := workflow.RenderRepoReport(report, formatOrDefault(ctx, "markdown"), normalizeLang(ctx.Arg("lang"))) + if err != nil { + return err + } + _, err = fmt.Fprint(os.Stdout, rendered) + return err +} + +func runBitableSchema(ctx *common.RuntimeContext) error { + schema := BuildBitableSchema(parseList(firstNonEmpty(ctx.Arg("tables"), defaultTables))) + return renderBitableSchema(os.Stdout, schema, formatOrDefault(ctx, "markdown")) +} + +func runBitableRecords(ctx *common.RuntimeContext) error { + report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang"))) + if err != nil { + return err + } + records := BuildBitableRecords(report, parseList(firstNonEmpty(ctx.Arg("tables"), defaultTables))) + return renderBitableRecords(os.Stdout, records, formatOrDefault(ctx, "json")) +} + +func normalizeLang(lang string) string { + switch strings.TrimSpace(lang) { + case "zh-CN": + return "zh-CN" + default: + return defaultLang + } +} + +func formatOrDefault(ctx *common.RuntimeContext, defaultFormat string) string { + if strings.TrimSpace(cmdutil.Format) == "" { + return defaultFormat + } + return ctx.Format +} diff --git a/shortcuts/feishu/feishu_test.go b/shortcuts/feishu/feishu_test.go new file mode 100644 index 0000000..92de326 --- /dev/null +++ b/shortcuts/feishu/feishu_test.go @@ -0,0 +1,138 @@ +package feishu + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestShortcutsExposeExpectedCommands(t *testing.T) { + got := map[string]bool{} + for _, shortcut := range Shortcuts() { + got[shortcut.Name] = true + } + for _, name := range []string{"bot-test", "notify", "weekly-report", "bitable-schema", "bitable-records"} { + if !got[name] { + t.Fatalf("Shortcuts missing %s", name) + } + } +} + +func TestDeliveryOptionsRejectSendDryRun(t *testing.T) { + ctx := &common.RuntimeContext{Args: map[string]string{ + "send": "true", + "dry-run": "true", + "webhook-url": "https://open.feishu.cn/open-apis/bot/v2/hook/test", + }} + _, err := deliveryOptionsFromContext(ctx) + if err == nil { + t.Fatal("expected --send --dry-run error") + } +} + +func TestDeliveryOptionsRequireWebhookForSend(t *testing.T) { + ctx := &common.RuntimeContext{Args: map[string]string{"send": "true"}} + _, err := deliveryOptionsFromContext(ctx) + if err == nil { + t.Fatal("expected missing webhook error") + } +} + +func TestRedactWebhookURL(t *testing.T) { + got := redactWebhookURL("https://open.feishu.cn/open-apis/bot/v2/hook/12345678-1234-1234-1234-123456789abc") + if strings.Contains(got, "1234-1234") || !strings.Contains(got, "https://open.feishu.cn/.../") { + t.Fatalf("redacted webhook URL leaked too much: %s", got) + } +} + +func TestSignCustomBotRequestIsDeterministic(t *testing.T) { + first := SignCustomBotRequest(1710000000, "secret") + second := SignCustomBotRequest(1710000000, "secret") + if first == "" || first != second { + t.Fatalf("signature not deterministic: first=%q second=%q", first, second) + } +} + +func TestBuildWorkflowCardIncludesDocButton(t *testing.T) { + report, err := readWorkflowReport(filepath.Join("..", "workflow", "testdata", "repo_report.json"), "en") + if err != nil { + t.Fatalf("readWorkflowReport returned error: %v", err) + } + card := BuildWorkflowCard(report, parseList(defaultInclude), "", "en", "https://example.feishu.cn/wiki/node") + encoded, err := json.Marshal(card) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + if !strings.Contains(string(encoded), "Open Feishu report") { + t.Fatalf("card missing doc button: %s", string(encoded)) + } +} + +func TestWebhookClientSendsPayload(t *testing.T) { + var sawTimestamp bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + var payload WebhookPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + sawTimestamp = payload.Timestamp != "" && payload.Sign != "" + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"msg":"success"}`)) + })) + defer server.Close() + + client := WebhookClient{ + URL: server.URL, + Secret: "secret", + HTTP: server.Client(), + Now: func() time.Time { return time.Unix(1710000000, 0) }, + } + resp, err := client.Send(context.Background(), NewInteractivePayload(BuildBotTestCard("", "", "en"))) + if err != nil { + t.Fatalf("Send returned error: %v", err) + } + if resp.StatusCode != http.StatusOK || resp.Code != 0 { + t.Fatalf("response = %+v", resp) + } + if !sawTimestamp { + t.Fatal("signed payload missing timestamp/sign") + } +} + +func TestReadWorkflowReportSupportsInputFixture(t *testing.T) { + report, err := readWorkflowReport(filepath.Join("..", "workflow", "testdata", "repo_report.json"), "en") + if err != nil { + t.Fatalf("readWorkflowReport returned error: %v", err) + } + if report.Repository != "Gitlink/gitlink-cli" || report.ReportScore == 0 { + t.Fatalf("report = %+v", report) + } +} + +func TestBitableSchemaAndRecords(t *testing.T) { + report, err := readWorkflowReport(filepath.Join("..", "workflow", "testdata", "repo_report.json"), "en") + if err != nil { + t.Fatalf("readWorkflowReport returned error: %v", err) + } + schema := BuildBitableSchema(parseList("issues,prs,reports")) + if len(schema.Tables) != 3 { + t.Fatalf("schema table count = %d", len(schema.Tables)) + } + records := BuildBitableRecords(report, parseList("issues,prs,reports")) + 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"])) + } +} diff --git a/shortcuts/feishu/options.go b/shortcuts/feishu/options.go new file mode 100644 index 0000000..c67f056 --- /dev/null +++ b/shortcuts/feishu/options.go @@ -0,0 +1,113 @@ +package feishu + +import ( + "fmt" + "net/url" + "os" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type DeliveryOptions struct { + WebhookURL string `json:"-"` + Secret string `json:"-"` + Send bool `json:"send"` + DryRun bool `json:"dry_run"` +} + +func deliveryOptionsFromContext(ctx *common.RuntimeContext) (DeliveryOptions, error) { + opts := DeliveryOptions{ + WebhookURL: firstNonEmpty(ctx.Arg("webhook-url"), os.Getenv("FEISHU_WEBHOOK_URL")), + Secret: firstNonEmpty(ctx.Arg("secret"), os.Getenv("FEISHU_WEBHOOK_SECRET")), + Send: parseBool(ctx.Arg("send")), + DryRun: parseBool(ctx.Arg("dry-run")), + } + if opts.Send && opts.DryRun { + return DeliveryOptions{}, fmt.Errorf("--send and --dry-run cannot be used together") + } + if opts.Send && strings.TrimSpace(opts.WebhookURL) == "" { + return DeliveryOptions{}, fmt.Errorf("--send requires --webhook-url or FEISHU_WEBHOOK_URL") + } + if opts.WebhookURL != "" { + if err := validateWebhookURL(opts.WebhookURL); err != nil { + return DeliveryOptions{}, err + } + } + return opts, nil +} + +func validateWebhookURL(raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("invalid Feishu webhook URL") + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return fmt.Errorf("invalid Feishu webhook URL: scheme must be https or http") + } + return nil +} + +func redactWebhookURL(raw string) string { + if strings.TrimSpace(raw) == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return "***" + } + path := strings.Trim(parsed.EscapedPath(), "/") + parts := strings.Split(path, "/") + last := "" + if len(parts) > 0 { + last = parts[len(parts)-1] + } + if len(last) > 8 { + last = last[:4] + "..." + last[len(last)-4:] + } else if last != "" { + last = "***" + } + return parsed.Scheme + "://" + parsed.Host + "/.../" + last +} + +func parseBool(value string) bool { + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + return err == nil && parsed +} + +func parseList(value string) []string { + parts := strings.Split(value, ",") + seen := map[string]bool{} + result := []string{} + for _, part := range parts { + part = strings.ToLower(strings.TrimSpace(part)) + if part == "" || seen[part] { + continue + } + if part == "pulls" { + part = "prs" + } + seen[part] = true + result = append(result, part) + } + return result +} + +func hasItem(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/shortcuts/feishu/render.go b/shortcuts/feishu/render.go new file mode 100644 index 0000000..74c1f4a --- /dev/null +++ b/shortcuts/feishu/render.go @@ -0,0 +1,118 @@ +package feishu + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type DeliveryOutput struct { + Mode string `json:"mode"` + Send bool `json:"send"` + DryRun bool `json:"dry_run"` + WebhookURL string `json:"webhook_url,omitempty"` + Payload WebhookPayload `json:"payload"` + Response *WebhookResponse `json:"response,omitempty"` +} + +func deliverOrPreview(ctx *common.RuntimeContext, opts DeliveryOptions, payload WebhookPayload, markdown string) error { + output := DeliveryOutput{ + Mode: "preview", + Send: opts.Send, + DryRun: !opts.Send, + WebhookURL: redactWebhookURL(opts.WebhookURL), + Payload: payload, + } + if opts.Send { + client := WebhookClient{ + URL: opts.WebhookURL, + Secret: opts.Secret, + } + resp, err := client.Send(context.Background(), payload) + output.Mode = "sent" + output.DryRun = false + output.Response = resp + if err != nil { + _ = renderDeliveryOutput(os.Stdout, output, ctx.Format, markdown) + return err + } + } + return renderDeliveryOutput(os.Stdout, output, ctx.Format, markdown) +} + +func renderDeliveryOutput(w io.Writer, output DeliveryOutput, format string, markdown string) error { + switch normalizeFormat(format) { + case "markdown": + if markdown != "" { + _, err := fmt.Fprint(w, markdown) + return err + } + return writeDeliveryMarkdown(w, output) + case "table": + return writeDeliveryTable(w, output) + default: + return writeJSON(w, output) + } +} + +func writeDeliveryMarkdown(w io.Writer, output DeliveryOutput) error { + lines := []string{ + "# Feishu Delivery Preview", + "", + fmt.Sprintf("- Mode: `%s`", output.Mode), + fmt.Sprintf("- Send: `%t`", output.Send), + fmt.Sprintf("- Dry run: `%t`", output.DryRun), + } + if output.WebhookURL != "" { + lines = append(lines, fmt.Sprintf("- Webhook: `%s`", output.WebhookURL)) + } + if output.Response != nil { + lines = append(lines, fmt.Sprintf("- HTTP status: `%d`", output.Response.StatusCode)) + lines = append(lines, fmt.Sprintf("- Feishu code: `%d`", output.Response.Code)) + if output.Response.Message != "" { + lines = append(lines, fmt.Sprintf("- Message: `%s`", output.Response.Message)) + } + } + _, err := fmt.Fprintln(w, strings.Join(lines, "\n")) + return err +} + +func writeDeliveryTable(w io.Writer, output DeliveryOutput) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "MODE\tSEND\tDRY_RUN\tWEBHOOK\tHTTP_STATUS\tFEISHU_CODE"); err != nil { + return err + } + status := "" + code := "" + if output.Response != nil { + status = fmt.Sprintf("%d", output.Response.StatusCode) + code = fmt.Sprintf("%d", output.Response.Code) + } + if _, err := fmt.Fprintf(tw, "%s\t%t\t%t\t%s\t%s\t%s\n", output.Mode, output.Send, output.DryRun, output.WebhookURL, status, code); err != nil { + return err + } + return tw.Flush() +} + +func writeJSON(w io.Writer, data interface{}) error { + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(encoded)) + return err +} + +func normalizeFormat(format string) string { + format = strings.ToLower(strings.TrimSpace(format)) + if format == "" { + return "json" + } + return format +} diff --git a/shortcuts/feishu/sign.go b/shortcuts/feishu/sign.go new file mode 100644 index 0000000..1af79b1 --- /dev/null +++ b/shortcuts/feishu/sign.go @@ -0,0 +1,21 @@ +package feishu + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "strconv" + "time" +) + +// SignCustomBotRequest implements the Feishu custom bot signature algorithm. +// Feishu uses timestamp + "\n" + secret as the HMAC key and signs an empty body. +func SignCustomBotRequest(timestamp int64, secret string) string { + stringToSign := strconv.FormatInt(timestamp, 10) + "\n" + secret + mac := hmac.New(sha256.New, []byte(stringToSign)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +func timestampSeconds(now time.Time) int64 { + return now.Unix() +} diff --git a/shortcuts/feishu/workflow_input.go b/shortcuts/feishu/workflow_input.go new file mode 100644 index 0000000..60553a9 --- /dev/null +++ b/shortcuts/feishu/workflow_input.go @@ -0,0 +1,81 @@ +package feishu + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" +) + +func readWorkflowReport(path string, lang string) (workflow.RepoReportResult, error) { + data, err := os.ReadFile(path) + if err != nil { + return workflow.RepoReportResult{}, fmt.Errorf("read workflow JSON: %w", err) + } + data, err = unwrapWorkflowJSON(data) + if err != nil { + return workflow.RepoReportResult{}, err + } + + var result workflow.RepoReportResult + if err := json.Unmarshal(data, &result); err == nil && looksLikeRepoReportResult(result) { + return normalizeReportResult(result), nil + } + + var input workflow.RepoReportInput + if err := json.Unmarshal(data, &input); err == nil && looksLikeRepoReportInput(input) { + return workflow.AnalyzeRepoReport(input, lang), nil + } + + return workflow.RepoReportResult{}, fmt.Errorf("parse workflow JSON: expected workflow RepoReportResult or RepoReportInput") +} + +func unwrapWorkflowJSON(data []byte) ([]byte, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse workflow JSON: %w", err) + } + for _, key := range []string{"data", "repo_report", "report"} { + if value, ok := raw[key]; ok && len(value) > 0 && string(value) != "null" { + return value, nil + } + } + return data, nil +} + +func looksLikeRepoReportResult(result workflow.RepoReportResult) bool { + return strings.TrimSpace(result.Repository) != "" && + (strings.TrimSpace(result.RiskLevel) != "" || + result.ReportScore != 0 || + result.IssueSummary.Total != 0 || + result.PRSummary.Total != 0 || + len(result.Recommendations) > 0) +} + +func looksLikeRepoReportInput(input workflow.RepoReportInput) bool { + return strings.TrimSpace(input.Repository) != "" || + input.Health != nil || + len(input.Issues) > 0 || + len(input.PullRequests) > 0 +} + +func normalizeReportResult(result workflow.RepoReportResult) workflow.RepoReportResult { + if strings.TrimSpace(result.Source) == "" { + result.Source = "workflow-json" + } + if result.IssueSummary.ByType == nil { + result.IssueSummary.ByType = map[string]int{} + } + if result.IssueSummary.ByPriority == nil { + result.IssueSummary.ByPriority = map[string]int{} + } + if result.PRSummary.ByType == nil { + result.PRSummary.ByType = map[string]int{} + } + if result.PRSummary.ByRisk == nil { + result.PRSummary.ByRisk = map[string]int{} + } + return result +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..8855031 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -9,6 +9,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" + "github.com/gitlink-org/gitlink-cli/shortcuts/feishu" "github.com/gitlink-org/gitlink-cli/shortcuts/health" "github.com/gitlink-org/gitlink-cli/shortcuts/ignore" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" @@ -53,6 +54,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), + "feishu": feishu.Shortcuts(tr), "webhook": webhook.Shortcuts(tr), "wiki": wiki.Shortcuts(), "health": health.Shortcuts(tr), @@ -78,6 +80,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"), + "feishu": "Export GitLink workflow data to Feishu", "webhook": tr.T("cmd.webhook.short"), "wiki": "Wiki page management", "health": "Project health data collection", diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 00f4c57..592c412 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) { "repo", "issue", "label", "license", "pr", "profile", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "dataset", "health", "ignore", "wiki", + "dataset", "health", "ignore", "wiki", "feishu", } groupSet := map[string]bool{} From d8cb4aa9d2922031d773e5faa3c86adca555ab5e Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Mon, 15 Jun 2026 15:44:53 +0800 Subject: [PATCH 03/16] feat: add feishu doc export --- docs/feishu-integration.md | 130 +++++++ examples/feishu/README.md | 45 +++ .../OFFICIAL_DOCS_ALIGNMENT.md | 14 + reports/FEISHU_TASK_COMPLETION.md | 26 +- shortcuts/feishu/doc_export.go | 329 ++++++++++++++++++ shortcuts/feishu/feishu.go | 34 ++ shortcuts/feishu/feishu_test.go | 84 ++++- shortcuts/feishu/openapi.go | 242 +++++++++++++ skills/gitlink-feishu/SKILL.md | 79 +++++ 9 files changed, 979 insertions(+), 4 deletions(-) create mode 100644 docs/feishu-integration.md create mode 100644 examples/feishu/README.md create mode 100644 shortcuts/feishu/doc_export.go create mode 100644 shortcuts/feishu/openapi.go create mode 100644 skills/gitlink-feishu/SKILL.md diff --git a/docs/feishu-integration.md b/docs/feishu-integration.md new file mode 100644 index 0000000..c6b4f4b --- /dev/null +++ b/docs/feishu-integration.md @@ -0,0 +1,130 @@ +# Feishu Integration + +`gitlink-cli feishu` exports local GitLink workflow JSON to Feishu collaboration surfaces. + +The commands are intentionally one-way: + +```text +workflow JSON -> local preview +workflow JSON -> Feishu bot card +workflow JSON -> Feishu DocX / Wiki report +workflow JSON -> Bitable-ready dry-run records +``` + +## Safety Model + +Default behavior is local preview. + +Network operations require `--send`. + +`--send` and `--dry-run` cannot be used together. + +The implementation does not write to GitLink resources, does not close issues, does not comment on PRs, and does not merge code. + +## Custom Bot + +Use a Feishu custom group bot for notification cards. + +Environment: + +```powershell +$env:FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/..." +$env:FEISHU_WEBHOOK_SECRET="optional signing secret" +``` + +Preview: + +```bash +gitlink-cli feishu +bot-test --format json +``` + +Send: + +```bash +gitlink-cli feishu +bot-test --send --format table +``` + +Send a workflow report card: + +```bash +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json > report.json +gitlink-cli feishu +notify --from-workflow-json report.json --send --format table +``` + +Send a card with a DocX or Wiki link: + +```bash +gitlink-cli feishu +notify \ + --from-workflow-json report.json \ + --doc-url "https://example.feishu.cn/wiki/..." \ + --send \ + --format table +``` + +## DocX / Wiki Export + +Use a Feishu self-built app for document export. + +Environment: + +```powershell +$env:FEISHU_APP_ID="cli_xxx" +$env:FEISHU_APP_SECRET="..." +``` + +Preview: + +```bash +gitlink-cli feishu +doc-export \ + --from-workflow-json report.json \ + --wiki-url "https://example.feishu.cn/wiki/..." \ + --format markdown +``` + +Append report blocks to a Wiki-backed DocX: + +```bash +gitlink-cli feishu +doc-export \ + --from-workflow-json report.json \ + --wiki-url "https://example.feishu.cn/wiki/..." \ + --send \ + --format table +``` + +Create a new DocX in a folder: + +```bash +gitlink-cli feishu +doc-export \ + --from-workflow-json report.json \ + --folder-token "<folder_token>" \ + --title "GitLink workflow report" \ + --send \ + --format table +``` + +Required Feishu setup: + +```text +1. The self-built app must have DocX / Drive application scopes. +2. The app permission version must be published and approved. +3. The target Wiki / DocX / folder must grant the app write permission. +``` + +If Feishu returns `1770032: forBidden`, the token is valid but the app cannot write to the target document. Grant the app access to that Wiki/DocX page or use a folder where the app can create documents. + +## Bitable Dry Run + +Generate recommended table schemas: + +```bash +gitlink-cli feishu +bitable-schema --format markdown +``` + +Generate Bitable-ready records: + +```bash +gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json +``` + +These commands do not call Bitable OpenAPI and do not create, update, or upsert records. + diff --git a/examples/feishu/README.md b/examples/feishu/README.md new file mode 100644 index 0000000..132e698 --- /dev/null +++ b/examples/feishu/README.md @@ -0,0 +1,45 @@ +# Feishu Export Examples + +## 1. Generate Workflow JSON + +```bash +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json > report.json +``` + +## 2. Preview Notification Card + +```bash +gitlink-cli feishu +notify --from-workflow-json report.json --format json +``` + +## 3. Send Notification Card + +```bash +gitlink-cli feishu +notify --from-workflow-json report.json --send --format table +``` + +## 4. Preview Wiki Export + +```bash +gitlink-cli feishu +doc-export \ + --from-workflow-json report.json \ + --wiki-url "https://example.feishu.cn/wiki/..." \ + --format markdown +``` + +## 5. Export To Wiki + +```bash +gitlink-cli feishu +doc-export \ + --from-workflow-json report.json \ + --wiki-url "https://example.feishu.cn/wiki/..." \ + --send \ + --format table +``` + +## 6. Generate Bitable Records + +```bash +gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json +``` + diff --git a/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md b/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md index 22be782..69b7af7 100644 --- a/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md +++ b/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md @@ -207,6 +207,20 @@ Bot card = notification and entry link. Bitable records = structured data for later dashboards. ``` +Observed permission behavior: + +```text +tenant_access_token acquisition succeeded. +Wiki get_node succeeded. +DocX create-block failed with HTTP 403 / code 1770032 / forBidden. +``` + +This means the design must include explicit permission diagnostics: + +```text +The self-built app must have both approved DocX/Drive scopes and write access to the target Wiki/DocX page or folder. +``` + ## Bitable Real Write Requirements Keep current `+bitable-schema` and `+bitable-records` as dry-run commands. diff --git a/reports/FEISHU_TASK_COMPLETION.md b/reports/FEISHU_TASK_COMPLETION.md index 9a77c99..b4f0f69 100644 --- a/reports/FEISHU_TASK_COMPLETION.md +++ b/reports/FEISHU_TASK_COMPLETION.md @@ -30,6 +30,11 @@ gitlink-cli feishu +bitable-records - Added project activity card generation from workflow JSON. - Added weekly report rendering from workflow JSON. - Added `--doc-url` support for notification cards. +- Added `feishu +doc-export` for Feishu DocX / Wiki export. +- Added self-built app tenant token acquisition. +- Added Wiki node resolution. +- Added DocX block creation client. +- Added document export preview and explicit `--send` behavior. - Added Bitable dry-run schema generation. - Added Bitable-ready dry-run records. - Registered the new shortcut group in `shortcuts/register.go`. @@ -74,6 +79,22 @@ object token: present No document content was modified in this check. +The first real DocX block write attempt reached the DocX block endpoint but Feishu rejected the write: + +```text +HTTP status: 403 +Feishu code: 1770032 +Message: forBidden +``` + +Interpretation: + +```text +The app credentials are valid and the Wiki node is readable, but the app does not currently have write permission on the target Wiki-backed DocX page or lacks the required document scope approval. +``` + +The command now reports a permission hint for this case. + ## Knowledge Base Design Update Added official-docs alignment notes: @@ -128,9 +149,8 @@ DocX content write ## Next Engineering Step -Add `feishu +doc-export` as the first self-built app integration: +Complete the Feishu document permission setup and rerun: ```text -app_id/app_secret -> tenant_access_token -> resolve Wiki node or create DocX -> write report blocks -> return doc URL +gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url <wiki_url> --send --format table ``` - diff --git a/shortcuts/feishu/doc_export.go b/shortcuts/feishu/doc_export.go new file mode 100644 index 0000000..afba168 --- /dev/null +++ b/shortcuts/feishu/doc_export.go @@ -0,0 +1,329 @@ +package feishu + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" + "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" +) + +type DocExportOptions struct { + AppID string `json:"-"` + AppSecret string `json:"-"` + FolderToken string `json:"folder_token,omitempty"` + DocumentID string `json:"document_id,omitempty"` + WikiURL string `json:"wiki_url,omitempty"` + WikiNodeToken string `json:"wiki_node_token,omitempty"` + Title string `json:"title"` + Send bool `json:"send"` + DryRun bool `json:"dry_run"` +} + +type DocExportOutput struct { + Mode string `json:"mode"` + Send bool `json:"send"` + DryRun bool `json:"dry_run"` + TargetType string `json:"target_type"` + Operation string `json:"operation"` + Title string `json:"title"` + DocumentID string `json:"document_id,omitempty"` + DocumentURL string `json:"document_url,omitempty"` + WikiNodeToken string `json:"wiki_node_token,omitempty"` + WikiNode *WikiNodeSummary `json:"wiki_node,omitempty"` + BlockCount int `json:"block_count"` + TokenExpire int `json:"token_expire,omitempty"` + RevisionID int `json:"revision_id,omitempty"` + Preview string `json:"preview,omitempty"` +} + +type WikiNodeSummary struct { + NodeType string `json:"node_type,omitempty"` + ObjType string `json:"obj_type,omitempty"` + Title string `json:"title,omitempty"` +} + +type DocBlock map[string]interface{} + +func docExportOptionsFromContext(ctx *common.RuntimeContext) (DocExportOptions, error) { + opts := DocExportOptions{ + AppID: firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")), + AppSecret: firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET")), + FolderToken: firstNonEmpty(ctx.Arg("folder-token"), os.Getenv("FEISHU_DOC_FOLDER_TOKEN")), + DocumentID: firstNonEmpty(ctx.Arg("document-id"), os.Getenv("FEISHU_DOCUMENT_ID")), + WikiURL: firstNonEmpty(ctx.Arg("wiki-url"), os.Getenv("FEISHU_WIKI_URL")), + WikiNodeToken: firstNonEmpty(ctx.Arg("wiki-node-token"), os.Getenv("FEISHU_WIKI_NODE_TOKEN")), + Title: strings.TrimSpace(ctx.Arg("title")), + Send: parseBool(ctx.Arg("send")), + DryRun: parseBool(ctx.Arg("dry-run")), + } + if opts.WikiNodeToken == "" && opts.WikiURL != "" { + opts.WikiNodeToken = wikiNodeTokenFromURL(opts.WikiURL) + } + if opts.DocumentID == "" && opts.WikiURL != "" { + opts.DocumentID = docxTokenFromURL(opts.WikiURL) + } + if opts.Send && opts.DryRun { + return DocExportOptions{}, fmt.Errorf("--send and --dry-run cannot be used together") + } + if opts.Send { + if strings.TrimSpace(opts.AppID) == "" { + return DocExportOptions{}, fmt.Errorf("--send requires --app-id or FEISHU_APP_ID") + } + if strings.TrimSpace(opts.AppSecret) == "" { + return DocExportOptions{}, fmt.Errorf("--send requires --app-secret or FEISHU_APP_SECRET") + } + if opts.FolderToken == "" && opts.DocumentID == "" && opts.WikiNodeToken == "" { + return DocExportOptions{}, fmt.Errorf("--send requires --folder-token, --document-id, --wiki-url, or --wiki-node-token") + } + } + return opts, nil +} + +func exportDocOrPreview(ctx *common.RuntimeContext, opts DocExportOptions, report workflow.RepoReportResult, lang string) error { + title := firstNonEmpty(opts.Title, "GitLink workflow report: "+report.Repository) + markdown, err := workflow.RenderRepoReport(report, "markdown", lang) + if err != nil { + return err + } + blocks := BuildDocBlocks(report, lang) + output := DocExportOutput{ + Mode: "preview", + Send: opts.Send, + DryRun: !opts.Send, + TargetType: docTargetType(opts), + Operation: docOperation(opts), + Title: title, + DocumentID: opts.DocumentID, + DocumentURL: firstNonEmpty(opts.WikiURL), + BlockCount: len(blocks), + Preview: markdown, + } + if opts.WikiNodeToken != "" { + output.WikiNodeToken = opts.WikiNodeToken + } + if !opts.Send { + return renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "markdown")) + } + + client := NewOpenAPIClient(http.DefaultClient) + token, err := client.TenantAccessToken(context.Background(), opts.AppID, opts.AppSecret) + if err != nil { + return err + } + output.TokenExpire = token.Expire + + documentID := opts.DocumentID + if opts.WikiNodeToken != "" { + node, err := client.GetWikiNode(context.Background(), token.Value, opts.WikiNodeToken) + if err != nil { + return err + } + output.TargetType = "wiki" + if node.ObjType != "" && node.ObjType != "docx" { + return fmt.Errorf("Feishu wiki node object type %q is not supported; expected docx", node.ObjType) + } + documentID = node.ObjToken + output.DocumentID = documentID + output.WikiNode = &WikiNodeSummary{ + NodeType: node.NodeType, + ObjType: node.ObjType, + Title: node.Title, + } + if output.DocumentURL == "" { + output.DocumentURL = node.URL + } + } + if documentID == "" { + created, err := client.CreateDocument(context.Background(), token.Value, opts.FolderToken, title) + if err != nil { + return err + } + documentID = created.DocumentID + output.DocumentID = created.DocumentID + output.DocumentURL = created.URL + output.RevisionID = created.RevisionID + output.Operation = "create" + } + createdBlocks, err := client.CreateBlocks(context.Background(), token.Value, documentID, documentID, blocks) + if err != nil { + return fmt.Errorf("%w\nhint: grant the Feishu self-built app edit access to the target DocX/Wiki page, or export to a folder where the app has document creation permission", err) + } + if createdBlocks.RevisionID != 0 { + output.RevisionID = createdBlocks.RevisionID + } + output.Mode = "sent" + output.DryRun = false + output.Preview = "" + return renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json")) +} + +func BuildDocBlocks(report workflow.RepoReportResult, lang string) []DocBlock { + healthScore := "N/A" + healthRisk := "N/A" + if report.Health != nil { + healthScore = fmt.Sprintf("%d", report.Health.HealthScore) + healthRisk = report.Health.RiskLevel + } + blocks := []DocBlock{ + textBlock("GitLink workflow report: " + report.Repository), + textBlock(fmt.Sprintf("Report score: %d", report.ReportScore)), + textBlock("Risk level: " + firstNonEmpty(report.RiskLevel, "unknown")), + textBlock(fmt.Sprintf("Health score: %s; health risk: %s", healthScore, healthRisk)), + textBlock(fmt.Sprintf("Issues: total=%d, high_risk=%d, missing_info=%d", report.IssueSummary.Total, report.IssueSummary.HighRisk, report.IssueSummary.MissingInfo)), + textBlock(fmt.Sprintf("Pull Requests: total=%d, high_risk=%d", report.PRSummary.Total, report.PRSummary.HighRisk)), + } + if len(report.PRSummary.ReviewFocus) > 0 { + blocks = append(blocks, textBlock("Review focus:\n"+joinLines(report.PRSummary.ReviewFocus, 6))) + } + if len(report.Recommendations) > 0 { + blocks = append(blocks, textBlock("Recommendations:\n"+joinLines(report.Recommendations, 8))) + } + if len(report.Reasoning) > 0 { + blocks = append(blocks, textBlock("Reasoning:\n"+joinLines(report.Reasoning, 8))) + } + blocks = append(blocks, textBlock("Source: "+firstNonEmpty(report.Source, "workflow-json"))) + return blocks +} + +func textBlock(content string) DocBlock { + return DocBlock{ + "block_type": 2, + "text": map[string]interface{}{ + "elements": []interface{}{ + map[string]interface{}{ + "text_run": map[string]interface{}{ + "content": content, + }, + }, + }, + }, + } +} + +func renderDocExportOutput(w io.Writer, output DocExportOutput, format string) error { + switch normalizeFormat(format) { + case "json": + return writeJSON(w, output) + case "table": + return writeDocExportTable(w, output) + default: + return writeDocExportMarkdown(w, output) + } +} + +func writeDocExportMarkdown(w io.Writer, output DocExportOutput) error { + if _, err := fmt.Fprintf(w, "# Feishu Doc Export\n\n"); err != nil { + return err + } + lines := []string{ + fmt.Sprintf("- Mode: `%s`", output.Mode), + fmt.Sprintf("- Send: `%t`", output.Send), + fmt.Sprintf("- Dry run: `%t`", output.DryRun), + fmt.Sprintf("- Target: `%s`", output.TargetType), + fmt.Sprintf("- Operation: `%s`", output.Operation), + fmt.Sprintf("- Title: `%s`", output.Title), + fmt.Sprintf("- Blocks: `%d`", output.BlockCount), + } + if output.DocumentID != "" { + lines = append(lines, fmt.Sprintf("- Document ID: `%s`", output.DocumentID)) + } + if output.DocumentURL != "" { + lines = append(lines, fmt.Sprintf("- Document URL: %s", output.DocumentURL)) + } + if _, err := fmt.Fprintln(w, strings.Join(lines, "\n")); err != nil { + return err + } + if output.Preview != "" { + if _, err := fmt.Fprint(w, "\n## Preview\n\n"); err != nil { + return err + } + _, err := fmt.Fprint(w, output.Preview) + return err + } + return nil +} + +func writeDocExportTable(w io.Writer, output DocExportOutput) error { + _, err := fmt.Fprintf(w, "MODE\tSEND\tDRY_RUN\tTARGET\tOPERATION\tBLOCKS\tDOCUMENT\n%s\t%t\t%t\t%s\t%s\t%d\t%s\n", + output.Mode, + output.Send, + output.DryRun, + output.TargetType, + output.Operation, + output.BlockCount, + firstNonEmpty(output.DocumentURL, output.DocumentID), + ) + return err +} + +func docTargetType(opts DocExportOptions) string { + switch { + case opts.WikiNodeToken != "": + return "wiki" + case opts.DocumentID != "": + return "docx" + case opts.FolderToken != "": + return "folder" + default: + return "preview" + } +} + +func docOperation(opts DocExportOptions) string { + if opts.FolderToken != "" && opts.DocumentID == "" && opts.WikiNodeToken == "" { + return "create" + } + if opts.DocumentID != "" || opts.WikiNodeToken != "" { + return "append" + } + return "preview" +} + +func wikiNodeTokenFromURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "" + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + for i, part := range parts { + if part == "wiki" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +func docxTokenFromURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "" + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + for i, part := range parts { + if part == "docx" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +func joinLines(values []string, limit int) string { + if limit <= 0 || limit > len(values) { + limit = len(values) + } + lines := make([]string, 0, limit) + for _, value := range values[:limit] { + value = strings.TrimSpace(value) + if value == "" { + continue + } + lines = append(lines, "- "+value) + } + return strings.Join(lines, "\n") +} diff --git a/shortcuts/feishu/feishu.go b/shortcuts/feishu/feishu.go index c58f537..920504d 100644 --- a/shortcuts/feishu/feishu.go +++ b/shortcuts/feishu/feishu.go @@ -24,6 +24,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { newBotTestShortcut(), newNotifyShortcut(), newWeeklyReportShortcut(), + newDocExportShortcut(), newBitableSchemaShortcut(), newBitableRecordsShortcut(), } @@ -72,6 +73,27 @@ func newWeeklyReportShortcut() *common.Shortcut { } } +func newDocExportShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "doc-export", + Description: "Preview or export a workflow report to Feishu DocX or Wiki", + Flags: []common.Flag{ + {Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true}, + {Name: "title", Usage: "Document title"}, + {Name: "folder-token", Usage: "Feishu folder token for creating a new DocX. Defaults to FEISHU_DOC_FOLDER_TOKEN"}, + {Name: "document-id", Usage: "Existing Feishu DocX document ID. Defaults to FEISHU_DOCUMENT_ID"}, + {Name: "wiki-url", Usage: "Existing Feishu Wiki URL. Defaults to FEISHU_WIKI_URL"}, + {Name: "wiki-node-token", Usage: "Existing Feishu Wiki node token. Defaults to FEISHU_WIKI_NODE_TOKEN"}, + {Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"}, + {Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"}, + {Name: "send", Usage: "Create or update a Feishu document. Without --send, preview locally", Bool: true, Default: "false"}, + {Name: "dry-run", Usage: "Force local preview. Cannot be combined with --send", Bool: true, Default: "false"}, + {Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang}, + }, + Run: runDocExport, + } +} + func newBitableSchemaShortcut() *common.Shortcut { return &common.Shortcut{ Name: "bitable-schema", @@ -153,6 +175,18 @@ func runWeeklyReport(ctx *common.RuntimeContext) error { return err } +func runDocExport(ctx *common.RuntimeContext) error { + opts, err := docExportOptionsFromContext(ctx) + if err != nil { + return err + } + report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang"))) + if err != nil { + return err + } + return exportDocOrPreview(ctx, opts, report, normalizeLang(ctx.Arg("lang"))) +} + func runBitableSchema(ctx *common.RuntimeContext) error { schema := BuildBitableSchema(parseList(firstNonEmpty(ctx.Arg("tables"), defaultTables))) return renderBitableSchema(os.Stdout, schema, formatOrDefault(ctx, "markdown")) diff --git a/shortcuts/feishu/feishu_test.go b/shortcuts/feishu/feishu_test.go index 92de326..54a33d9 100644 --- a/shortcuts/feishu/feishu_test.go +++ b/shortcuts/feishu/feishu_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gitlink-org/gitlink-cli/shortcuts/common" + "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" ) func TestShortcutsExposeExpectedCommands(t *testing.T) { @@ -18,7 +19,7 @@ func TestShortcutsExposeExpectedCommands(t *testing.T) { for _, shortcut := range Shortcuts() { got[shortcut.Name] = true } - for _, name := range []string{"bot-test", "notify", "weekly-report", "bitable-schema", "bitable-records"} { + for _, name := range []string{"bot-test", "notify", "weekly-report", "doc-export", "bitable-schema", "bitable-records"} { if !got[name] { t.Fatalf("Shortcuts missing %s", name) } @@ -136,3 +137,84 @@ func TestBitableSchemaAndRecords(t *testing.T) { t.Fatalf("reports records = %d, want 1", len(records.Tables["reports"])) } } + +func TestWikiNodeTokenFromURL(t *testing.T) { + got := wikiNodeTokenFromURL("https://tenant.feishu.cn/wiki/NodeToken123?from=from_copylink") + if got != "NodeToken123" { + t.Fatalf("wikiNodeTokenFromURL = %q", got) + } +} + +func TestDocExportOptionsRequireAppCredentialsForSend(t *testing.T) { + ctx := &common.RuntimeContext{Args: map[string]string{ + "send": "true", + "wiki-url": "https://tenant.feishu.cn/wiki/NodeToken123", + "app-id": "", + "app-secret": "", + }} + _, err := docExportOptionsFromContext(ctx) + if err == nil { + t.Fatal("expected missing app credential error") + } +} + +func TestOpenAPIClientDocExportFlow(t *testing.T) { + var sawBlocks bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal": + _, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`)) + case r.Method == http.MethodGet && r.URL.Path == "/wiki/v2/spaces/get_node": + if r.URL.Query().Get("token") != "NodeToken123" { + t.Fatalf("wiki token = %q", r.URL.Query().Get("token")) + } + _, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"node":{"space_id":"space","node_token":"NodeToken123","obj_token":"doc_token","obj_type":"docx","node_type":"origin","title":"Report"}}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/docx/v1/documents/doc_token/blocks/doc_token/children": + sawBlocks = true + var payload struct { + Children []DocBlock `json:"children"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode blocks: %v", err) + } + if len(payload.Children) == 0 { + t.Fatal("no blocks in payload") + } + _, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"revision_id":9}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := OpenAPIClient{BaseURL: server.URL, HTTP: server.Client()} + token, err := client.TenantAccessToken(context.Background(), "cli_xxx", "secret") + if err != nil { + t.Fatalf("TenantAccessToken returned error: %v", err) + } + node, err := client.GetWikiNode(context.Background(), token.Value, "NodeToken123") + if err != nil { + t.Fatalf("GetWikiNode returned error: %v", err) + } + if node.ObjToken != "doc_token" || node.ObjType != "docx" { + t.Fatalf("node = %+v", node) + } + blocks := BuildDocBlocks(workflowReportFixture(t), "en") + created, err := client.CreateBlocks(context.Background(), token.Value, node.ObjToken, node.ObjToken, blocks) + if err != nil { + t.Fatalf("CreateBlocks returned error: %v", err) + } + if created.RevisionID != 9 || !sawBlocks { + t.Fatalf("created = %+v sawBlocks=%t", created, sawBlocks) + } +} + +func workflowReportFixture(t *testing.T) workflow.RepoReportResult { + t.Helper() + report, err := readWorkflowReport(filepath.Join("..", "workflow", "testdata", "repo_report.json"), "en") + if err != nil { + t.Fatalf("readWorkflowReport returned error: %v", err) + } + return report +} diff --git a/shortcuts/feishu/openapi.go b/shortcuts/feishu/openapi.go new file mode 100644 index 0000000..ce25e4d --- /dev/null +++ b/shortcuts/feishu/openapi.go @@ -0,0 +1,242 @@ +package feishu + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +var openAPIBaseURL = "https://open.feishu.cn/open-apis" + +type OpenAPIClient struct { + BaseURL string + HTTP *http.Client +} + +type TenantToken struct { + Value string + Expire int +} + +type WikiNode struct { + SpaceID string `json:"space_id"` + NodeToken string `json:"node_token"` + ObjToken string `json:"obj_token"` + ObjType string `json:"obj_type"` + NodeType string `json:"node_type"` + Title string `json:"title"` + URL string `json:"url"` +} + +type CreatedDocument struct { + DocumentID string `json:"document_id"` + RevisionID int `json:"revision_id"` + Title string `json:"title"` + URL string `json:"url"` +} + +type CreatedBlocks struct { + RevisionID int `json:"revision_id"` +} + +func NewOpenAPIClient(httpClient *http.Client) OpenAPIClient { + if httpClient == nil { + httpClient = http.DefaultClient + } + return OpenAPIClient{ + BaseURL: openAPIBaseURL, + HTTP: httpClient, + } +} + +func (c OpenAPIClient) TenantAccessToken(ctx context.Context, appID, appSecret string) (TenantToken, error) { + body := map[string]string{ + "app_id": appID, + "app_secret": appSecret, + } + reqBody, err := json.Marshal(body) + if err != nil { + return TenantToken{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/auth/v3/tenant_access_token/internal"), bytes.NewReader(reqBody)) + if err != nil { + return TenantToken{}, err + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + var resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + TenantAccessToken string `json:"tenant_access_token"` + Expire int `json:"expire"` + } + if err := c.doJSON(req, &resp); err != nil { + return TenantToken{}, err + } + if resp.Code != 0 { + return TenantToken{}, fmt.Errorf("Feishu tenant token returned code %d: %s", resp.Code, resp.Msg) + } + if strings.TrimSpace(resp.TenantAccessToken) == "" { + return TenantToken{}, fmt.Errorf("Feishu tenant token response missing tenant_access_token") + } + return TenantToken{Value: resp.TenantAccessToken, Expire: resp.Expire}, nil +} + +func (c OpenAPIClient) GetWikiNode(ctx context.Context, tenantToken string, wikiNodeToken string) (WikiNode, error) { + query := url.Values{} + query.Set("token", wikiNodeToken) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("/wiki/v2/spaces/get_node")+"?"+query.Encode(), nil) + if err != nil { + return WikiNode{}, err + } + req.Header.Set("Authorization", "Bearer "+tenantToken) + + var resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Node WikiNode `json:"node"` + } `json:"data"` + } + if err := c.doJSON(req, &resp); err != nil { + return WikiNode{}, err + } + if resp.Code != 0 { + return WikiNode{}, fmt.Errorf("Feishu wiki get_node returned code %d: %s", resp.Code, resp.Msg) + } + if strings.TrimSpace(resp.Data.Node.ObjToken) == "" { + return WikiNode{}, fmt.Errorf("Feishu wiki node response missing obj_token") + } + return resp.Data.Node, nil +} + +func (c OpenAPIClient) CreateDocument(ctx context.Context, tenantToken string, folderToken string, title string) (CreatedDocument, error) { + body := map[string]string{"title": title} + if strings.TrimSpace(folderToken) != "" { + body["folder_token"] = strings.TrimSpace(folderToken) + } + reqBody, err := json.Marshal(body) + if err != nil { + return CreatedDocument{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/docx/v1/documents"), bytes.NewReader(reqBody)) + if err != nil { + return CreatedDocument{}, err + } + req.Header.Set("Authorization", "Bearer "+tenantToken) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + var resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Document CreatedDocument `json:"document"` + } `json:"data"` + } + if err := c.doJSON(req, &resp); err != nil { + return CreatedDocument{}, err + } + if resp.Code != 0 { + return CreatedDocument{}, fmt.Errorf("Feishu docx create returned code %d: %s", resp.Code, resp.Msg) + } + if strings.TrimSpace(resp.Data.Document.DocumentID) == "" { + return CreatedDocument{}, fmt.Errorf("Feishu docx create response missing document_id") + } + return resp.Data.Document, nil +} + +func (c OpenAPIClient) CreateBlocks(ctx context.Context, tenantToken string, documentID string, parentBlockID string, blocks []DocBlock) (CreatedBlocks, error) { + if strings.TrimSpace(parentBlockID) == "" { + parentBlockID = documentID + } + body := map[string]interface{}{"children": blocks} + reqBody, err := json.Marshal(body) + if err != nil { + return CreatedBlocks{}, err + } + path := fmt.Sprintf("/docx/v1/documents/%s/blocks/%s/children", url.PathEscape(documentID), url.PathEscape(parentBlockID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(path), bytes.NewReader(reqBody)) + if err != nil { + return CreatedBlocks{}, err + } + req.Header.Set("Authorization", "Bearer "+tenantToken) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + var resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + RevisionID int `json:"revision_id"` + } `json:"data"` + } + if err := c.doJSON(req, &resp); err != nil { + return CreatedBlocks{}, err + } + if resp.Code != 0 { + return CreatedBlocks{}, fmt.Errorf("Feishu docx create blocks returned code %d: %s", resp.Code, resp.Msg) + } + return CreatedBlocks{RevisionID: resp.Data.RevisionID}, nil +} + +func (c OpenAPIClient) endpoint(path string) string { + base := strings.TrimRight(c.BaseURL, "/") + if base == "" { + base = strings.TrimRight(openAPIBaseURL, "/") + } + return base + path +} + +func (c OpenAPIClient) doJSON(req *http.Request, target interface{}) error { + httpClient := c.HTTP + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var decoded struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + detail := strings.TrimSpace(string(body)) + if err := json.Unmarshal(body, &decoded); err == nil && decoded.Msg != "" { + detail = fmt.Sprintf("code %d: %s", decoded.Code, decoded.Msg) + } + if len(detail) > 300 { + detail = detail[:300] + "..." + } + return fmt.Errorf("Feishu OpenAPI %s %s returned HTTP %d: %s", req.Method, redactOpenAPIPath(req.URL.Path), resp.StatusCode, detail) + } + if err := json.Unmarshal(body, target); err != nil { + return fmt.Errorf("parse Feishu OpenAPI response: %w", err) + } + return nil +} + +func redactOpenAPIPath(path string) string { + replacements := []struct { + pattern string + repl string + }{ + {`/documents/[^/]+`, `/documents/...`}, + {`/blocks/[^/]+`, `/blocks/...`}, + } + for _, replacement := range replacements { + path = regexp.MustCompile(replacement.pattern).ReplaceAllString(path, replacement.repl) + } + return path +} diff --git a/skills/gitlink-feishu/SKILL.md b/skills/gitlink-feishu/SKILL.md new file mode 100644 index 0000000..ba0f60c --- /dev/null +++ b/skills/gitlink-feishu/SKILL.md @@ -0,0 +1,79 @@ +--- +name: gitlink-feishu +version: 1.0.0 +description: "Export GitLink workflow JSON to Feishu bot cards, DocX/Wiki reports, and Bitable-ready dry-run records." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli feishu --help" +--- + +# gitlink-feishu + +Use this skill when a user needs to send or export GitLink workflow analysis to Feishu. + +## Rules + +- Prefer local preview first. +- Use `--send` only when the user explicitly wants a Feishu network write. +- Never use BotBuilder or Robot Assistant workflows. +- Do not write to GitLink resources. +- Do not print webhook URLs, app secrets, or access tokens. +- Use `+bitable-records` for dry-run output only; do not claim that Bitable has been written. + +## Workflow + +Generate workflow JSON: + +```bash +gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json > report.json +``` + +Preview a Feishu card: + +```bash +gitlink-cli feishu +notify --from-workflow-json report.json --format json +``` + +Send a Feishu card: + +```bash +gitlink-cli feishu +notify --from-workflow-json report.json --send --format table +``` + +Preview a document export: + +```bash +gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --format markdown +``` + +Export to DocX or Wiki: + +```bash +gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --send --format table +``` + +Generate Bitable-ready records: + +```bash +gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json +``` + +## Feishu Setup + +Custom bot commands need: + +```text +FEISHU_WEBHOOK_URL +FEISHU_WEBHOOK_SECRET optional +``` + +DocX/Wiki export needs: + +```text +FEISHU_APP_ID +FEISHU_APP_SECRET +``` + +The self-built app must also have permission to write the target DocX/Wiki page or folder. + From 1af54e015adea6653d9a69f639b6269282f3187a Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Mon, 15 Jun 2026 22:03:52 +0800 Subject: [PATCH 04/16] chore: harden feishu export delivery --- docs/feishu-bitable-schema.md | 68 +++++++++ docs/feishu-integration.md | 133 +++++++++++------- docs/feishu-security.md | 75 ++++++++++ examples/feishu/README.md | 40 ++++-- .../OFFICIAL_DOCS_ALIGNMENT.md | 7 +- feishu-export-design/TECHNICAL_PLAN.md | 12 +- reports/FEISHU_TASK_COMPLETION.md | 22 ++- .../FEISHU_TEST_ENTERPRISE_SMOKE_20260615.md | 116 +++++++++++++++ shortcuts/feishu/feishu.go | 2 +- shortcuts/feishu/feishu_test.go | 26 ++++ shortcuts/feishu/workflow_input.go | 33 +++++ skills/gitlink-feishu/SKILL.md | 95 +++++++++---- 12 files changed, 531 insertions(+), 98 deletions(-) create mode 100644 docs/feishu-bitable-schema.md create mode 100644 docs/feishu-security.md create mode 100644 reports/FEISHU_TEST_ENTERPRISE_SMOKE_20260615.md diff --git a/docs/feishu-bitable-schema.md b/docs/feishu-bitable-schema.md new file mode 100644 index 0000000..29584c3 --- /dev/null +++ b/docs/feishu-bitable-schema.md @@ -0,0 +1,68 @@ +# Feishu Bitable Dry-Run Schema + +`gitlink-cli feishu` currently generates Bitable schema and records locally. + +It does not call Feishu Bitable OpenAPI. + +## Commands + +```bash +gitlink-cli feishu +bitable-schema --format markdown +gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json +``` + +## Tables + +Default tables: + +```text +issues +prs +contributors +reports +``` + +## Record Semantics + +Records are summary records derived from `workflow +repo-report` JSON. + +They are not one row per GitLink issue or one row per GitLink pull request. + +Current behavior: + +```text +reports: one summary row per repo report +issues: summary buckets by issue type and priority +prs: summary buckets by change type and risk +contributors: reserved schema; records are empty unless workflow JSON later includes contributor details +``` + +## Real Write Boundary + +Not implemented: + +```text +Bitable OpenAPI create +Bitable OpenAPI batch create +Bitable update +Bitable upsert +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 +``` + diff --git a/docs/feishu-integration.md b/docs/feishu-integration.md index c6b4f4b..6e9a914 100644 --- a/docs/feishu-integration.md +++ b/docs/feishu-integration.md @@ -1,27 +1,39 @@ # Feishu Integration -`gitlink-cli feishu` exports local GitLink workflow JSON to Feishu collaboration surfaces. +`gitlink-cli feishu` exports local GitLink workflow JSON to Feishu. -The commands are intentionally one-way: +The stable command path is intentionally narrow: ```text workflow JSON -> local preview -workflow JSON -> Feishu bot card -workflow JSON -> Feishu DocX / Wiki report -workflow JSON -> Bitable-ready dry-run records +workflow JSON -> Feishu custom bot card +workflow JSON -> weekly report +workflow JSON -> Bitable schema / records dry-run ``` +## Stable Commands + +```text +gitlink-cli feishu +bot-test +gitlink-cli feishu +notify +gitlink-cli feishu +weekly-report +gitlink-cli feishu +bitable-schema +gitlink-cli feishu +bitable-records +``` + +`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. + ## Safety Model -Default behavior is local preview. +- Default behavior is local preview. +- Real Feishu bot delivery requires `--send`. +- `--send` and `--dry-run` cannot be used together. +- Webhook URLs are redacted in command output. +- Secrets and tokens are never intentionally printed. +- The stable commands do not write to GitLink resources. +- Bitable commands are dry-run only and do not call Bitable OpenAPI. -Network operations require `--send`. - -`--send` and `--dry-run` cannot be used together. - -The implementation does not write to GitLink resources, does not close issues, does not comment on PRs, and does not merge code. - -## Custom Bot +## Custom Bot Setup Use a Feishu custom group bot for notification cards. @@ -32,26 +44,41 @@ $env:FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/..." $env:FEISHU_WEBHOOK_SECRET="optional signing secret" ``` -Preview: +Preview a test card: ```bash gitlink-cli feishu +bot-test --format json ``` -Send: +Send a test card: ```bash gitlink-cli feishu +bot-test --send --format table ``` -Send a workflow report card: +## Workflow Report Card + +Generate workflow JSON: ```bash gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json > report.json +``` + +On Windows PowerShell, redirected files may be written with UTF-16 encoding. `feishu` workflow JSON readers accept UTF-8 and UTF-16 BOM files so the redirected output above can be consumed directly. + +Preview a card: + +```bash +gitlink-cli feishu +notify --from-workflow-json report.json --format json +``` + +Send a card: + +```bash gitlink-cli feishu +notify --from-workflow-json report.json --send --format table ``` -Send a card with a DocX or Wiki link: +Send a card with an existing Feishu document or Wiki link: ```bash gitlink-cli feishu +notify \ @@ -61,9 +88,39 @@ gitlink-cli feishu +notify \ --format table ``` -## DocX / Wiki Export +## Weekly Report -Use a Feishu self-built app for document export. +Render markdown: + +```bash +gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown +``` + +Send a weekly summary card: + +```bash +gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table +``` + +## Bitable Dry Run + +Generate recommended table schemas: + +```bash +gitlink-cli feishu +bitable-schema --format markdown +``` + +Generate Bitable-ready records: + +```bash +gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json +``` + +These records are summary records derived from workflow repo-report JSON. They are not a per-issue or per-PR synchronization. + +## Experimental DocX / Wiki Export + +`feishu +doc-export` is experimental because it uses Feishu self-built app credentials and writes to DocX / Wiki through OpenAPI. Environment: @@ -81,7 +138,7 @@ gitlink-cli feishu +doc-export \ --format markdown ``` -Append report blocks to a Wiki-backed DocX: +Write to DocX / Wiki: ```bash gitlink-cli feishu +doc-export \ @@ -91,40 +148,10 @@ gitlink-cli feishu +doc-export \ --format table ``` -Create a new DocX in a folder: - -```bash -gitlink-cli feishu +doc-export \ - --from-workflow-json report.json \ - --folder-token "<folder_token>" \ - --title "GitLink workflow report" \ - --send \ - --format table -``` - Required Feishu setup: ```text -1. The self-built app must have DocX / Drive application scopes. -2. The app permission version must be published and approved. -3. The target Wiki / DocX / folder must grant the app write permission. +1. The self-built app must have approved DocX / Drive scopes. +2. The target Wiki / DocX / folder must grant the app write permission. +3. If Feishu returns 1770032: forBidden, credentials are valid but the app cannot write the target document. ``` - -If Feishu returns `1770032: forBidden`, the token is valid but the app cannot write to the target document. Grant the app access to that Wiki/DocX page or use a folder where the app can create documents. - -## Bitable Dry Run - -Generate recommended table schemas: - -```bash -gitlink-cli feishu +bitable-schema --format markdown -``` - -Generate Bitable-ready records: - -```bash -gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json -``` - -These commands do not call Bitable OpenAPI and do not create, update, or upsert records. - diff --git a/docs/feishu-security.md b/docs/feishu-security.md new file mode 100644 index 0000000..6ac40f5 --- /dev/null +++ b/docs/feishu-security.md @@ -0,0 +1,75 @@ +# Feishu Security Notes + +## Default Behavior + +All Feishu shortcut commands default to local preview. + +Real network writes require `--send`. + +`--send` and `--dry-run` cannot be used together. + +## Secrets + +Supported environment variables: + +```text +FEISHU_WEBHOOK_URL +FEISHU_WEBHOOK_SECRET +FEISHU_APP_ID experimental doc-export only +FEISHU_APP_SECRET experimental doc-export only +``` + +Do not commit real webhook URLs, app secrets, access tokens, Base app tokens, table IDs, or document tokens. + +Command output redacts webhook URLs. Tests use fake webhook IDs. + +## Stable Surface + +The stable surface uses Feishu custom bot webhooks: + +```text +feishu +bot-test +feishu +notify +feishu +weekly-report +``` + +These commands can send Feishu cards, but they do not read or write Feishu documents, tables, users, or groups. + +## Dry-Run Surface + +The Bitable commands are local only: + +```text +feishu +bitable-schema +feishu +bitable-records +``` + +They do not call Feishu OpenAPI and cannot create, update, or upsert Bitable records. + +## Experimental Surface + +`feishu +doc-export` is experimental. It uses: + +```text +app_id +app_secret +tenant_access_token +Wiki OpenAPI +DocX 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. + +## Non-Goals + +```text +BotBuilder integration +Feishu Robot Assistant workflows +automatic Feishu permission changes +GitLink remote writes +GitLink comments +Issue closure +merge actions +real Bitable writes +``` + diff --git a/examples/feishu/README.md b/examples/feishu/README.md index 132e698..941fc2e 100644 --- a/examples/feishu/README.md +++ b/examples/feishu/README.md @@ -1,24 +1,48 @@ # Feishu Export Examples -## 1. Generate Workflow JSON +## Stable Workflow + +### 1. Generate Workflow JSON ```bash gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json > report.json ``` -## 2. Preview Notification Card +### 2. Preview Notification Card ```bash gitlink-cli feishu +notify --from-workflow-json report.json --format json ``` -## 3. Send Notification Card +### 3. Send Notification Card ```bash gitlink-cli feishu +notify --from-workflow-json report.json --send --format table ``` -## 4. Preview Wiki Export +### 4. Render Weekly Report + +```bash +gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown +``` + +### 5. Generate Bitable Schema + +```bash +gitlink-cli feishu +bitable-schema --format markdown +``` + +### 6. Generate Bitable Records + +```bash +gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json +``` + +## Experimental DocX / Wiki Export + +`+doc-export` is available for Feishu self-built app experiments. It is not part of the stable clean export path. + +Preview: ```bash gitlink-cli feishu +doc-export \ @@ -27,7 +51,7 @@ gitlink-cli feishu +doc-export \ --format markdown ``` -## 5. Export To Wiki +Write: ```bash gitlink-cli feishu +doc-export \ @@ -37,9 +61,3 @@ gitlink-cli feishu +doc-export \ --format table ``` -## 6. Generate Bitable Records - -```bash -gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json -``` - diff --git a/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md b/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md index 69b7af7..17504fc 100644 --- a/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md +++ b/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md @@ -266,7 +266,8 @@ But it is incomplete for a "Feishu collaboration export" feature because it does Required design adjustment: ```text -Add doc-export with DocX/Wiki support as the first Open Platform custom-app integration. -Keep real Bitable writes after doc-export. -Keep custom bot as the low-friction smoke test path. +Keep custom bot as the low-friction stable smoke test path. +Keep Bitable as dry-run in the stable path. +Treat doc-export with DocX/Wiki support as experimental because it uses self-built app OpenAPI and document write permissions. +Keep real Bitable writes out of scope. ``` diff --git a/feishu-export-design/TECHNICAL_PLAN.md b/feishu-export-design/TECHNICAL_PLAN.md index 3969196..3904876 100644 --- a/feishu-export-design/TECHNICAL_PLAN.md +++ b/feishu-export-design/TECHNICAL_PLAN.md @@ -266,7 +266,7 @@ type BitableRecord struct { Official Feishu Open Platform docs show that cloud document integration belongs to the self-built app flow, not the custom bot flow. -Add this after the custom bot MVP: +Experimental command: ```text feishu +doc-export @@ -310,13 +310,19 @@ Implementation notes: - Mock all HTTP tests. - Do not implement document sharing or permission changes in the first doc export pass. -Recommended product flow: +Stable product flow: + +```text +workflow +repo-report -> feishu +weekly-report -> feishu +notify --doc-url -> feishu +bitable-records +``` + +Experimental product flow: ```text workflow +repo-report -> feishu +doc-export --wiki-url -> feishu +notify --doc-url -> feishu +bitable-records ``` -This should land before any real Bitable write because DocX export has clearer value and simpler consistency semantics than Bitable upsert. +DocX export should remain clearly marked as experimental until tenant permissions, scopes, and document-write behavior are stable. ## Tests To Add diff --git a/reports/FEISHU_TASK_COMPLETION.md b/reports/FEISHU_TASK_COMPLETION.md index b4f0f69..8c46bb9 100644 --- a/reports/FEISHU_TASK_COMPLETION.md +++ b/reports/FEISHU_TASK_COMPLETION.md @@ -30,7 +30,7 @@ gitlink-cli feishu +bitable-records - Added project activity card generation from workflow JSON. - Added weekly report rendering from workflow JSON. - Added `--doc-url` support for notification cards. -- Added `feishu +doc-export` for Feishu DocX / Wiki export. +- Added experimental `feishu +doc-export` for Feishu DocX / Wiki export. - Added self-built app tenant token acquisition. - Added Wiki node resolution. - Added DocX block creation client. @@ -109,6 +109,20 @@ Design now treats Feishu Knowledge Base / Wiki pages as a project showcase and r workflow JSON -> DocX/Wiki report -> bot card with doc URL -> Bitable dry-run records ``` +After scope review, DocX / Wiki export is explicitly experimental and not part of the stable clean workflow. + +Stable path: + +```text +workflow JSON -> bot card / weekly report / Bitable dry-run records +``` + +Experimental path: + +```text +workflow JSON -> DocX/Wiki export through self-built app OpenAPI +``` + ## Tests Commands run: @@ -147,9 +161,13 @@ 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. + ## Next Engineering Step -Complete the Feishu document permission setup and rerun: +For stable delivery, continue validating custom bot delivery, weekly reports, and Bitable dry-run output. + +For experimental DocX/Wiki export, complete the Feishu document permission setup and rerun: ```text gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url <wiki_url> --send --format table diff --git a/reports/FEISHU_TEST_ENTERPRISE_SMOKE_20260615.md b/reports/FEISHU_TEST_ENTERPRISE_SMOKE_20260615.md new file mode 100644 index 0000000..581bf7c --- /dev/null +++ b/reports/FEISHU_TEST_ENTERPRISE_SMOKE_20260615.md @@ -0,0 +1,116 @@ +# Feishu Test Enterprise Smoke Report + +Date: 2026-06-15 + +Branch: + +```text +feat/feishu-export-clean +``` + +## Scope + +This smoke run validates the stable Feishu export path in a real Feishu test enterprise. + +Stable path: + +```text +bot-test +notify +weekly-report +bitable-schema +bitable-records +``` + +Experimental path: + +```text +doc-export +``` + +Secrets, full webhook URLs, app secrets, and real Wiki tokens are not recorded in this report. + +## Local Verification + +Commands: + +```bash +gofmt -w shortcuts/feishu +git diff --check +go test ./shortcuts/feishu ./shortcuts ./... +``` + +Result: + +```text +passed +``` + +## Windows JSON Encoding Check + +The smoke run generated workflow JSON through Windows PowerShell redirection. + +Initial result before fix: + +```text +parse workflow JSON: invalid character 'ÿ' looking for beginning of value +``` + +Fix: + +```text +workflow JSON reader now accepts UTF-8 BOM, UTF-16LE BOM, and UTF-16BE BOM. +``` + +Post-fix result: + +```text +workflow JSON generated by PowerShell redirection is accepted. +``` + +## Real Feishu Bot Smoke + +| Step | Result | Notes | +| --- | --- | --- | +| bot-test preview | pass | local preview | +| bot-test send | pass | HTTP 200 / Feishu code 0 | +| notify preview | pass | local preview | +| notify send | pass | HTTP 200 / Feishu code 0 | +| notify send with doc URL | pass | HTTP 200 / Feishu code 0 | +| weekly-report markdown | pass | markdown rendered | +| weekly-report send | pass | HTTP 200 / Feishu code 0 | + +## Bitable Dry-Run Smoke + +| Step | Result | Notes | +| --- | --- | --- | +| bitable-schema json | pass | JSON schema generated | +| bitable-schema markdown | pass | Markdown schema generated | +| bitable-records json | pass | dry-run records generated | +| bitable-records table | pass | reports/issues/prs records summarized; contributors reserved | + +## Experimental Doc Export Smoke + +| Step | Result | Notes | +| --- | --- | --- | +| doc-export preview | pass | target resolved as Wiki append preview | +| doc-export send | expected failure | Feishu returned HTTP 403 / code 1770032 / forBidden | + +Interpretation: + +```text +The self-built app credentials can get tenant_access_token and resolve the Wiki node, but the app cannot write blocks to the target Wiki-backed DocX page yet. +``` + +Required Feishu-side action: + +```text +Grant the self-built app approved DocX/Drive scopes and write access to the target Wiki/DocX page or use a writable folder token. +``` + +## Conclusion + +Stable Feishu export path is working in the test enterprise. + +`doc-export` remains experimental and permission-blocked for real writes. + diff --git a/shortcuts/feishu/feishu.go b/shortcuts/feishu/feishu.go index 920504d..4b242b9 100644 --- a/shortcuts/feishu/feishu.go +++ b/shortcuts/feishu/feishu.go @@ -76,7 +76,7 @@ func newWeeklyReportShortcut() *common.Shortcut { func newDocExportShortcut() *common.Shortcut { return &common.Shortcut{ Name: "doc-export", - Description: "Preview or export a workflow report to Feishu DocX or Wiki", + Description: "Experimental: preview or export a workflow report to Feishu DocX or Wiki", Flags: []common.Flag{ {Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true}, {Name: "title", Usage: "Document title"}, diff --git a/shortcuts/feishu/feishu_test.go b/shortcuts/feishu/feishu_test.go index 54a33d9..8e8829b 100644 --- a/shortcuts/feishu/feishu_test.go +++ b/shortcuts/feishu/feishu_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -120,6 +121,31 @@ func TestReadWorkflowReportSupportsInputFixture(t *testing.T) { } } +func TestReadWorkflowReportSupportsPowerShellUTF16Redirect(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "workflow", "testdata", "repo_report.json")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + utf16Data := []byte{0xFF, 0xFE} + for _, r := range string(raw) { + if r > 0xFFFF { + t.Fatalf("fixture contains non-BMP rune %q", r) + } + utf16Data = append(utf16Data, byte(r), byte(r>>8)) + } + path := filepath.Join(t.TempDir(), "report.json") + if err := os.WriteFile(path, utf16Data, 0600); err != nil { + t.Fatalf("write UTF-16 fixture: %v", err) + } + report, err := readWorkflowReport(path, "en") + if err != nil { + t.Fatalf("readWorkflowReport returned error: %v", err) + } + if report.Repository != "Gitlink/gitlink-cli" { + t.Fatalf("Repository = %q", report.Repository) + } +} + func TestBitableSchemaAndRecords(t *testing.T) { report, err := readWorkflowReport(filepath.Join("..", "workflow", "testdata", "repo_report.json"), "en") if err != nil { diff --git a/shortcuts/feishu/workflow_input.go b/shortcuts/feishu/workflow_input.go index 60553a9..5c67320 100644 --- a/shortcuts/feishu/workflow_input.go +++ b/shortcuts/feishu/workflow_input.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "strings" + "unicode/utf16" "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" ) @@ -14,6 +15,10 @@ func readWorkflowReport(path string, lang string) (workflow.RepoReportResult, er if err != nil { return workflow.RepoReportResult{}, fmt.Errorf("read workflow JSON: %w", err) } + data, err = normalizeJSONBytes(data) + if err != nil { + return workflow.RepoReportResult{}, err + } data, err = unwrapWorkflowJSON(data) if err != nil { return workflow.RepoReportResult{}, err @@ -32,6 +37,34 @@ func readWorkflowReport(path string, lang string) (workflow.RepoReportResult, er return workflow.RepoReportResult{}, fmt.Errorf("parse workflow JSON: expected workflow RepoReportResult or RepoReportInput") } +func normalizeJSONBytes(data []byte) ([]byte, error) { + if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { + return data[3:], nil + } + if len(data) >= 2 && data[0] == 0xFF && data[1] == 0xFE { + return decodeUTF16(data[2:], true) + } + if len(data) >= 2 && data[0] == 0xFE && data[1] == 0xFF { + return decodeUTF16(data[2:], false) + } + return data, nil +} + +func decodeUTF16(data []byte, littleEndian bool) ([]byte, error) { + if len(data)%2 != 0 { + return nil, fmt.Errorf("parse workflow JSON: invalid UTF-16 byte length") + } + words := make([]uint16, 0, len(data)/2) + for i := 0; i < len(data); i += 2 { + if littleEndian { + words = append(words, uint16(data[i])|uint16(data[i+1])<<8) + } else { + words = append(words, uint16(data[i])<<8|uint16(data[i+1])) + } + } + return []byte(string(utf16.Decode(words))), nil +} + func unwrapWorkflowJSON(data []byte) ([]byte, error) { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { diff --git a/skills/gitlink-feishu/SKILL.md b/skills/gitlink-feishu/SKILL.md index ba0f60c..81efc03 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 bot cards, DocX/Wiki reports, and Bitable-ready dry-run records." +description: "Export GitLink workflow JSON to Feishu custom bot cards, weekly reports, and Bitable-ready dry-run records." metadata: requires: bins: ["gitlink-cli"] @@ -10,47 +10,58 @@ metadata: # gitlink-feishu -Use this skill when a user needs to send or export GitLink workflow analysis to Feishu. +Use this skill when a user needs to export GitLink workflow analysis into Feishu. -## Rules +## Purpose -- Prefer local preview first. -- Use `--send` only when the user explicitly wants a Feishu network write. -- Never use BotBuilder or Robot Assistant workflows. -- Do not write to GitLink resources. -- Do not print webhook URLs, app secrets, or access tokens. -- Use `+bitable-records` for dry-run output only; do not claim that Bitable has been written. +Stable path: -## Workflow +```text +workflow JSON -> Feishu bot card / weekly report / Bitable dry-run records +``` -Generate workflow JSON: +Experimental path: + +```text +workflow JSON -> Feishu DocX / Wiki export +``` + +## Inputs + +Workflow JSON should usually come from: ```bash gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json > report.json ``` -Preview a Feishu card: +## Safety Rules + +- Preview first. +- Use `--send` only when the user explicitly wants a Feishu network write. +- Never use BotBuilder or Robot Assistant workflows. +- Do not write to GitLink resources. +- Do not print webhook URLs, app secrets, access tokens, or table tokens. +- Treat `+bitable-schema` and `+bitable-records` as local dry-run commands only. +- Treat `+doc-export` as experimental because it uses self-built app OpenAPI and document write permissions. + +## Preview Flow + +Preview a card: ```bash gitlink-cli feishu +notify --from-workflow-json report.json --format json ``` -Send a Feishu card: +Render a weekly report: ```bash -gitlink-cli feishu +notify --from-workflow-json report.json --send --format table +gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown ``` -Preview a document export: +Generate Bitable schemas: ```bash -gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --format markdown -``` - -Export to DocX or Wiki: - -```bash -gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --send --format table +gitlink-cli feishu +bitable-schema --format markdown ``` Generate Bitable-ready records: @@ -59,7 +70,7 @@ Generate Bitable-ready records: gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json ``` -## Feishu Setup +## Send Flow Custom bot commands need: @@ -68,12 +79,46 @@ FEISHU_WEBHOOK_URL FEISHU_WEBHOOK_SECRET optional ``` -DocX/Wiki export needs: +Send a card: + +```bash +gitlink-cli feishu +notify --from-workflow-json report.json --send --format table +``` + +Send a weekly report card: + +```bash +gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table +``` + +## Experimental Doc Export + +DocX / Wiki export needs: ```text FEISHU_APP_ID FEISHU_APP_SECRET ``` -The self-built app must also have permission to write the target DocX/Wiki page or folder. +Preview only: + +```bash +gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --format markdown +``` + +Write to Feishu: + +```bash +gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --send --format table +``` + +If Feishu returns `1770032: forBidden`, the app token is valid but the app cannot write to the target DocX/Wiki page or folder. + +## Non-Goals + +- No GitLink remote writes. +- No GitLink comments, issue closure, merge, or webhook creation. +- No Bitable real writes in the stable path. +- No BotBuilder integration. +- No automatic Feishu permission changes. From 69334f68947ce1fa5b91f5deef9b8332ae48d4d3 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Tue, 16 Jun 2026 10:40:04 +0800 Subject: [PATCH 05/16] docs: expand role-based feishu collaboration plan --- docs/feishu-integration.md | 40 + examples/feishu/README.md | 35 +- .../ROLE_BASED_COLLABORATION.md | 899 ++++++++++++++++++ feishu-export-design/TECHNICAL_PLAN.md | 76 ++ 4 files changed, 1049 insertions(+), 1 deletion(-) create mode 100644 feishu-export-design/ROLE_BASED_COLLABORATION.md diff --git a/docs/feishu-integration.md b/docs/feishu-integration.md index 6e9a914..58d537d 100644 --- a/docs/feishu-integration.md +++ b/docs/feishu-integration.md @@ -118,6 +118,46 @@ gitlink-cli feishu +bitable-records --from-workflow-json report.json --format js These records are summary records derived from workflow repo-report JSON. They are not a per-issue or per-PR synchronization. +## Role-Aware Collaboration Roadmap + +The Feishu integration is designed to support two different notification modes: + +```text +Owner / maintainer: summarized digest. +Contributor: immediate personal feedback. +``` + +Owner-oriented cards should group PRs by review stage instead of sending one message for every PR event. Recommended stages: + +```text +blue: new or unreviewed +grey: active review +green: close to merge or merged +yellow: needs rebase +orange: major changes requested +red: blocked +``` + +Contributor notifications are different. A contributor should receive fast feedback when their own PR is reviewed, commented on, blocked by rebase/conflict, approved, merged, or closed. + +Long-form project material should be exported to Feishu Docs / Wiki: + +```text +README summary +contribution guide +owner digest archive +milestone plan +PR stage table +``` + +Milestone and Gantt support should start as Bitable-ready records and document sections. Real Bitable writes and view creation require a separate permissioned OpenAPI design. + +Detailed design: + +```text +feishu-export-design/ROLE_BASED_COLLABORATION.md +``` + ## Experimental DocX / Wiki Export `feishu +doc-export` is experimental because it uses Feishu self-built app credentials and writes to DocX / Wiki through OpenAPI. diff --git a/examples/feishu/README.md b/examples/feishu/README.md index 941fc2e..8059047 100644 --- a/examples/feishu/README.md +++ b/examples/feishu/README.md @@ -38,6 +38,40 @@ gitlink-cli feishu +bitable-schema --format markdown gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json ``` +## Role-Aware Collaboration Direction + +Owner digest: + +```text +aggregate PR and workflow state +group PRs by review stage +send daily or weekly summary cards +link to Feishu Doc / Wiki full report +``` + +Contributor feedback: + +```text +send fast notifications only for the contributor's own PR events +review comment +changes requested +needs rebase +approved +merged +closed +``` + +Planned color semantics: + +```text +blue = new / unreviewed +green = close to merge +yellow = needs rebase +orange = major changes requested +red = blocked +grey = active review or closed +``` + ## Experimental DocX / Wiki Export `+doc-export` is available for Feishu self-built app experiments. It is not part of the stable clean export path. @@ -60,4 +94,3 @@ gitlink-cli feishu +doc-export \ --send \ --format table ``` - diff --git a/feishu-export-design/ROLE_BASED_COLLABORATION.md b/feishu-export-design/ROLE_BASED_COLLABORATION.md new file mode 100644 index 0000000..ff8e60c --- /dev/null +++ b/feishu-export-design/ROLE_BASED_COLLABORATION.md @@ -0,0 +1,899 @@ +# Role-Based Feishu Collaboration Design + +## Purpose + +Extend the Feishu export workflow from a simple report sender into a role-aware collaboration layer. + +The product should not notify every maintainer about every Pull Request event. It should separate the two communication needs: + +```text +Owner / maintainer: periodic, summarized, prioritized project state. +Contributor: immediate, personal feedback for work they own. +``` + +This keeps Feishu useful as a collaboration surface instead of turning it into a noisy event stream. + +## Role Model + +### Owner View + +Owners need batch summaries and decision support. + +Default owner delivery: + +```text +daily digest +weekly report +milestone status +review queue summary +high-risk PR summary +stale contribution summary +``` + +Owners should receive: + +```text +repository status +new contributor activity +PRs grouped by review stage +PRs blocked by conflicts or required rebase +PRs close to merge +PRs needing owner decision +review coverage and stale review data +links to Feishu Doc / Wiki pages for full context +``` + +Owners should not receive by default: + +```text +one message for every new PR +one message for every comment +one message for every patchset push +one message for every review reply +``` + +### Contributor View + +Contributors need immediate feedback on their own work. + +Default contributor delivery: + +```text +review comment received +review status changed +changes requested +rebase required +merge conflict detected +CI or quality gate failed +PR approved +PR merged +PR refused or closed +maintainer requested more information +``` + +Contributor notifications should be personal and direct where Feishu identity mapping is available. If open_id mapping is not configured, the system should fall back to repository-level cards or dry-run output. + +## Event Strategy + +### Owner Events + +Owner notifications are aggregation jobs, not raw events. + +Recommended command shape: + +```bash +gitlink-cli feishu +owner-digest \ + --owner <owner> \ + --repo <repo> \ + --period daily \ + --webhook-url "$FEISHU_WEBHOOK_URL" \ + --send +``` + +Alternative input-only flow: + +```bash +gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json > report.json +gitlink-cli feishu +owner-digest --from-workflow-json report.json --send +``` + +The first implementation should prefer the input-only flow. Direct GitLink collection can come later once the data model is stable. + +### Contributor Events + +Contributor notifications can be real-time if GitLink has webhooks, or near-real-time through polling. + +Recommended command shape: + +```bash +gitlink-cli feishu +contributor-notify \ + --from-event-json event.json \ + --send +``` + +Polling shape: + +```bash +gitlink-cli feishu +contributor-watch \ + --owner <owner> \ + --repo <repo> \ + --interval 5m \ + --state-file .gitlink-feishu-state.json \ + --send +``` + +The state file records delivered event IDs so repeated polling does not resend old notifications. + +## Feishu Channel Matrix + +Different Feishu surfaces should not be used for the same job. + +| Surface | Best Fit | Delivery Style | Current Status | +| --- | --- | --- | --- | +| Custom group bot webhook | Owner digest, weekly report, project status card | One-way group card | Implemented for current notify/report path | +| Self-built app IM API | Personal contributor notifications | Direct message or mention | Future, needs open_id mapping and IM scopes | +| DocX / Wiki | Long-form report, README mirror, project knowledge base | Persistent document | Experimental `+doc-export` exists | +| Bitable records | PR stage table, milestones, dashboard source | Structured rows | Dry-run records only | +| Bitable Gantt view | Milestone timeline | Visual project planning | Manual view first, OpenAPI later | +| Feishu AI summary / weekly report | Summarize generated docs and records | Feishu-side automation | Do not depend on private API | + +Practical rule: + +```text +Card = attention. +Doc / Wiki = context. +Bitable = structured state. +Gantt = milestone visualization. +AI summary = Feishu-side value added on top of structured content. +``` + +## Scheduling Model + +Owner notifications should be scheduled. Contributor notifications should be event-driven where possible. + +Owner cadence: + +```text +daily: review queue and blocked items +weekly: contributor activity, milestone progress, risk trend +on demand: full report preview or manual send +``` + +Contributor cadence: + +```text +immediate: review/comment/merge/rebase/conflict events +debounced: repeated comments in the same PR within a short window +digest fallback: if personal identity mapping is missing +``` + +Recommended debounce rule: + +```text +If the same contributor receives multiple comments on the same PR within 10 minutes, +combine them into one notification with a count and latest link. +``` + +This avoids replacing owner spam with contributor spam. + +## Pull Request Stage Colors + +Feishu cards should use color as a stage signal, not as decoration. + +Default stage rules: + +| Stage | Card Color | Meaning | Typical Inputs | +| --- | --- | --- | --- | +| `new` | blue | New PR, not reviewed yet | no reviews, one patchset, recently opened | +| `active-review` | grey | Review is active, no clear risk yet | comments or common reviews exist | +| `near-ready` | green | Small gap to merge | approved or low-risk review, no conflict, checks pass | +| `needs-rebase` | yellow | Contributor action needed before review can continue | base branch changed, conflict, stale branch, merge check failed | +| `major-changes` | orange | Larger change request or high-risk delta | rejected review, large diff, missing tests, repeated review cycles | +| `blocked` | red | Owner or platform action needed | permission issue, failing required checks, unresolved dependency | +| `merged` | green | Completed | merged status | +| `closed` | grey | Closed without merge | closed/refused status | + +The first stable implementation should support defaults only. User customization can be added as a config file after the stage model is proven. + +Recommended config shape: + +```yaml +feishu: + pr_stages: + near_ready: + color: green + max_unresolved_comments: 2 + require_no_conflicts: true + needs_rebase: + color: yellow + require_mergeable: false + major_changes: + color: orange + min_review_rounds: 2 + high_risk_labels: + - missing-tests + - large-diff +``` + +## Review Degree Model + +The stage calculation should be explainable. A card should not only show a color; it should also show why the PR is in that stage. + +Recommended derived fields: + +```text +review_rounds +patchset_count +last_review_status +unresolved_comment_count +requested_changes_count +approved_count +changed_files_count +additions +deletions +mergeable +needs_rebase +ci_status +last_activity_at +``` + +GitLink anchors already available in the CLI ecosystem: + +```text +pr +list +pr +view +pr +reviews +pr +versions +pr +files +``` + +The design should avoid scraping pages. It should use existing GitLink APIs or existing CLI JSON outputs. + +## Stage Classification Order + +The stage classifier should be deterministic. Later rules should not override higher-priority terminal or blocking states. + +Recommended order: + +```text +1. merged +2. closed +3. blocked +4. needs-rebase +5. major-changes +6. near-ready +7. active-review +8. new +``` + +Classification logic: + +```text +merged: + pull_request_status == merged + +closed: + pull_request_status == closed/refused + +blocked: + required check failed, permission issue, unresolved dependency, or owner-defined blocked label + +needs-rebase: + mergeable == false, conflict exists, stale base branch, or merge check says rebase is required + +major-changes: + last review rejected, requested_changes_count > 0, large diff threshold exceeded, or repeated review rounds + +near-ready: + approved_count > 0, no conflict, no requested changes, low remaining risk + +active-review: + common review/comment exists, patchset_count > 1, or maintainer has interacted + +new: + no review, no maintainer interaction, recently opened +``` + +Every stage output should include `reasons`: + +```json +{ + "stage": "needs-rebase", + "color": "yellow", + "reasons": [ + "merge check failed", + "base branch changed after latest patchset" + ] +} +``` + +## Data Contracts + +The role-aware extension should accept local JSON first. This keeps tests deterministic and avoids changing existing GitLink network behavior. + +### Owner Digest Input + +Recommended minimal JSON: + +```json +{ + "repository": "Gitlink/gitlink-cli", + "period": { + "start": "2026-06-09", + "end": "2026-06-16" + }, + "pull_requests": [ + { + "number": 123, + "title": "feat: add export flow", + "author": "contributor-a", + "url": "https://www.gitlink.org.cn/org/repo/pulls/123", + "status": "open", + "stage": "needs-rebase", + "color": "yellow", + "reasons": ["merge check failed"], + "review_rounds": 2, + "patchset_count": 3, + "last_activity_at": "2026-06-16T10:30:00+08:00" + } + ], + "milestones": [], + "contributors": [] +} +``` + +### Owner Digest Output + +Recommended output: + +```json +{ + "repository": "Gitlink/gitlink-cli", + "period_label": "2026-06-09 to 2026-06-16", + "stage_counts": { + "near-ready": 3, + "needs-rebase": 2, + "major-changes": 1, + "new": 4 + }, + "top_actions": [ + "Review 4 new PRs", + "Ask 2 contributors to rebase", + "Merge 3 near-ready PRs" + ], + "doc_url": "https://example.feishu.cn/wiki/...", + "dry_run": true +} +``` + +### Contributor Event Input + +Recommended minimal event JSON: + +```json +{ + "event_id": "repo-pr-123-review-456", + "event_type": "review_comment", + "repository": "Gitlink/gitlink-cli", + "pr": { + "number": 123, + "title": "feat: add export flow", + "url": "https://www.gitlink.org.cn/org/repo/pulls/123", + "author": "contributor-a" + }, + "actor": "maintainer-a", + "recipient_gitlink_user": "contributor-a", + "summary": "Maintainer requested changes in the export options.", + "required_action": "Update the PR and push a new patchset.", + "created_at": "2026-06-16T10:30:00+08:00" +} +``` + +### Contributor Notification Output + +Recommended output: + +```json +{ + "event_id": "repo-pr-123-review-456", + "recipient_gitlink_user": "contributor-a", + "recipient_feishu_id": "", + "delivery_mode": "dry-run", + "card_title": "PR feedback received", + "required_action": "Update the PR and push a new patchset.", + "dry_run": true +} +``` + +If `recipient_feishu_id` is empty, direct personal delivery must not be attempted. + +## Owner Digest Card + +Owner digest cards should be compact and action-oriented. + +Recommended sections: + +```text +1. Repository and period +2. Review queue by stage +3. Near-ready PRs +4. PRs needing rebase +5. High-risk or major-change PRs +6. New contributors +7. Stale PRs +8. Milestone progress +9. Link to Feishu Wiki / Doc full report +``` + +Example card semantics: + +```text +Header: GitLink Owner Digest +Green section: 3 PRs close to merge +Yellow section: 2 PRs need rebase +Orange section: 1 PR needs major changes +Grey section: 4 new/unreviewed PRs +Button: Open Feishu report +Button: Open GitLink PR queue +``` + +The owner card should cap inline PR rows. A full report belongs in Feishu Doc / Wiki. + +Recommended card limits: + +```text +maximum stage groups shown: 5 +maximum PR rows per stage: 3 +maximum total inline PR rows: 10 +always include full report link when available +``` + +If the owner digest exceeds the inline limits, the card should say how many rows are hidden and link to the Doc / Wiki report. + +## Contributor Notification Card + +Contributor cards should be immediate and specific. + +Recommended sections: + +```text +1. PR title and repository +2. Event type +3. Reviewer or actor +4. Required action +5. Short feedback summary +6. Link to PR +7. Link to Feishu reference doc if relevant +``` + +Example event mapping: + +| Event | Card Intent | +| --- | --- | +| review comment | Read maintainer feedback | +| rejected review | Modify PR according to requested changes | +| approved review | Wait for merge or owner decision | +| merged | Contribution accepted | +| needs rebase | Rebase branch before further review | +| conflict | Resolve merge conflict | + +Contributor delivery requires identity mapping: + +```text +GitLink username -> Feishu open_id / union_id / email +``` + +Until that mapping exists, the CLI should generate dry-run records instead of attempting direct personal delivery. + +## Identity Mapping + +Identity mapping is a separate concern from PR analysis. + +Supported mapping sources, in priority order: + +```text +1. explicit local mapping file +2. Bitable mapping table +3. email match from GitLink user profile and Feishu directory +4. no mapping, dry-run only +``` + +First implementation should only support the local file: + +```yaml +contributors: + contributor-a: + feishu_open_id: ou_xxx + display_name: Contributor A + contributor-b: + email: contributor-b@example.com +``` + +Validation rules: + +```text +mapping file is optional +missing mapping downgrades to dry-run +mapping values are redacted in logs +the CLI does not call Feishu directory APIs in the first pass +``` + +## Feishu Docs / Wiki + +Feishu Docs and Wiki should be treated as the long-form project artifact. + +Recommended generated content: + +```text +project overview +README summary +contribution guide summary +review policy +milestone plan +daily or weekly owner digest archive +PR stage table +high-risk change notes +``` + +README export should not replace the repository README. It should produce a Feishu-readable version for maintainers and contributors. + +Recommended command shape: + +```bash +gitlink-cli feishu +readme-doc \ + --owner <owner> \ + --repo <repo> \ + --wiki-url "<wiki_url>" \ + --send +``` + +Permission boundary: + +```text +The owner configures Feishu app scopes and document permissions. +The CLI never changes Feishu document permissions automatically. +The CLI prints permission diagnostics when write access fails. +``` + +This matches the current `+doc-export` boundary and avoids hidden permission changes. + +## README and Knowledge Base Export + +The README export should be deterministic and conservative. + +Recommended sections: + +```text +1. Project title +2. Short repository summary +3. Quick start +4. Contribution workflow +5. Review policy +6. Current milestones +7. Current owner digest link +8. Source repository links +``` + +The command should accept local files before remote reads: + +```bash +gitlink-cli feishu +readme-doc \ + --from-readme README.md \ + --from-contributing CONTRIBUTING.md \ + --wiki-url "<wiki_url>" \ + --format markdown +``` + +Later remote mode can use GitLink repository file APIs: + +```bash +gitlink-cli feishu +readme-doc \ + --owner <owner> \ + --repo <repo> \ + --ref master \ + --wiki-url "<wiki_url>" +``` + +Doc write behavior: + +```text +preview by default +--send required for document writes +append or update target must be explicit +do not change sharing settings +return permission diagnostics on 403 +``` + +## Milestones and Gantt + +Gantt-style planning belongs to milestone tracking, not raw notification cards. + +Recommended data model: + +```text +milestone_id +milestone_title +start_date +due_date +status +linked_issues +linked_prs +owner +progress_percent +risk_level +``` + +Feishu implementation options: + +```text +Doc / Wiki: milestone narrative and current status. +Bitable records: structured milestone rows. +Bitable Gantt view: created manually by owner at first. +Later OpenAPI sync: update milestone rows after table IDs are configured. +``` + +The first implementation should only generate milestone-ready records and Doc content. Automatic Bitable view creation should remain out of scope until real Bitable writes are implemented. + +Recommended Bitable milestone fields: + +```text +milestone_key +repository +title +owner +start_date +due_date +status +progress_percent +risk_level +linked_prs +linked_issues +last_updated_at +``` + +Manual Gantt setup: + +```text +1. Owner creates a Bitable table using generated schema. +2. Owner imports generated milestone records. +3. Owner creates a Gantt view from start_date and due_date. +4. Later CLI sync updates rows, not views. +``` + +## Feishu AI Summary Fit + +The CLI should not depend on a private Feishu AI summary API for the first implementation. + +Instead, the CLI should generate structured Feishu Docs and cards that are easy for Feishu-side summary, daily report, weekly report, and knowledge-base features to consume. + +Practical split: + +```text +gitlink-cli: collect, normalize, stage, render, send. +Feishu: summarize, archive, search, collaborate, display. +Owner: configure permissions, choose digest schedule, tune stage rules. +``` + +## Configuration File + +A future config file should keep project policy out of command-line flags. + +Recommended path: + +```text +.gitlink-feishu.yaml +``` + +Recommended shape: + +```yaml +repository: Gitlink/gitlink-cli + +owner_digest: + enabled: true + cadence: weekly + webhook_env: FEISHU_WEBHOOK_URL + doc_url_env: FEISHU_PROJECT_DOC_URL + inline_limit: 10 + +contributor_notifications: + enabled: true + delivery: dry-run + identity_mapping: .gitlink-feishu-users.yaml + debounce_window: 10m + +pr_stage_rules: + near_ready: + color: green + require_approved: true + require_no_conflicts: true + needs_rebase: + color: yellow + require_mergeable: false + major_changes: + color: orange + min_review_rounds: 2 + min_changed_files: 20 + +docs: + wiki_url_env: FEISHU_PROJECT_WIKI_URL + readme_sources: + - README.md + - CONTRIBUTING.md + +milestones: + enabled: true + records_only: true +``` + +Rules: + +```text +environment variable names may be stored +secret values must not be stored +unknown config keys should warn, not crash +invalid stage colors should fail validation +``` + +## Implementation Phases + +### Phase A: Role-Aware Dry Run + +Add local outputs only: + +```text +owner digest model +contributor event model +PR stage model +default stage color rules +milestone record model +README-to-doc preview model +``` + +Commands: + +```text +feishu +owner-digest --from-workflow-json +feishu +pr-stage-report --from-pr-json +feishu +contributor-events --from-event-json +feishu +readme-doc --from-readme +``` + +No GitLink writes. No Feishu writes by default. + +Acceptance: + +```text +owner digest JSON is stable +PR stage classification is deterministic +card color is derived from stage +missing optional fields do not panic +large input is capped in card preview +all tests use fixtures +``` + +### Phase B: Owner Digest Send + +Enable bot cards for aggregated owner summaries: + +```text +feishu +owner-digest --send +``` + +Use custom bot webhook, same safety model as current `+notify`. + +Acceptance: + +```text +--send is required for webhook delivery +--send without webhook URL fails +--send --dry-run fails +webhook URL is redacted +mock HTTP tests cover 200, 400, 429, and 500 +``` + +### Phase C: Contributor Direct Notifications + +Add contributor delivery after identity mapping exists: + +```text +GitLink username -> Feishu user ID +``` + +Supported delivery modes: + +```text +custom group bot mention +self-built app IM message +dry-run only if identity mapping is missing +``` + +Acceptance: + +```text +missing identity mapping downgrades to dry-run +direct message mode requires explicit --send +recipient IDs are redacted in logs +event_id/state prevents duplicate delivery +debounce behavior is covered by tests +``` + +### Phase D: Docs / Wiki Project Space + +Extend experimental document export: + +```text +README summary +owner digest archive +milestone page +PR stage table +``` + +Keep all document writes behind `--send`. + +Acceptance: + +```text +README preview renders markdown +DocX/Wiki write requires app credentials and --send +403 errors include permission diagnostics +document permission changes are not attempted +``` + +### Phase E: Milestone / Gantt Data + +Generate milestone-ready Bitable records first. + +Real Bitable sync can follow only after: + +```text +tenant token flow +table IDs +unique keys +upsert behavior +permission diagnostics +partial failure handling +``` + +Acceptance: + +```text +milestone records include stable unique keys +records are usable for manual Bitable import +no Bitable OpenAPI calls happen without explicit send behavior +Gantt view creation remains manual in this phase +``` + +## Non-Goals + +Do not implement in the first role-aware extension: + +```text +automatic GitLink merge +automatic GitLink review +automatic GitLink comments +automatic Feishu permission changes +automatic Bitable view creation +direct dependency on BotBuilder +notification spam for every owner-visible event +``` + +## Design Verdict + +This direction is stronger than a raw PR event notifier. + +The product should be: + +```text +Owner: Feishu digest and knowledge-base workspace. +Contributor: immediate personal feedback loop. +Project: Docs/Wiki for long-form context, Bitable/Gantt for milestone tracking. +``` + +The current implementation already supports the lowest-risk part: + +```text +workflow JSON -> Feishu card / weekly report / Doc link / Bitable-ready records +``` + +The next useful design step is to add role-aware models and dry-run outputs before adding new Feishu or GitLink network behavior. diff --git a/feishu-export-design/TECHNICAL_PLAN.md b/feishu-export-design/TECHNICAL_PLAN.md index 3904876..d5f4dd1 100644 --- a/feishu-export-design/TECHNICAL_PLAN.md +++ b/feishu-export-design/TECHNICAL_PLAN.md @@ -324,6 +324,82 @@ workflow +repo-report -> feishu +doc-export --wiki-url -> feishu +notify --doc-u DocX export should remain clearly marked as experimental until tenant permissions, scopes, and document-write behavior are stable. +## Role-Based Collaboration Extension + +The next design layer is role-aware delivery, documented in: + +```text +feishu-export-design/ROLE_BASED_COLLABORATION.md +``` + +Core split: + +```text +Owner / maintainer: periodic aggregated digest, not per-event spam. +Contributor: immediate personal feedback for PR comments, reviews, rebase needs, and merge results. +``` + +Owner-facing features should build on aggregated workflow and PR data: + +```text +daily digest +weekly report +review queue summary +PR stage color report +milestone status +Feishu Doc / Wiki full report link +``` + +Contributor-facing features need a separate event model: + +```text +review comment received +changes requested +needs rebase +conflict detected +approved +merged +closed or refused +``` + +PR stage colors should be derived from explainable inputs such as review status, patchset count, mergeability, requested changes, and stale activity: + +```text +blue: new / unreviewed +grey: active review +green: near ready or merged +yellow: needs rebase +orange: major changes requested +red: blocked +``` + +The first role-aware extension should stay local and dry-run by default: + +```text +feishu +owner-digest --from-workflow-json +feishu +pr-stage-report --from-pr-json +feishu +contributor-events --from-event-json +feishu +readme-doc --from-readme +``` + +Do not add real-time contributor delivery until identity mapping is designed: + +```text +GitLink username -> Feishu open_id / union_id / email +``` + +Feishu Docs / Wiki should hold long-form project context: + +```text +README summary +contribution guide summary +owner digest archive +milestone plan +PR stage table +``` + +The owner configures Feishu permissions. The CLI must not change document permissions automatically. + ## Tests To Add ```text From 3798bb1e13dee0f3c53b10ef9fc3f9c41caf9bd1 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Fri, 26 Jun 2026 14:22:06 +0800 Subject: [PATCH 06/16] docs: add feishu gitlink redesign research --- docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md | 1160 ++++++++++++++++++++++ 1 file changed, 1160 insertions(+) create mode 100644 docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md diff --git a/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md b/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md new file mode 100644 index 0000000..6d3c8a6 --- /dev/null +++ b/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md @@ -0,0 +1,1160 @@ +# GitLink CLI x Feishu Redesign Research + +Date: 2026-06-26 + +## 1. Executive Summary + +The current `gitlink-cli feishu` implementation is a safe export module. It can preview and send Feishu custom bot cards, render weekly reports, generate Bitable schemas and Bitable-ready records, and experiment with DocX / Wiki export. + +The redesigned product direction should be broader but staged: + +```text +GitLink project collaboration gateway for maintainers, contributors, and teams inside Feishu. +``` + +The stable first path should remain conservative: + +```text +GitLink data -> Feishu cards / reports / Bitable-ready records / Doc-ready content +``` + +The later path can become a permissioned collaboration gateway: + +```text +Feishu app / card callback -> identity mapping -> GitLink action preview -> confirmation -> audited action +``` + +The key design correction is that Feishu should not be treated only as a message sink. Feishu's public product materials, Bilibili tutorials, and official Base documentation consistently position Bitable as: + +```text +one data source +multiple role-specific views +lightweight business system +dashboard / cockpit +workflow and AI-enabled collaboration base +``` + +For GitLink projects, this means the most useful Feishu integration is not "send one message per PR". It is: + +```text +1. owner cockpit +2. contributor task panel +3. PR / Issue / CI history base +4. milestone Gantt +5. weekly report archive +6. Doc / Wiki project knowledge space +7. optional low-risk GitLink action gateway +``` + +## 2. Research Sources + +### Current Repository + +Inspected local repository paths: + +```text +README.md +README.zh-CN.md +shortcuts/feishu +shortcuts/workflow +shortcuts/issue +shortcuts/pr +shortcuts/webhook +shortcuts/member +shortcuts/ci +shortcuts/pipeline +skills/ +docs/feishu-integration.md +docs/feishu-security.md +reports/FEISHU_TASK_COMPLETION.md +reports/FEISHU_TEST_ENTERPRISE_SMOKE_20260615.md +feishu-export-design/ROLE_BASED_COLLABORATION.md +``` + +Local command checks: + +```text +gitlink-cli feishu --help +gitlink-cli issue --help +gitlink-cli pr --help +gitlink-cli webhook --help +gitlink-cli workflow --help +gitlink-cli member --help +gitlink-cli ci --help +gitlink-cli pipeline --help +``` + +### Feishu / Lark Official Sources + +Primary references: + +```text +https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot +https://open.feishu.cn/document/feishu-cards/quick-start/send-message-cards-with-custom-bot?lang=zh-CN +https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN +https://open.feishu.cn/document/server-docs/im-v1/message/create?lang=zh-CN +https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/create +https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document-block/create?lang=zh-CN +https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/create?lang=zh-CN +https://open.feishu.cn/document/task-v2/task/create?lang=zh-CN +https://www.feishu.cn/product/base +https://www.feishu.cn/hc/zh-CN/articles/360049067931-%E4%BD%BF%E7%94%A8%E5%A4%9A%E7%BB%B4%E8%A1%A8%E6%A0%BC%E8%A7%86%E5%9B%BE +https://www.feishu.cn/hc/zh-CN/articles/558830919244-%E4%BD%BF%E7%94%A8%E5%A4%9A%E7%BB%B4%E8%A1%A8%E6%A0%BC%E7%9A%84%E7%94%98%E7%89%B9%E8%A7%86%E5%9B%BE +https://www.feishu.cn/hc/zh-CN/articles/161059314076-%E4%BD%BF%E7%94%A8%E5%A4%9A%E7%BB%B4%E8%A1%A8%E6%A0%BC%E4%BB%AA%E8%A1%A8%E7%9B%98 +``` + +### Bilibili / Social Content Sources + +Observed Bilibili content: + +```text +https://www.bilibili.com/video/BV1rd4y167KZ/ +``` + +This is a Feishu Help Center Bilibili video in the "多维表格实战课" collection. The collection includes: + +```text +视图:一张多维表格,多种展示方式 +表单:信息收集汇总 +仪表盘:数据可视化 +关联和引用:数据关系建模 +自动化与高级权限 +搭建心法:像打造产品一样搭建多维表格 +销售管理系统 +HR 招聘和试用期管理系统 +产研敏捷开发管理系统 +多维表格 + 飞书组合 +``` + +Social-platform-facing Feishu content also emphasizes: + +```text +一张表管公司 +企业驾驶舱 +多视图切换 +项目管理甘特图 +看板管理 +日历排期 +表单收集 +仪表盘分析 +小红书 / 抖音 / 电商内容数据管理 +AI 字段 / AI 总结 / 内容创作流程 +``` + +Direct Xiaohongshu pages are not reliably accessible through normal web indexing in this environment. The useful signal comes from Feishu official template and content pages that explicitly mention Xiaohongshu data management and content workflows: + +```text +https://www.feishu.cn/template/group/base +https://www.feishu.cn/content/article/7584012383060757728 +https://www.feishu.cn/content/article/7582904528077917408 +``` + +### lark-cli Sources + +Primary references: + +```text +https://github.com/larksuite/cli +https://github.com/larksuite/cli/blob/main/README.zh.md +https://open.larksuite.com/document/mcp_open_tools/feishu-cli-let-ai-actually-do-your-work-in-feishu +https://www.feishu.cn/feishu-cli +``` + +Key lark-cli claims observed: + +```text +official Lark / Feishu CLI +built for humans and AI Agents +covers Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, Markdown, etc. +200+ commands +20+ AI Agent Skills +structured output +schema introspection +explicit safety warnings for AI Agent use +``` + +## 3. Current Repository Capability Assessment + +### Implemented Stable Commands + +```bash +gitlink-cli feishu +bot-test +gitlink-cli feishu +notify +gitlink-cli feishu +weekly-report +gitlink-cli feishu +bitable-schema +gitlink-cli feishu +bitable-records +``` + +Stable behavior: + +```text +local preview by default +real Feishu custom bot delivery only with --send +--send and --dry-run conflict validation +webhook URL redaction +custom bot signing +interactive card payload building +workflow JSON input support +weekly report rendering +--doc-url button support +Bitable schema dry-run +Bitable-ready record dry-run +UTF-8 / UTF-16 workflow JSON input support +mockable HTTP client +``` + +### Experimental Command + +```bash +gitlink-cli feishu +doc-export +``` + +Experimental behavior: + +```text +app_id / app_secret +tenant_access_token +Wiki node resolution +DocX block write attempt +document export preview +explicit --send for write attempt +``` + +Observed real Feishu test: + +```text +custom bot send: passed +notify send: passed +weekly-report send: passed +Bitable schema / records dry-run: passed +tenant_access_token: acquired +Wiki node: resolved +DocX write: blocked by Feishu 403 / 1770032 / forBidden +``` + +Interpretation: + +```text +The app credentials and Wiki read path can work, but document writes still require correct DocX / Drive scopes and target document or folder permissions. +``` + +### Current Bitable Usage + +Current Bitable implementation is local-only. + +Implemented: + +```text +feishu +bitable-schema +feishu +bitable-records +``` + +Current tables: + +```text +reports +issues +prs +contributors +``` + +Current records: + +```text +reports: one row per workflow report +issues: summary bucket rows +prs: summary bucket rows +contributors: reserved, empty unless workflow JSON contains contributor details +``` + +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 +``` + +Conclusion: + +```text +Current Bitable support is useful as schema and data-shape proof, but not yet useful as an actual project management cockpit. +``` + +To support project management, the next Bitable model must become row-level instead of summary-only: + +```text +one row per PR +one row per Issue +one row per CI run +one row per contributor-period +one row per milestone +one row per release +one row per action/audit event +``` + +## 4. Feishu Capability Boundary + +### Custom Bot + +Fit: + +```text +one-way group notifications +message cards +weekly report cards +owner digest cards +links to GitLink / Feishu Docs / Bitable views +low-friction smoke test +``` + +Not fit: + +```text +document writes +Bitable writes +task creation +user identity mapping +personal direct messages +interactive GitLink write actions +permission management +callback server by itself +``` + +Design rule: + +```text +Custom bot = notification and link surface. +``` + +### Self-Built App + +Fit: + +```text +tenant_access_token +app-level authentication +IM message send +DocX / Wiki write +Bitable app / table / record operations +Task API +card callback / event subscription +user identity resolution +chat_id / repo binding +permission diagnostics +``` + +Design rule: + +```text +Self-built app = authorization, Feishu resource write, callbacks, and future interaction surface. +``` + +### lark-cli + +Fit: + +```text +optional external bridge +AI Agent operations on Feishu resources +Docs / Base / Tasks / Calendar / Mail / IM operations +manual experiments before native implementation +developer productivity +cross-platform workflows where Codex controls both CLIs +``` + +Not fit as a hard dependency: + +```text +gitlink-cli mainline runtime +stable Go tests +minimal install path +security-critical callback gateway +deterministic server-side behavior +``` + +Design rule: + +```text +gitlink-cli should not depend on lark-cli. +lark-cli can be an optional bridge for agents and operators. +``` + +### gitlink-cli + +Fit: + +```text +authoritative GitLink data reader +authoritative GitLink action executor +workflow report generation +Issue / PR / Review / CI / Webhook / Member operations +dry-run and confirmation behavior +audit log source +``` + +Design rule: + +```text +GitLink actions should be executed through gitlink-cli native commands, not through Feishu-side ad hoc HTTP calls hidden in callbacks. +``` + +## 5. Bilibili / Xiaohongshu / Social Content Findings + +### What Feishu Emphasizes Publicly + +The Bilibili tutorial collection and Feishu public materials repeatedly emphasize: + +```text +多维表格不是普通电子表格,而是轻量业务系统。 +同一份数据可以用多种视图呈现。 +管理者和执行者应该看不同视角。 +仪表盘是业务驾驶舱。 +表单用于收集。 +看板用于流程状态。 +甘特图用于时间计划。 +日历用于排期。 +画册用于卡片化展示。 +自动化用于减少人工搬运。 +高级权限用于控制敏感数据。 +AI 字段和总结用于内容生产、分类、分析。 +``` + +This matters for GitLink because GitLink data naturally has multiple roles: + +```text +owner / maintainer +reviewer +contributor +PM / project manager +organization manager +AI agent +``` + +The Feishu product narrative suggests that we should not build a single flat report. We should design a data model that supports multiple views. + +### Mapping Social Product Narrative to GitLink + +| Feishu narrative | GitLink equivalent | Product implication | +| --- | --- | --- | +| one table, many views | one project data model, many project views | Store PR / Issue / CI / milestone rows in Bitable | +| management cockpit | owner dashboard | owner digest + dashboard records | +| personal task panel | contributor pending action view | contributor digest and assigned PR/Issue records | +| Kanban | PR / Issue stage board | group by review stage, issue status, CI status | +| Gantt | milestone and release planning | start/end dates, dependencies, owners | +| Calendar | review deadlines, release dates, meetings | due_date and scheduled review fields | +| Gallery | project showcase and PR cards | card-style PR / contributor / feature display | +| Form | collection and intake | issue intake, contributor weekly update, review request | +| Dashboard | high-level metrics | open PRs, stale PRs, merged PRs, risk count, CI pass rate | +| AI field / summary | summarization and classification | AI can summarize PRs, classify risks, draft weekly report | + +### Implication for Current Bitable Commands + +Current `+bitable-records` produces summary buckets. That is not enough for: + +```text +Kanban +Gantt +Calendar +Gallery +Form-driven workflow +individual task panel +enterprise cockpit +``` + +Required next data shape: + +```text +Pull Requests table: + pr_key + repository + number + title + author + assignee + reviewer + stage + stage_color + risk_level + review_rounds + patchset_count + changed_files + additions + deletions + mergeable + needs_rebase + ci_status + opened_at + updated_at + due_date + url + +Issues table: + issue_key + repository + number + title + author + assignee + status + priority + labels + risk_level + due_date + stale_days + url + +CI Runs table: + ci_key + repository + ref + commit + status + started_at + finished_at + duration + url + +Milestones table: + milestone_key + repository + title + owner + start_date + due_date + status + progress_percent + linked_prs + linked_issues + risk_level + +Contributors table: + contributor_key + gitlink_username + feishu_user_id + role + active_prs + merged_prs + pending_actions + last_activity_at + +Audit table: + action_id + source + actor_feishu_id + actor_gitlink_user + repository + action_type + risk_level + dry_run + confirmed + result + created_at +``` + +### Recommended Feishu Views + +| View | Target user | Backing table | Purpose | +| --- | --- | --- | --- | +| Table view | Admin / maintainer | all tables | raw data inspection | +| Kanban view | Owner / reviewer | PRs, Issues | stage tracking | +| Gantt view | PM / owner | Milestones, PRs | release and milestone planning | +| Calendar view | Reviewer / contributor | PRs, Issues, Milestones | due dates and review windows | +| Gallery view | Community manager | PRs, Contributors | showcase features and contributor highlights | +| Form view | Contributors | Issues, Updates | issue intake and weekly updates | +| Dashboard | Owner / organization | PRs, Issues, CI, Milestones | enterprise cockpit | +| Personal task panel | Contributor | PRs, Issues | my pending actions | + +### Enterprise Cockpit + +For organization managers, the cockpit should answer: + +```text +How many active repositories are healthy? +Which repos have stale PRs? +Which maintainers are overloaded? +Which milestones are at risk? +How many PRs are near merge? +How many PRs need rebase? +What is the CI pass rate trend? +Which contributors need response? +Which issues are high risk? +``` + +This should be implemented through Bitable records and dashboard views, not through long chat messages. + +### Personal Task Panel + +For contributors, the personal panel should answer: + +```text +Which of my PRs need rebase? +Which PRs received review comments? +Which issues are assigned to me? +Which CI runs failed on my branch? +Which maintainer action am I waiting for? +What was merged this week? +``` + +This requires identity mapping: + +```text +GitLink username -> Feishu open_id / union_id / email +``` + +Until identity mapping exists, the CLI should only generate dry-run contributor records. + +## 6. Current gitlink-cli Capability Matrix + +### Issue + +Available commands: + +```text +issue +list +issue +view +issue +create +issue +update +issue +comment +issue +close +issue +batch-close +issue +authors +issue +assigners +issue +priorities +issue +statuses +issue +tags +``` + +Gateway classification: + +```text +read: list, view, metadata list +low-risk write: comment, create +medium-risk write: update metadata +high-risk write: close, batch-close +``` + +### Pull Request + +Available commands: + +```text +pr +list +pr +view +pr +files +pr +diff +pr +versions +pr +version-diff +pr +reviews +pr +review +pr +comment +pr +create +pr +merge +pr +refuse +pr +reopen +``` + +Gateway classification: + +```text +read: list, view, files, diff, versions, version-diff, reviews +low-risk write: comment, review comment, approve, request changes +medium-risk write: create, reopen +high-risk write: merge, refuse / close +``` + +### Webhook + +Available commands: + +```text +webhook +list +webhook +view +webhook +create +webhook +update +webhook +delete +webhook +test +webhook +tasks +``` + +Gateway classification: + +```text +read: list, view, tasks +medium-risk write: test +high-risk write: create, update, delete +``` + +### Workflow + +Available commands: + +```text +workflow +triage +workflow +health +workflow +pr-summary +workflow +repo-report +``` + +Gateway classification: + +```text +read / local analysis: all workflow commands +``` + +These are ideal first-class inputs for Feishu reports. + +### CI / Pipeline + +Available commands: + +```text +ci +builds +ci +logs +ci +restart +ci +stop + +pipeline +list +pipeline +view +pipeline +runs +pipeline +run +pipeline +logs +pipeline +results +pipeline +save-yaml +pipeline +enable +pipeline +disable +pipeline +delete +``` + +Gateway classification: + +```text +read: builds, logs, list, view, runs, results, save-yaml +medium-risk write: restart, run, stop, enable, disable +high-risk write: delete +``` + +### Member + +Available commands: + +```text +member +list +member +add +member +batch-add +member +remove +member +role +member +invite-link +member +invite-info +member +accept-invite +``` + +Gateway classification: + +```text +read: list, invite-info +medium-risk write: accept-invite +high-risk write: add, batch-add, remove, role, invite-link +``` + +## 7. Functional Matrix + +| Function | Custom bot | Self-built app | lark-cli | gitlink-cli | Recommendation | +| --- | --- | --- | --- | --- | --- | +| Project progress card | yes | yes | possible | data source | Stage 1 custom bot | +| Weekly report | yes | yes | possible | workflow | Stage 1 custom bot | +| Owner digest | yes | yes | possible | workflow / PR / Issue | Stage 1 custom bot | +| Contributor digest | group only | direct message possible | possible | data source | Stage 1 dry-run, Stage 2 app | +| PR stage cards | yes | yes | possible | PR reader | Stage 1 | +| Issue risk summary | yes | yes | possible | Issue/workflow | Stage 1 | +| CI summary | yes | yes | possible | CI/pipeline | Stage 1 read | +| Link to GitLink | yes | yes | yes | URL source | Stage 1 | +| DocX / Wiki write | no | yes | yes | content source | Stage 2 | +| Bitable real sync | no | yes | yes | record source | Stage 2 | +| Bitable views | no | partial through API/manual | possible | schema source | Stage 2/manual first | +| Task creation | no | yes | yes | task source | Stage 2 | +| Card callback | no as standalone | yes | possible | action executor | Stage 3 | +| Issue comment | no | entry only | bridge possible | issue +comment | Stage 3 low-risk | +| PR review | no | entry only | bridge possible | pr +review | Stage 3 low-risk | +| Merge PR | no | entry only | bridge possible | pr +merge | High-risk, disabled by default | +| Close issue | no | entry only | bridge possible | issue +close | High-risk, disabled by default | +| Member management | no | entry only | bridge possible | member commands | High-risk, disabled by default | + +## 8. Revised Product Stages + +### Stage 1: Safe Export and Notification + +Goal: + +```text +Make GitLink project state visible in Feishu without Feishu-triggered GitLink writes. +``` + +Capabilities: + +```text +custom bot notification +weekly report +owner digest +contributor digest dry-run +PR stage summary +Issue risk summary +CI summary +Bitable-ready row-level records +Doc/Wiki-ready markdown preview +explicit --send for notification +``` + +Implementation priority: + +```text +1. owner digest card +2. contributor digest preview +3. PR row records +4. Issue row records +5. CI row records +6. milestone records +7. README / Doc-ready markdown +``` + +No: + +```text +no callback server +no GitLink write actions +no real Bitable writes +no task creation +no permission management +``` + +### Stage 2: Feishu Open Platform App Integration + +Goal: + +```text +Turn Feishu into the project collaboration space while still avoiding GitLink writes from Feishu. +``` + +Capabilities: + +```text +app_id / app_secret config +tenant_access_token cache +app-check +scope diagnostics +chat_id / repo binding +DocX / Wiki write +Bitable record sync +task creation +IM send through app bot +permission diagnostics +lark-cli-check optional +``` + +No: + +```text +no PR merge +no issue close +no member management +no branch protection changes +``` + +### Stage 3: Callback-Based Low-Risk GitLink Actions + +Goal: + +```text +Allow permissioned, audited low-risk GitLink actions from Feishu cards. +``` + +Capabilities: + +```text +callback server +callback signature verification +action payload parser +repo binding check +identity mapping +GitLink permission check +dry-run preview +confirmation +audit log +issue comment +PR review comment +PR approve +PR request changes +create issue +result writeback to Feishu +``` + +No: + +```text +no merge by default +no close by default +no delete by default +no member role change by default +``` + +### Stage 4: High-Risk Action Design + +Goal: + +```text +Design high-risk actions but keep them disabled unless explicitly enabled. +``` + +Actions: + +```text +merge PR +close issue +delete branch +delete release +add/remove member +change member role +protect/unprotect branch +disable/delete pipeline +``` + +Required safeguards: + +```text +--enable-dangerous-actions +dry-run first +second confirmation +maintainer role check +repo allowlist +audit log +rate limit +rollback guidance where possible +``` + +## 9. lark-cli and gitlink-cli Interaction + +### Decision + +```text +gitlink-cli should not depend on lark-cli. +lark-cli should be an optional agent bridge. +``` + +Reasons: + +```text +hard dependency increases installation complexity +hard dependency complicates Go tests +hard dependency mixes Feishu and GitLink ownership +security boundary becomes unclear +lark-cli is broad and fast-moving +gitlink-cli needs a minimal stable Feishu path +``` + +### Recommended Interaction Modes + +Mode A: gitlink-cli to Feishu + +```text +gitlink-cli reads GitLink data +gitlink-cli renders card/report/records +gitlink-cli sends custom bot card or exports dry-run data +``` + +Mode B: Feishu to gitlink-cli + +```text +Feishu callback arrives at action gateway +gateway validates Feishu identity and repo binding +gateway maps action to gitlink-cli dry-run +user confirms +gateway executes allowed GitLink action +gateway writes audit log and Feishu result message +``` + +Mode C: Agent bridge with both CLIs + +```text +Codex / Claude / OpenClaw loads gitlink-cli skills and lark-cli skills +gitlink-cli handles GitLink +lark-cli handles Feishu Docs / Base / Tasks / Calendar / IM +Agent orchestrates both for one-off or operator-assisted workflows +``` + +### Effect of lark-cli Skills on GitLink Organizations + +For maintainers: + +```text +less context switching between GitLink, Feishu, and terminal +Feishu Docs can become project memory +Bitable can become project cockpit +meetings can generate GitLink issues or review checklists +weekly reports can be generated from GitLink data and written to Feishu +``` + +For contributors: + +```text +personal pending-action panel +review feedback pushed into Feishu +rebase / CI failure reminders +quick jump back to GitLink PR / Issue +self-summary of weekly contribution +``` + +For AI agents: + +```text +gitlink-cli skills provide GitLink understanding and action execution +lark-cli skills provide Feishu resource operations +structured outputs improve cross-platform automation +dry-run / confirmation policies reduce accidental writes +``` + +Main risk: + +```text +An AI Agent with Feishu user authorization and GitLink token can accidentally bridge two permission domains. +``` + +Required control: + +```text +least privilege +explicit --send / --apply +dry-run first +identity mapping +repo binding +confirmation +audit log +high-risk actions disabled +secret redaction +``` + +## 10. Recommended Documents to Add Next + +This research report should be followed by focused boundary documents: + +```text +docs/FEISHU_CAPABILITY_BOUNDARY.md +docs/FEISHU_OPEN_PLATFORM_PLAN.md +docs/FEISHU_ACTION_GATEWAY_SECURITY.md +docs/FEISHU_LARK_CLI_INTEROP.md +``` + +Recommended command planning documents: + +```text +gitlink-cli feishu +app-check +gitlink-cli feishu +binding-list +gitlink-cli feishu +binding-add +gitlink-cli feishu +binding-remove +gitlink-cli feishu +action-preview +gitlink-cli feishu +serve +gitlink-cli feishu +audit-log +gitlink-cli feishu +lark-cli-check +``` + +Do not implement all commands immediately. Document the gateway and permission model first. + +## 11. Implementation Implications for Current Code + +### Keep + +```text +custom bot signer +custom bot client +card builders +workflow JSON reader +weekly report renderer +Bitable schema builder +Bitable records builder +DocX / Wiki experimental client +redaction and send-mode validation +``` + +### Refactor Later + +```text +current Bitable records are summary-oriented +new Bitable records should support row-level PR / Issue / CI / milestone records +card builders need owner digest and contributor digest variants +Doc export needs clearer permission diagnostics and app-check +OpenAPI token client needs cache and scope diagnostics +``` + +### Add Later + +```text +role-aware data models +PR stage classifier +owner digest +contributor digest +row-level Bitable records +README / Doc-ready markdown exporter +app-check +binding model +action gateway preview +audit log +``` + +### Avoid + +```text +claiming real Bitable sync before API writes exist +claiming Feishu task creation before Task API exists in code +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 +``` + +## 12. Final Verdict + +The project should be repositioned from: + +```text +GitLink workflow export to Feishu +``` + +to: + +```text +GitLink project collaboration gateway for Feishu, implemented in staged safety layers. +``` + +The immediate engineering plan should still stay conservative: + +```text +Stage 1 = safe visibility. +Stage 2 = Feishu workspace integration. +Stage 3 = low-risk action gateway. +Stage 4 = high-risk action design only. +``` + +The most important design insight from Feishu's Bilibili and public content is: + +```text +Feishu's value is not just notification. Its value is turning structured data into role-specific work surfaces. +``` + +For GitLink, that means: + +```text +owner cockpit +contributor task panel +PR / Issue / CI / milestone Base +Doc / Wiki project memory +weekly report archive +optional permissioned action gateway +``` + +This direction is practical, extensible, and matches Feishu's own product narrative while keeping the first implementation safe enough for a mainline PR. From bcdab0b4363e27f589d2209da2034c06495269f4 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Fri, 26 Jun 2026 14:29:13 +0800 Subject: [PATCH 07/16] docs: define gitlink cli action boundaries --- docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md | 80 +-- docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md | 637 +++++++++++++++++++++++ 2 files changed, 683 insertions(+), 34 deletions(-) create mode 100644 docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md diff --git a/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md b/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md index 6d3c8a6..d26508d 100644 --- a/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md +++ b/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md @@ -1,4 +1,4 @@ -# GitLink CLI x Feishu Redesign Research +# GitLink CLI x Feishu Redesign Research Date: 2026-06-26 @@ -111,34 +111,34 @@ Observed Bilibili content: https://www.bilibili.com/video/BV1rd4y167KZ/ ``` -This is a Feishu Help Center Bilibili video in the "多维表格实战课" collection. The collection includes: +This is a Feishu Help Center Bilibili video in the Feishu Base practical course collection. The collection includes: ```text -视图:一张多维表格,多种展示方式 -表单:信息收集汇总 -仪表盘:数据可视化 -关联和引用:数据关系建模 -自动化与高级权限 -搭建心法:像打造产品一样搭建多维表格 -销售管理系统 -HR 招聘和试用期管理系统 -产研敏捷开发管理系统 -多维表格 + 飞书组合 +views: one Base, multiple presentation modes +forms: information collection and aggregation +dashboard: data visualization +relations and lookup: data relationship modeling +automation and advanced permissions +Base-building methodology: build Base like a product +sales management system +HR recruiting and probation management system +product and engineering agile development management +Base plus Feishu collaboration combinations ``` Social-platform-facing Feishu content also emphasizes: ```text -一张表管公司 -企业驾驶舱 -多视图切换 -项目管理甘特图 -看板管理 -日历排期 -表单收集 -仪表盘分析 -小红书 / 抖音 / 电商内容数据管理 -AI 字段 / AI 总结 / 内容创作流程 +one table for company operations +enterprise cockpit +multi-view switching +project-management Gantt charts +Kanban management +calendar scheduling +form collection +dashboard analysis +Xiaohongshu / Douyin / e-commerce content data management +AI fields / AI summaries / content creation workflows ``` Direct Xiaohongshu pages are not reliably accessible through normal web indexing in this environment. The useful signal comes from Feishu official template and content pages that explicitly mention Xiaohongshu data management and content workflows: @@ -407,6 +407,12 @@ Design rule: GitLink actions should be executed through gitlink-cli native commands, not through Feishu-side ad hoc HTTP calls hidden in callbacks. ``` +Detailed GitLink capability boundary: + +```text +docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md +``` + ## 5. Bilibili / Xiaohongshu / Social Content Findings ### What Feishu Emphasizes Publicly @@ -414,18 +420,18 @@ GitLink actions should be executed through gitlink-cli native commands, not thro The Bilibili tutorial collection and Feishu public materials repeatedly emphasize: ```text -多维表格不是普通电子表格,而是轻量业务系统。 -同一份数据可以用多种视图呈现。 -管理者和执行者应该看不同视角。 -仪表盘是业务驾驶舱。 -表单用于收集。 -看板用于流程状态。 -甘特图用于时间计划。 -日历用于排期。 -画册用于卡片化展示。 -自动化用于减少人工搬运。 -高级权限用于控制敏感数据。 -AI 字段和总结用于内容生产、分类、分析。 +Base is positioned as a lightweight business system, not just a spreadsheet. +The same data can be presented through different views. +Managers and executors should see role-specific views. +Dashboards serve as business cockpits. +Forms collect structured input. +Kanban views show workflow state. +Gantt views show time plans. +Calendar views show schedules. +Gallery views show card-style records. +Automation reduces manual data movement. +Advanced permissions protect sensitive data. +AI fields and summaries support content production, classification, and analysis. ``` This matters for GitLink because GitLink data naturally has multiple roles: @@ -614,6 +620,12 @@ Until identity mapping exists, the CLI should only generate dry-run contributor ## 6. Current gitlink-cli Capability Matrix +This section summarizes the command surface. The detailed action-risk boundary is maintained in: + +```text +docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md +``` + ### Issue Available commands: diff --git a/docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md b/docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md new file mode 100644 index 0000000..af6a6bf --- /dev/null +++ b/docs/GITLINK_CLI_CAPABILITY_BOUNDARY.md @@ -0,0 +1,637 @@ +# GitLink CLI Capability Boundary + +Date: 2026-06-26 + +## Purpose + +This document defines the current `gitlink-cli` capability boundary before the Feishu integration grows from export-only reporting into a permissioned collaboration gateway. + +The key rule is: + +```text +Feishu can become an entry point, but gitlink-cli remains the authoritative GitLink data reader and action executor. +``` + +Any Feishu-triggered GitLink action must respect this boundary: + +```text +Feishu callback -> validate Feishu identity -> validate repo binding -> map to gitlink-cli action -> dry-run -> confirm -> execute -> audit +``` + +## Capability Levels + +Use these levels when deciding whether a GitLink command can be exposed through Feishu. + +| Level | Name | Meaning | Feishu Gateway Policy | +| --- | --- | --- | --- | +| Level 0 | Read / Local Analysis | Reads GitLink data or analyzes local input | Safe for Stage 1 cards, docs, Bitable-ready records | +| Level 1 | Low-Risk Write | Adds reversible or additive collaboration data | Stage 3 only, requires identity mapping, dry-run, confirmation, audit | +| Level 2 | Medium-Risk Write | Changes project state but is usually recoverable | Planned only, disabled by default | +| Level 3 | High-Risk Write | Merge, delete, close, permission, or membership changes | Do not expose by default; requires explicit dangerous-action opt-in | +| Admin | Credential / Raw API | Auth, config, arbitrary API calls | Do not expose through Feishu cards | + +## Current Command Surface + +Top-level command groups currently available: + +```text +api +auth +branch +ci +compare +config +dataset +doctor +feishu +health +ignore +issue +label +license +member +milestone +org +pipeline +pr +profile +release +repo +search +user +webhook +workflow +``` + +## Level 0: Read and Local Analysis + +These commands are appropriate inputs for Feishu reports, Bitable records, project dashboards, and owner / contributor digest cards. + +### Repository Read + +```text +repo +list +repo +info +repo +readme +repo +tree +repo +languages +repo +contributors +repo +contributor-stats +repo +code-stats +repo +watchers +repo +stargazers +``` + +Feishu use: + +```text +project overview +README / Wiki mirror +contributor dashboard +repository health report +organization cockpit +``` + +### Issue Read + +```text +issue +list +issue +view +issue +authors +issue +assigners +issue +priorities +issue +statuses +issue +tags +``` + +Feishu use: + +```text +issue risk summary +triage queue +personal task panel +dashboard by priority / status / stale age +``` + +### Pull Request Read + +```text +pr +list +pr +view +pr +files +pr +diff +pr +versions +pr +version-diff +pr +reviews +``` + +Feishu use: + +```text +PR stage cards +review queue +contributor feedback digest +rebase / conflict / review-round tracking +near-ready merge list +``` + +### CI and Pipeline Read + +```text +ci +builds +ci +logs +pipeline +list +pipeline +view +pipeline +runs +pipeline +logs +pipeline +results +pipeline +save-yaml +``` + +Feishu use: + +```text +CI failure digest +release readiness report +pipeline health dashboard +PR risk enrichment +``` + +### Webhook Read + +```text +webhook +list +webhook +view +webhook +tasks +``` + +Feishu use: + +```text +integration diagnostics +delivery failure summary +owner operational report +``` + +### Member / Organization Read + +```text +member +list +member +invite-info +org +list +org +info +org +members +``` + +Feishu use: + +```text +maintainer roster +reviewer capacity view +repository permission audit preview +``` + +### Milestone / Release / Dataset / Label Read + +```text +milestone +list +milestone +view +release +list +release +view +release +edit +dataset +list +dataset +view +label +list +license +list +``` + +Feishu use: + +```text +milestone Gantt source +release calendar +dataset inventory +issue label taxonomy +``` + +### Search / User / Profile / Compare + +```text +search +repos +search +users +user +me +user +info +profile +ability +profile +activity +profile +contribution +profile +major +profile +role +compare +view +compare +files +``` + +Feishu use: + +```text +contributor profile enrichment +organization talent view +release diff summary +project discovery +``` + +### Workflow and Health Analysis + +```text +workflow +triage +workflow +health +workflow +pr-summary +workflow +repo-report +health +fetch +doctor +version +``` + +Feishu use: + +```text +weekly report +owner digest +issue triage card +PR review summary +repository health dashboard +``` + +Boundary: + +```text +Workflow commands are the safest first-class source for Feishu Stage 1. +They should remain read-only or local-analysis commands. +``` + +## Level 1: Low-Risk Write + +These actions add collaboration information but do not normally destroy project state. + +Candidates: + +```text +issue +comment +issue +create +pr +comment +pr +review with common/comment +pr +review with approved +pr +review with rejected/request changes +``` + +Feishu Gateway policy: + +```text +Stage 3 only +requires self-built app callback validation +requires Feishu user -> GitLink user mapping +requires repo binding +requires GitLink token and permission check +requires dry-run preview +requires explicit confirmation +requires audit log +``` + +Why these are lower risk: + +```text +comments and reviews are additive +issue creation is visible and reversible by later close/edit +approval/request changes affects review state but does not merge code +``` + +Still not safe for Stage 1: + +```text +These are GitLink writes. They must not be exposed from custom bot cards or unauthenticated webhooks. +``` + +## Level 2: Medium-Risk Write + +These actions change workflow state and can disrupt project management, but they are usually recoverable. + +Candidates: + +```text +issue +update +pr +create +pr +reopen +ci +restart +ci +stop +pipeline +run +pipeline +enable +pipeline +disable +milestone +create +milestone +update +milestone +close +milestone +reopen +release +create +release +update +dataset +create +dataset +update +label +create +label +update +repo +follow +repo +unfollow +repo +like +repo +unlike +webhook +test +member +accept-invite +``` + +Feishu Gateway policy: + +```text +planned only +disabled by default +requires stronger confirmation than Level 1 +requires allowlist by action type and repository +requires audit log +should support dry-run where the underlying command supports it +``` + +Design note: + +```text +Some Level 2 actions can move to Level 1 only after the project has clear policy and tests. +For example, creating a milestone may be low-risk in one organization but not in another. +``` + +## Level 3: High-Risk Write + +These actions should not be exposed by default through Feishu. + +Actions: + +```text +pr +merge +pr +refuse +issue +close +issue +batch-close +branch +delete +branch +protect +branch +unprotect +release +delete +dataset +delete-attachment +label +delete +repo +delete +member +add +member +batch-add +member +remove +member +role +webhook +create +webhook +update +webhook +delete +pipeline +delete +org +create +repo +create +repo +fork +branch +create +``` + +Why high risk: + +```text +merge changes code history and release state +close/refuse can stop contributor work +delete actions can remove project assets +member actions change access control +webhook actions can exfiltrate or disrupt events +branch protection changes affect repository safety +repo/org creation can create governance and ownership issues +``` + +Feishu Gateway policy: + +```text +do not implement in the main Stage 3 path +only design as experimental +requires --enable-dangerous-actions or equivalent server config +requires maintainer / owner role check +requires repository allowlist +requires action-specific second confirmation +requires audit log with before/after payloads when available +requires rate limiting +requires rollback guidance where possible +``` + +## Admin and Raw API Surface + +These surfaces should not be exposed as Feishu card actions. + +```text +auth login +auth logout +config set +api arbitrary METHOD PATH +api --batch-file without strict allowlist +``` + +Reason: + +```text +They operate on credentials, local configuration, or arbitrary GitLink API requests. +They are too broad for a safe Feishu action gateway. +``` + +Allowed Feishu use: + +```text +show auth status diagnostics +show required setup steps +run app-check style read-only environment diagnostics +``` + +Not allowed: + +```text +collect GitLink passwords +display tokens +write credentials from Feishu payloads +execute arbitrary raw API requests from card callbacks +``` + +## Dry-Run and Confirmation Requirements + +Current gitlink-cli has uneven dry-run coverage. Some write commands support dry-run, some do not. + +Action Gateway must not assume all commands are safe to preview. + +Required gateway behavior: + +```text +Level 0: + can run read commands directly after repo binding validation + +Level 1: + must construct a dry-run preview + if native dry-run exists, use it + if native dry-run does not exist, render a gateway-level preview and stop before execution + +Level 2: + dry-run preview plus explicit confirmation + repository and action allowlist required + +Level 3: + disabled by default + dangerous-action opt-in required + second confirmation required +``` + +## Identity Boundary + +GitLink CLI and Feishu identities are different permission domains. + +Required mapping: + +```text +Feishu open_id / union_id / email -> GitLink username -> GitLink token or allowed service identity +``` + +Do not assume: + +```text +Feishu display name == GitLink username +Feishu email always exists +one Feishu user maps to exactly one GitLink user +group chat actor is authorized for all repository actions +``` + +Stage policy: + +```text +Stage 1: + no personal write actions, identity mapping optional + +Stage 2: + mapping can be used for dashboards and personal panels + +Stage 3: + mapping is mandatory before any GitLink write + +Stage 4: + mapping plus maintainer role verification is mandatory +``` + +## Feishu Integration Implications + +### Safe First Implementation + +Use Level 0 commands to produce: + +```text +owner digest +contributor digest preview +PR stage summary +Issue risk summary +CI status summary +Bitable-ready records +Doc/Wiki markdown +``` + +### Low-Risk Action Gateway + +Only after self-built app integration exists: + +```text +issue comment +PR comment +PR review comment +PR approve +PR request changes +create issue +``` + +### Explicitly Not First-Line Feishu Actions + +```text +merge PR +close issue +delete branch +delete release +add/remove member +change member role +create/update/delete webhook +raw API call +token/config operation +``` + +## Recommended Bitable Tables from GitLink Data + +The current `feishu +bitable-records` command emits summary records. For project management views, GitLink CLI should eventually produce row-level records. + +Recommended row-level tables: + +```text +repositories +pull_requests +issues +ci_runs +pipeline_runs +milestones +releases +contributors +members +webhook_deliveries +action_audit +``` + +View mapping: + +```text +Kanban: + pull_requests by stage + issues by status + +Gantt: + milestones by start_date / due_date + releases by release window + +Calendar: + issue due dates + review due dates + release dates + +Gallery: + contributors + features / merged PRs + +Dashboard: + repository health + PR stage counts + Issue risk counts + CI pass rate + stale work + +Personal task panel: + PRs authored by mapped user + issues assigned to mapped user + PRs waiting for mapped reviewer +``` + +## Final Boundary + +The clean GitLink boundary for Feishu is: + +```text +Stage 1: + Feishu displays GitLink state. + GitLink is read-only from Feishu. + +Stage 2: + Feishu stores GitLink-derived artifacts. + GitLink remains read-only from Feishu. + +Stage 3: + Feishu can request low-risk GitLink collaboration actions. + gitlink-cli executes only after validation, dry-run, confirmation, and audit. + +Stage 4: + High-risk GitLink actions are designed but disabled by default. +``` + +This boundary keeps `gitlink-cli` credible as the safe execution layer while still allowing Feishu to become the collaboration entry point. From 8bcc8607f5dea83ea2465cfebbbe053d85a83f17 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Fri, 26 Jun 2026 15:53:47 +0800 Subject: [PATCH 08/16] feat(feishu): add layered collaboration integration --- README.md | 36 +++ README.zh-CN.md | 36 +++ docs/FEISHU_CAPABILITY_LAYERS.md | 202 ++++++++++++ docs/FEISHU_ENVIRONMENT.md | 118 +++++++ docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md | 38 ++- docs/PR_REVIEW_NOTES_DRAFT.md | 12 + docs/PR_VISUAL_GUIDE.md | 42 +++ docs/feishu-bitable-schema.md | 41 +-- docs/feishu-integration.md | 105 ++++++- docs/feishu-security.md | 37 ++- docs/images/.gitkeep | 1 + reports/FEISHU_LOCAL_TESTING_GUIDE.md | 179 +++++++++++ reports/FEISHU_PERMISSION_MATRIX.md | 20 ++ reports/FEISHU_SMOKE_20260626.md | 138 +++++++++ reports/FEISHU_TASK_COMPLETION.md | 31 +- shortcuts/feishu/bitable.go | 375 +++++++++++++++-------- shortcuts/feishu/bitable_sync.go | 226 ++++++++++++++ shortcuts/feishu/digest.go | 292 ++++++++++++++++++ shortcuts/feishu/doc_export.go | 33 +- shortcuts/feishu/feishu.go | 167 +++++++++- shortcuts/feishu/feishu_test.go | 184 ++++++++++- shortcuts/feishu/openapi.go | 200 ++++++++++++ shortcuts/feishu/options.go | 33 ++ shortcuts/feishu/task.go | 313 +++++++++++++++++++ skills/gitlink-feishu/SKILL.md | 58 +++- 25 files changed, 2727 insertions(+), 190 deletions(-) create mode 100644 docs/FEISHU_CAPABILITY_LAYERS.md create mode 100644 docs/FEISHU_ENVIRONMENT.md create mode 100644 docs/PR_REVIEW_NOTES_DRAFT.md create mode 100644 docs/PR_VISUAL_GUIDE.md create mode 100644 docs/images/.gitkeep create mode 100644 reports/FEISHU_LOCAL_TESTING_GUIDE.md create mode 100644 reports/FEISHU_PERMISSION_MATRIX.md create mode 100644 reports/FEISHU_SMOKE_20260626.md create mode 100644 shortcuts/feishu/bitable_sync.go create mode 100644 shortcuts/feishu/digest.go create mode 100644 shortcuts/feishu/task.go 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 <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 <owner> --repo <repo> --format json > - Never use BotBuilder or Robot Assistant workflows. - Do not write to GitLink resources. - Do not print webhook URLs, app secrets, access tokens, or table tokens. -- Treat `+bitable-schema` and `+bitable-records` as local dry-run commands only. +- Treat `+bitable-schema`, `+bitable-records`, and `+task-preview` as local dry-run commands only. - Treat `+doc-export` as experimental because it uses self-built app OpenAPI and document write permissions. +- Treat `+bitable-sync` and `+task-create` as experimental because they use self-built app OpenAPI and resource permissions. ## Preview Flow @@ -58,6 +60,13 @@ Render a weekly report: gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown ``` +Preview owner and contributor digests: + +```bash +gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown +gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown +``` + Generate Bitable schemas: ```bash @@ -70,6 +79,12 @@ Generate Bitable-ready records: gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json ``` +Preview task candidates: + +```bash +gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown +``` + ## Send Flow Custom bot commands need: @@ -91,6 +106,13 @@ Send a weekly report card: gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table ``` +Send owner and contributor digest cards: + +```bash +gitlink-cli feishu +owner-digest --from-workflow-json report.json --send --format table +gitlink-cli feishu +contributor-digest --from-workflow-json report.json --send --format table +``` + ## Experimental Doc Export DocX / Wiki export needs: @@ -114,11 +136,41 @@ gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wik If Feishu returns `1770032: forBidden`, the app token is valid but the app cannot write to the target DocX/Wiki page or folder. +## Experimental Bitable Sync And Task Create + +Open Platform validation commands need: + +```text +FEISHU_APP_ID +FEISHU_APP_SECRET +FEISHU_BASE_APP_TOKEN for bitable-sync +FEISHU_REPORT_TABLE_ID / FEISHU_ISSUE_TABLE_ID / FEISHU_PR_TABLE_ID for selected tables +``` + +Preview Bitable sync: + +```bash +gitlink-cli feishu +bitable-sync --from-workflow-json report.json --tables reports,issues,prs,tasks --format table +``` + +Write Bitable records: + +```bash +gitlink-cli feishu +bitable-sync --from-workflow-json report.json --tables reports,issues,prs,tasks --send --format table +``` + +Create Feishu tasks: + +```bash +gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table +``` + ## Non-Goals - No GitLink remote writes. - No GitLink comments, issue closure, merge, or webhook creation. - No Bitable real writes in the stable path. +- No Feishu task creation in the stable path. - No BotBuilder integration. - No automatic Feishu permission changes. From 73da46c143b37cb2b26e9e624b8c39963ad52d77 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Fri, 26 Jun 2026 16:28:57 +0800 Subject: [PATCH 09/16] test(feishu): add local setup and smoke scripts --- .gitignore | 6 + .local/feishu-gitlink.env.example.ps1 | 30 ++ README.md | 10 + README.zh-CN.md | 10 + docs/FEISHU_ENVIRONMENT.md | 21 ++ docs/PR_VISUAL_GUIDE.md | 6 + docs/feishu-integration.md | 18 ++ reports/FEISHU_LOCAL_TESTING_GUIDE.md | 123 +++++-- reports/FEISHU_SMOKE_20260626.md | 140 +++----- scripts/feishu-gitlink-env-check.ps1 | 88 +++++ scripts/feishu-gitlink-screenshot-check.ps1 | 35 ++ scripts/feishu-gitlink-setup.ps1 | 199 ++++++++++++ scripts/feishu-gitlink-smoke.ps1 | 336 ++++++++++++++++++++ 13 files changed, 906 insertions(+), 116 deletions(-) create mode 100644 .local/feishu-gitlink.env.example.ps1 create mode 100644 scripts/feishu-gitlink-env-check.ps1 create mode 100644 scripts/feishu-gitlink-screenshot-check.ps1 create mode 100644 scripts/feishu-gitlink-setup.ps1 create mode 100644 scripts/feishu-gitlink-smoke.ps1 diff --git a/.gitignore b/.gitignore index bd0ccf8..5bdeb90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ gitlink-cli.exe /gitlink-cli +.local/* +!.local/ +!.local/feishu-gitlink.env.example.ps1 +*.local.ps1 +*.secret.* +reports/feishu-real-smoke-terminal.log diff --git a/.local/feishu-gitlink.env.example.ps1 b/.local/feishu-gitlink.env.example.ps1 new file mode 100644 index 0000000..61d98a5 --- /dev/null +++ b/.local/feishu-gitlink.env.example.ps1 @@ -0,0 +1,30 @@ +# Stable Feishu webhook +$env:FEISHU_WEBHOOK_URL="" +$env:FEISHU_WEBHOOK_SECRET="" + +# Feishu Open Platform +$env:FEISHU_APP_ID="" +$env:FEISHU_APP_SECRET="" + +# Feishu DocX / Wiki +$env:FEISHU_WIKI_URL="" +$env:FEISHU_WIKI_NODE_TOKEN="" +$env:FEISHU_FOLDER_TOKEN="" + +# Feishu Base / Bitable +$env:FEISHU_BASE_APP_TOKEN="" +$env:FEISHU_REPORT_TABLE_ID="" +$env:FEISHU_ISSUE_TABLE_ID="" +$env:FEISHU_PR_TABLE_ID="" +$env:FEISHU_CONTRIBUTOR_TABLE_ID="" +$env:FEISHU_TASK_TABLE_ID="" + +# Feishu Task +$env:FEISHU_TASK_PROJECT_ID="" +$env:FEISHU_TASK_SECTION_ID="" + +# GitLink real test input +$env:GITLINK_OWNER="" +$env:GITLINK_REPO="" +$env:GITLINK_TEST_PR_IDS="" +$env:GITLINK_TOKEN="" diff --git a/README.md b/README.md index e967337..c7e7f29 100644 --- a/README.md +++ b/README.md @@ -686,6 +686,16 @@ Details: - [Feishu environment variables](./docs/FEISHU_ENVIRONMENT.md) - [Feishu permission matrix](./reports/FEISHU_PERMISSION_MATRIX.md) +Local setup and smoke testing: + +```powershell +.\scripts\feishu-gitlink-setup.ps1 +.\scripts\feishu-gitlink-env-check.ps1 -Layer stable +.\scripts\feishu-gitlink-smoke.ps1 -Mode preview +``` + +The setup script stores real values only in `.local/feishu-gitlink.env.ps1`, which is ignored. + ### Dataset `dataset` manages and queries GitLink research datasets (title, description, diff --git a/README.zh-CN.md b/README.zh-CN.md index 46f95f2..6571f96 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -586,6 +586,16 @@ gitlink-cli feishu +task-create --from-workflow-json report.json --send --format - [飞书能力分层](./docs/FEISHU_CAPABILITY_LAYERS.md) - [飞书环境变量](./docs/FEISHU_ENVIRONMENT.md) - [飞书权限矩阵](./reports/FEISHU_PERMISSION_MATRIX.md) + +本地配置和 smoke 测试: + +```powershell +.\scripts\feishu-gitlink-setup.ps1 +.\scripts\feishu-gitlink-env-check.ps1 -Layer stable +.\scripts\feishu-gitlink-smoke.ps1 -Mode preview +``` + +真实值只会写入被忽略的 `.local/feishu-gitlink.env.ps1`,不要提交。 ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: diff --git a/docs/FEISHU_ENVIRONMENT.md b/docs/FEISHU_ENVIRONMENT.md index 71ecabc..eeff1c5 100644 --- a/docs/FEISHU_ENVIRONMENT.md +++ b/docs/FEISHU_ENVIRONMENT.md @@ -4,6 +4,26 @@ Date: 2026-06-26 Do not commit real values. Use a local shell profile, CI secret store, or test terminal session. +Recommended local workflow: + +```powershell +.\scripts\feishu-gitlink-setup.ps1 +.\scripts\feishu-gitlink-env-check.ps1 -Layer stable +.\scripts\feishu-gitlink-smoke.ps1 -Mode preview +``` + +`feishu-gitlink-setup.ps1` opens the relevant Feishu / GitLink pages, lets the user paste values locally, and writes only to: + +```text +.local/feishu-gitlink.env.ps1 +``` + +The real local env file is ignored. The tracked example is: + +```text +.local/feishu-gitlink.env.example.ps1 +``` + ## Stable Custom Bot Variables | Name | Purpose | Required | Used by | Sensitive | How to obtain | @@ -112,6 +132,7 @@ $env:GITLINK_TOKEN="REDACTED" ```text Never paste real secrets into committed docs. +Never paste real secrets into ChatGPT. Never print raw webhook URLs or app secrets in smoke reports. Do not commit tenant_access_token or user_access_token. Do not enable --send in shared scripts unless the target test enterprise is intentional. diff --git a/docs/PR_VISUAL_GUIDE.md b/docs/PR_VISUAL_GUIDE.md index 3d09734..1a210f2 100644 --- a/docs/PR_VISUAL_GUIDE.md +++ b/docs/PR_VISUAL_GUIDE.md @@ -6,6 +6,12 @@ This file lists the manual screenshots to capture after local and real smoke tes 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`. +Use the helper to check current screenshot status: + +```powershell +.\scripts\feishu-gitlink-screenshot-check.ps1 +``` + | 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` | diff --git a/docs/feishu-integration.md b/docs/feishu-integration.md index 2114dee..1460104 100644 --- a/docs/feishu-integration.md +++ b/docs/feishu-integration.md @@ -53,6 +53,15 @@ Experimental commands use Feishu self-built app OpenAPI and are not part of the Use a Feishu custom group bot for notification cards. +Recommended local setup: + +```powershell +.\scripts\feishu-gitlink-setup.ps1 +.\scripts\feishu-gitlink-env-check.ps1 -Layer stable +``` + +The setup wizard opens Feishu / GitLink pages and stores values only in `.local/feishu-gitlink.env.ps1`. + Environment: ```powershell @@ -288,3 +297,12 @@ docs/FEISHU_ENVIRONMENT.md reports/FEISHU_PERMISSION_MATRIX.md reports/FEISHU_LOCAL_TESTING_GUIDE.md ``` + +Scripted smoke test: + +```powershell +.\scripts\feishu-gitlink-smoke.ps1 -Mode preview +.\scripts\feishu-gitlink-smoke.ps1 -Mode stable +.\scripts\feishu-gitlink-smoke.ps1 -Mode open-platform +.\scripts\feishu-gitlink-smoke.ps1 -Mode all +``` diff --git a/reports/FEISHU_LOCAL_TESTING_GUIDE.md b/reports/FEISHU_LOCAL_TESTING_GUIDE.md index 40d739b..19b4405 100644 --- a/reports/FEISHU_LOCAL_TESTING_GUIDE.md +++ b/reports/FEISHU_LOCAL_TESTING_GUIDE.md @@ -4,7 +4,48 @@ Date: 2026-06-26 This guide verifies the layered Feishu integration without committing secrets. -## 1. Configure GitLink Test Repository +## 1. Run Local Setup Wizard + +```powershell +.\scripts\feishu-gitlink-setup.ps1 +``` + +The setup wizard opens these pages as needed: + +```text +Feishu custom bot documentation +Feishu developer console +Feishu Docs / Wiki +Feishu Base / Bitable +Feishu Tasks +GitLink +``` + +The user logs in and authorizes in the browser. Values are pasted into the local PowerShell prompt, not into ChatGPT. + +The wizard writes only to: + +```text +.local/feishu-gitlink.env.ps1 +``` + +This file is ignored and must not be committed. A tracked empty example is available at: + +```text +.local/feishu-gitlink.env.example.ps1 +``` + +## 2. Check Local Environment + +```powershell +.\scripts\feishu-gitlink-env-check.ps1 -Layer stable +.\scripts\feishu-gitlink-env-check.ps1 -Layer open-platform +.\scripts\feishu-gitlink-env-check.ps1 -Layer all +``` + +The checker prints only redacted values. + +## 3. Configure GitLink Test Repository Manually If Needed ```powershell $env:GITLINK_OWNER="OWNER" @@ -17,7 +58,43 @@ If the current workflow command cannot filter specific PR IDs, keep the PR IDs i $env:GITLINK_TEST_PR_IDS="1,2,3" ``` -## 2. Generate Workflow Report JSON +## 4. Run Scripted Smoke Tests + +Preview only: + +```powershell +.\scripts\feishu-gitlink-smoke.ps1 -Mode preview +``` + +Stable custom bot sends: + +```powershell +.\scripts\feishu-gitlink-smoke.ps1 -Mode stable +``` + +Experimental Open Platform sends: + +```powershell +.\scripts\feishu-gitlink-smoke.ps1 -Mode open-platform +``` + +All available tests: + +```powershell +.\scripts\feishu-gitlink-smoke.ps1 -Mode all +``` + +The smoke runner writes: + +```text +.local/report.json +reports/FEISHU_SMOKE_YYYYMMDD.md +reports/feishu-real-smoke-terminal.log +``` + +The terminal log is ignored and must not be committed after real runs. + +## 5. Generate Workflow Report JSON Manually ```bash gitlink-cli workflow +repo-report \ @@ -28,13 +105,13 @@ gitlink-cli workflow +repo-report \ Windows PowerShell redirection may produce UTF-16 with BOM. The Feishu workflow JSON reader supports UTF-8 and UTF-16 BOM inputs. -## 3. Preview Feishu Notify Card +## 6. Preview Feishu Notify Card ```bash gitlink-cli feishu +notify --from-workflow-json report.json --format json ``` -## 4. Send Feishu Notify Card +## 7. Send Feishu Notify Card ```bash gitlink-cli feishu +notify --from-workflow-json report.json --send --format table @@ -47,50 +124,50 @@ FEISHU_WEBHOOK_URL FEISHU_WEBHOOK_SECRET optional ``` -## 5. Render Weekly Report +## 8. Render Weekly Report ```bash gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown ``` -## 6. Send Weekly Report +## 9. Send Weekly Report ```bash gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table ``` -## 7. Generate Owner Digest +## 10. Generate Owner Digest ```bash gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown ``` -## 8. Send Owner Digest +## 11. Send Owner Digest ```bash gitlink-cli feishu +owner-digest --from-workflow-json report.json --send --format table ``` -## 9. Generate Contributor Digest +## 12. Generate Contributor Digest ```bash gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown ``` -## 10. Send Contributor Digest +## 13. Send Contributor Digest ```bash gitlink-cli feishu +contributor-digest --from-workflow-json report.json --send --format table ``` -## 11. Generate Bitable-Ready Records +## 14. 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 +## 15. Preview Bitable Sync ```bash gitlink-cli feishu +bitable-sync \ @@ -99,7 +176,7 @@ gitlink-cli feishu +bitable-sync \ --format table ``` -## 13. Execute Bitable Sync +## 16. Execute Bitable Sync ```bash gitlink-cli feishu +bitable-sync \ @@ -122,7 +199,7 @@ FEISHU_CONTRIBUTOR_TABLE_ID optional FEISHU_TASK_TABLE_ID optional ``` -## 14. Preview DocX / Wiki Export +## 17. Preview DocX / Wiki Export ```bash gitlink-cli feishu +doc-export \ @@ -131,7 +208,7 @@ gitlink-cli feishu +doc-export \ --format markdown ``` -## 15. Execute DocX / Wiki Export +## 18. Execute DocX / Wiki Export ```bash gitlink-cli feishu +doc-export \ @@ -141,13 +218,13 @@ gitlink-cli feishu +doc-export \ --format table ``` -## 16. Preview Feishu Tasks +## 19. Preview Feishu Tasks ```bash gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown ``` -## 17. Create Feishu Tasks +## 20. Create Feishu Tasks ```bash gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table @@ -162,7 +239,15 @@ FEISHU_TASK_PROJECT_ID optional FEISHU_TASK_SECTION_ID optional ``` -## 18. Run Go Tests +## 21. Run Screenshot Check + +```powershell +.\scripts\feishu-gitlink-screenshot-check.ps1 +``` + +The script lists missing screenshots. It does not fabricate images. + +## 22. Run Go Tests ```bash gofmt -w shortcuts/feishu @@ -172,7 +257,7 @@ go test ./shortcuts go test ./... ``` -## 19. Capture Evidence +## 23. Capture Evidence Capture terminal logs and screenshots listed in `docs/PR_VISUAL_GUIDE.md`. diff --git a/reports/FEISHU_SMOKE_20260626.md b/reports/FEISHU_SMOKE_20260626.md index ab760e1..2acba26 100644 --- a/reports/FEISHU_SMOKE_20260626.md +++ b/reports/FEISHU_SMOKE_20260626.md @@ -1,6 +1,6 @@ -# Feishu Smoke Report +# Feishu Smoke Report -Date: 2026-06-26 +Date: 2026-06-26 16:29:18 +08:00 ## Branch @@ -11,23 +11,17 @@ feat/feishu-export-clean ## Commit ```text -working tree smoke before final implementation commit; base HEAD before this implementation: bcdab0b +9255518304e1a6b0fba9f9e5eee9bdf4f62d8e04 ``` -## Test Environment +## Mode ```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 +preview ``` ## Redacted Environment Presence -This section must record presence only, not raw values: - | Variable | Present? | | --- | --- | | `FEISHU_WEBHOOK_URL` | missing | @@ -35,104 +29,56 @@ This section must record presence only, not raw values: | `FEISHU_APP_ID` | missing | | `FEISHU_APP_SECRET` | missing | | `FEISHU_WIKI_URL` | missing | +| `FEISHU_WIKI_NODE_TOKEN` | missing | +| `FEISHU_FOLDER_TOKEN` | missing | | `FEISHU_BASE_APP_TOKEN` | missing | | `FEISHU_REPORT_TABLE_ID` | missing | | `FEISHU_ISSUE_TABLE_ID` | missing | | `FEISHU_PR_TABLE_ID` | missing | +| `FEISHU_CONTRIBUTOR_TABLE_ID` | missing | +| `FEISHU_TASK_TABLE_ID` | missing | | `FEISHU_TASK_PROJECT_ID` | missing | +| `FEISHU_TASK_SECTION_ID` | missing | | `GITLINK_OWNER` | missing | | `GITLINK_REPO` | missing | | `GITLINK_TEST_PR_IDS` | missing | +| `GITLINK_TOKEN` | missing | -## Commands Run So Far +## Results -```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 | +| Command | Result | Details | | --- | --- | --- | -| `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 | +| feishu help | pass | exit=0 | +| feishu +owner-digest help | pass | exit=0 | +| feishu +contributor-digest help | pass | exit=0 | +| feishu +bitable-sync help | pass | exit=0 | +| feishu +task-preview help | pass | exit=0 | +| feishu +task-create help | pass | exit=0 | +| workflow +repo-report | pass | report=.local/report.json; owner=Gitlink; repo=gitlink-cli | +| notify preview | pass | exit=0 | +| weekly report preview | pass | exit=0 | +| owner digest preview | pass | exit=0 | +| contributor digest preview | pass | exit=0 | +| bitable records preview | pass | exit=0 | +| task preview | pass | exit=0 | -## Real Feishu Webhook Result +## Notes -```text -not executed: FEISHU_WEBHOOK_URL was not present in the current shell. +- No .local/feishu-gitlink.env.ps1 file found. Preview smoke can run with public fallback data; real sends are skipped. +- GITLINK_OWNER/GITLINK_REPO were missing. Preview smoke used public Gitlink/gitlink-cli as a fallback. + +## Terminal Log + +Local redacted terminal log: `reports/feishu-real-smoke-terminal.log` + +This log file is ignored and should not be committed after real runs. + +## Screenshot Checklist + +Run: + +```powershell +.\scripts\feishu-gitlink-screenshot-check.ps1 ``` -## 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. +Do not fabricate screenshots. Capture missing images manually after real Feishu runs. diff --git a/scripts/feishu-gitlink-env-check.ps1 b/scripts/feishu-gitlink-env-check.ps1 new file mode 100644 index 0000000..e1b0f5f --- /dev/null +++ b/scripts/feishu-gitlink-env-check.ps1 @@ -0,0 +1,88 @@ +param( + [ValidateSet("stable", "open-platform", "all")] + [string]$Layer = "stable" +) + +$ErrorActionPreference = "Stop" +$RepoRoot = Split-Path -Parent $PSScriptRoot +$LocalEnv = Join-Path $RepoRoot ".local/feishu-gitlink.env.ps1" + +function Redact-Value { + param([string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return "" } + if ($Value.Length -le 8) { return "***" } + return "$($Value.Substring(0, 4))...$($Value.Substring($Value.Length - 4))" +} + +function Show-Var { + param( + [string]$Name, + [bool]$Sensitive = $true + ) + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { + Write-Host ("{0}: missing" -f $Name) + return $false + } + if ($Sensitive) { + Write-Host ("{0}: set ({1})" -f $Name, (Redact-Value $value)) + } else { + Write-Host ("{0}: set ({1})" -f $Name, $value) + } + return $true +} + +function Test-Group { + param( + [string]$Title, + [string[]]$Names, + [string[]]$Optional = @(), + [string[]]$NonSensitive = @() + ) + Write-Host "" + Write-Host "[$Title]" + $missingRequired = @() + foreach ($name in $Names) { + $isOptional = $Optional -contains $name + $isSensitive = -not ($NonSensitive -contains $name) + $present = Show-Var -Name $name -Sensitive:$isSensitive + if (-not $present -and -not $isOptional) { + $missingRequired += $name + } + } + return $missingRequired +} + +if (Test-Path $LocalEnv) { + . $LocalEnv + Write-Host "Loaded local env: .local/feishu-gitlink.env.ps1" +} else { + Write-Host "No local env file found. Run scripts/feishu-gitlink-setup.ps1 or copy .local/feishu-gitlink.env.example.ps1." +} + +$allMissing = @() + +if ($Layer -eq "stable" -or $Layer -eq "all") { + $allMissing += Test-Group -Title "Stable webhook" -Names @("FEISHU_WEBHOOK_URL", "FEISHU_WEBHOOK_SECRET") -Optional @("FEISHU_WEBHOOK_SECRET") +} + +if ($Layer -eq "open-platform" -or $Layer -eq "all") { + $allMissing += Test-Group -Title "Open Platform app" -Names @("FEISHU_APP_ID", "FEISHU_APP_SECRET") + $allMissing += Test-Group -Title "DocX / Wiki" -Names @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN") -Optional @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN") + $allMissing += Test-Group -Title "Base / Bitable" -Names @("FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID", "FEISHU_CONTRIBUTOR_TABLE_ID", "FEISHU_TASK_TABLE_ID") -Optional @("FEISHU_CONTRIBUTOR_TABLE_ID", "FEISHU_TASK_TABLE_ID") + $allMissing += Test-Group -Title "Feishu Task" -Names @("FEISHU_TASK_PROJECT_ID", "FEISHU_TASK_SECTION_ID") -Optional @("FEISHU_TASK_PROJECT_ID", "FEISHU_TASK_SECTION_ID") +} + +if ($Layer -eq "all") { + $allMissing += Test-Group -Title "GitLink test input" -Names @("GITLINK_OWNER", "GITLINK_REPO", "GITLINK_TEST_PR_IDS", "GITLINK_TOKEN") -Optional @("GITLINK_TEST_PR_IDS", "GITLINK_TOKEN") -NonSensitive @("GITLINK_OWNER", "GITLINK_REPO", "GITLINK_TEST_PR_IDS") +} + +if ($allMissing.Count -gt 0) { + Write-Host "" + Write-Host "Missing required values for layer '$Layer': $($allMissing -join ', ')" + exit 1 +} + +Write-Host "" +Write-Host "Layer '$Layer' is ready." +exit 0 diff --git a/scripts/feishu-gitlink-screenshot-check.ps1 b/scripts/feishu-gitlink-screenshot-check.ps1 new file mode 100644 index 0000000..957b728 --- /dev/null +++ b/scripts/feishu-gitlink-screenshot-check.ps1 @@ -0,0 +1,35 @@ +$ErrorActionPreference = "Stop" +$RepoRoot = Split-Path -Parent $PSScriptRoot +$Expected = @( + "docs/images/feishu-bot-card.png", + "docs/images/feishu-weekly-report.png", + "docs/images/feishu-owner-digest.png", + "docs/images/feishu-contributor-digest.png", + "docs/images/feishu-bitable-preview.png", + "docs/images/feishu-bitable-sync.png", + "docs/images/feishu-docx-wiki.png", + "docs/images/feishu-task-create.png", + "docs/images/feishu-smoke-terminal.png", + "docs/images/feishu-env-redacted.png" +) + +$missing = @() +foreach ($relative in $Expected) { + $path = Join-Path $RepoRoot $relative + if (Test-Path $path) { + Write-Host "Found screenshot: $relative" + } else { + Write-Host "Missing screenshot: $relative" + Write-Host "Open Feishu or terminal and capture this screenshot manually." + $missing += $relative + } +} + +if ($missing.Count -gt 0) { + Write-Host "" + Write-Host "Missing screenshots: $($missing.Count)" + exit 1 +} + +Write-Host "All expected screenshots are present." +exit 0 diff --git a/scripts/feishu-gitlink-setup.ps1 b/scripts/feishu-gitlink-setup.ps1 new file mode 100644 index 0000000..476762a --- /dev/null +++ b/scripts/feishu-gitlink-setup.ps1 @@ -0,0 +1,199 @@ +$ErrorActionPreference = "Stop" +$RepoRoot = Split-Path -Parent $PSScriptRoot +$LocalDir = Join-Path $RepoRoot ".local" +$LocalEnv = Join-Path $LocalDir "feishu-gitlink.env.ps1" + +function Redact { + param([string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return "missing" } + if ($Value.Length -le 8) { return "***" } + return "$($Value.Substring(0, 4))...$($Value.Substring($Value.Length - 4))" +} + +function Escape-PSString { + param([string]$Value) + if ($null -eq $Value) { return "" } + return $Value.Replace('`', '``').Replace('"', '`"') +} + +function SecureString-ToPlainText { + param([System.Security.SecureString]$Secure) + if ($null -eq $Secure) { return "" } + $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) + try { + return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) + } finally { + if ($ptr -ne [IntPtr]::Zero) { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) + } + } +} + +function Read-OptionalValue { + param( + [string]$Name, + [string]$CurrentValue, + [string]$Hint + ) + $currentLabel = if ([string]::IsNullOrWhiteSpace($CurrentValue)) { "empty" } else { Redact $CurrentValue } + Write-Host "" + Write-Host "$Name ($currentLabel)" + if ($Hint) { Write-Host $Hint } + $value = Read-Host "Enter value, or press Enter to keep current" + if ([string]::IsNullOrWhiteSpace($value)) { return $CurrentValue } + return $value.Trim() +} + +function Read-SecretValue { + param( + [string]$Name, + [string]$CurrentValue, + [string]$Hint + ) + $currentLabel = if ([string]::IsNullOrWhiteSpace($CurrentValue)) { "empty" } else { "set" } + Write-Host "" + Write-Host "$Name ($currentLabel)" + if ($Hint) { Write-Host $Hint } + $secure = Read-Host "Enter secret, or press Enter to keep current" -AsSecureString + $plain = SecureString-ToPlainText $secure + if ([string]::IsNullOrWhiteSpace($plain)) { return $CurrentValue } + return $plain.Trim() +} + +function Open-Url { + param( + [string]$Url, + [string]$Reason + ) + Write-Host "" + Write-Host "Opening: $Reason" + Write-Host $Url + Start-Process $Url +} + +function Pause-User { + param([string]$Message) + [void](Read-Host $Message) +} + +function Env { + param([string]$Name) + return [Environment]::GetEnvironmentVariable($Name) +} + +function Missing { + param([string[]]$Names) + foreach ($name in $Names) { + if ([string]::IsNullOrWhiteSpace((Env $name))) { return $true } + } + return $false +} + +New-Item -ItemType Directory -Force -Path $LocalDir | Out-Null +if (Test-Path $LocalEnv) { + . $LocalEnv + Write-Host "Loaded existing local env: .local/feishu-gitlink.env.ps1" +} + +Write-Host "This setup writes values only to .local/feishu-gitlink.env.ps1." +Write-Host "Do not paste secrets into chat or tracked docs." + +if (Missing @("FEISHU_WEBHOOK_URL")) { + Open-Url "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot" "Feishu custom bot guide" + Pause-User "Add a custom bot to your Feishu test group and copy the webhook URL. Press Enter when ready." + $env:FEISHU_WEBHOOK_URL = Read-OptionalValue "FEISHU_WEBHOOK_URL" (Env "FEISHU_WEBHOOK_URL") "Used by +bot-test, +notify, +weekly-report, +owner-digest, and +contributor-digest." + $env:FEISHU_WEBHOOK_SECRET = Read-SecretValue "FEISHU_WEBHOOK_SECRET" (Env "FEISHU_WEBHOOK_SECRET") "Optional signing secret from the custom bot security settings." +} + +if (Missing @("FEISHU_APP_ID", "FEISHU_APP_SECRET")) { + Open-Url "https://open.feishu.cn/app" "Feishu developer console" + Pause-User "Create/open a self-built app, enable required scopes, and copy app_id/app_secret. Press Enter when ready." + $env:FEISHU_APP_ID = Read-OptionalValue "FEISHU_APP_ID" (Env "FEISHU_APP_ID") "Used by experimental +doc-export, +bitable-sync, and +task-create." + $env:FEISHU_APP_SECRET = Read-SecretValue "FEISHU_APP_SECRET" (Env "FEISHU_APP_SECRET") "Self-built app secret." +} + +if (Missing @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN")) { + Open-Url "https://www.feishu.cn/" "Feishu Docs / Wiki" + Pause-User "Open your target Wiki/Doc page or folder and copy the URL/token. Press Enter when ready." + $env:FEISHU_WIKI_URL = Read-OptionalValue "FEISHU_WIKI_URL" (Env "FEISHU_WIKI_URL") "Existing Wiki or Doc URL for +doc-export." + $env:FEISHU_WIKI_NODE_TOKEN = Read-OptionalValue "FEISHU_WIKI_NODE_TOKEN" (Env "FEISHU_WIKI_NODE_TOKEN") "Optional. Usually parsed from FEISHU_WIKI_URL." + $env:FEISHU_FOLDER_TOKEN = Read-OptionalValue "FEISHU_FOLDER_TOKEN" (Env "FEISHU_FOLDER_TOKEN") "Optional. Used when creating a new DocX in a folder." +} + +if (Missing @("FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID")) { + Open-Url "https://www.feishu.cn/" "Feishu Base / Bitable" + Pause-User "Open/create the Base for reports, issues, prs, contributors, and tasks. Press Enter when table IDs are ready." + Write-Host "Expected tables: reports, issues, prs, contributors, tasks." + Write-Host "Each selected table should contain at least unique_key and repository." + $env:FEISHU_BASE_APP_TOKEN = Read-OptionalValue "FEISHU_BASE_APP_TOKEN" (Env "FEISHU_BASE_APP_TOKEN") "Base app token." + $env:FEISHU_REPORT_TABLE_ID = Read-OptionalValue "FEISHU_REPORT_TABLE_ID" (Env "FEISHU_REPORT_TABLE_ID") "Reports table ID." + $env:FEISHU_ISSUE_TABLE_ID = Read-OptionalValue "FEISHU_ISSUE_TABLE_ID" (Env "FEISHU_ISSUE_TABLE_ID") "Issues table ID." + $env:FEISHU_PR_TABLE_ID = Read-OptionalValue "FEISHU_PR_TABLE_ID" (Env "FEISHU_PR_TABLE_ID") "Pull request table ID." + $env:FEISHU_CONTRIBUTOR_TABLE_ID = Read-OptionalValue "FEISHU_CONTRIBUTOR_TABLE_ID" (Env "FEISHU_CONTRIBUTOR_TABLE_ID") "Optional contributor table ID." + $env:FEISHU_TASK_TABLE_ID = Read-OptionalValue "FEISHU_TASK_TABLE_ID" (Env "FEISHU_TASK_TABLE_ID") "Optional task candidate table ID." +} + +if (Missing @("FEISHU_TASK_PROJECT_ID", "FEISHU_TASK_SECTION_ID")) { + Open-Url "https://www.feishu.cn/" "Feishu Tasks" + Pause-User "Open the Feishu Task project/section if needed. Press Enter when IDs are ready." + $env:FEISHU_TASK_PROJECT_ID = Read-OptionalValue "FEISHU_TASK_PROJECT_ID" (Env "FEISHU_TASK_PROJECT_ID") "Optional. Some Task API paths may not require it." + $env:FEISHU_TASK_SECTION_ID = Read-OptionalValue "FEISHU_TASK_SECTION_ID" (Env "FEISHU_TASK_SECTION_ID") "Optional." +} + +if (Missing @("GITLINK_OWNER", "GITLINK_REPO")) { + Open-Url "https://www.gitlink.org.cn/" "GitLink" + Pause-User "Open the target GitLink repository and the previous 3 PRs. Press Enter when ready." + $env:GITLINK_OWNER = Read-OptionalValue "GITLINK_OWNER" (Env "GITLINK_OWNER") "Repository owner, for example Gitlink." + $env:GITLINK_REPO = Read-OptionalValue "GITLINK_REPO" (Env "GITLINK_REPO") "Repository name, for example gitlink-cli." + $env:GITLINK_TEST_PR_IDS = Read-OptionalValue "GITLINK_TEST_PR_IDS" (Env "GITLINK_TEST_PR_IDS") "Comma-separated PR IDs for smoke report references." + $env:GITLINK_TOKEN = Read-SecretValue "GITLINK_TOKEN" (Env "GITLINK_TOKEN") "Optional if gitlink-cli is already logged in." +} + +if (-not [string]::IsNullOrWhiteSpace($env:GITLINK_OWNER) -and -not [string]::IsNullOrWhiteSpace($env:GITLINK_REPO)) { + Open-Url "https://www.gitlink.org.cn/$env:GITLINK_OWNER/$env:GITLINK_REPO" "Target GitLink repository" + if (-not [string]::IsNullOrWhiteSpace($env:GITLINK_TEST_PR_IDS)) { + Write-Host "GITLINK_TEST_PR_IDS set: $env:GITLINK_TEST_PR_IDS" + Write-Host "Verify the PR page URL pattern manually if needed." + } +} + +$vars = @( + "FEISHU_WEBHOOK_URL", + "FEISHU_WEBHOOK_SECRET", + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_WIKI_URL", + "FEISHU_WIKI_NODE_TOKEN", + "FEISHU_FOLDER_TOKEN", + "FEISHU_BASE_APP_TOKEN", + "FEISHU_REPORT_TABLE_ID", + "FEISHU_ISSUE_TABLE_ID", + "FEISHU_PR_TABLE_ID", + "FEISHU_CONTRIBUTOR_TABLE_ID", + "FEISHU_TASK_TABLE_ID", + "FEISHU_TASK_PROJECT_ID", + "FEISHU_TASK_SECTION_ID", + "GITLINK_OWNER", + "GITLINK_REPO", + "GITLINK_TEST_PR_IDS", + "GITLINK_TOKEN" +) + +$lines = @( + "# Local Feishu/GitLink smoke-test environment.", + "# Generated by scripts/feishu-gitlink-setup.ps1.", + "# Do not commit this file." +) +foreach ($name in $vars) { + $value = [Environment]::GetEnvironmentVariable($name) + $lines += ('$' + ('env:{0}="{1}"' -f $name, (Escape-PSString $value))) +} +$lines | Set-Content -LiteralPath $LocalEnv -Encoding utf8 + +Write-Host "" +Write-Host "Saved local env: .local/feishu-gitlink.env.ps1" +Write-Host "Redacted summary:" +foreach ($name in $vars) { + $value = [Environment]::GetEnvironmentVariable($name) + Write-Host ("{0}: {1}" -f $name, (Redact $value)) +} diff --git a/scripts/feishu-gitlink-smoke.ps1 b/scripts/feishu-gitlink-smoke.ps1 new file mode 100644 index 0000000..80587a1 --- /dev/null +++ b/scripts/feishu-gitlink-smoke.ps1 @@ -0,0 +1,336 @@ +param( + [ValidateSet("preview", "stable", "open-platform", "all")] + [string]$Mode = "preview" +) + +$ErrorActionPreference = "Stop" +$RepoRoot = Split-Path -Parent $PSScriptRoot +$LocalDir = Join-Path $RepoRoot ".local" +$LocalEnv = Join-Path $LocalDir "feishu-gitlink.env.ps1" +$ReportJSON = Join-Path $LocalDir "report.json" +$TerminalLog = Join-Path $RepoRoot "reports/feishu-real-smoke-terminal.log" +$DateStamp = Get-Date -Format "yyyyMMdd" +$SmokeReport = Join-Path $RepoRoot "reports/FEISHU_SMOKE_$DateStamp.md" +$Results = New-Object System.Collections.Generic.List[object] +$Notes = New-Object System.Collections.Generic.List[string] + +function Env { + param([string]$Name) + return [Environment]::GetEnvironmentVariable($Name) +} + +function Has-Env { + param([string[]]$Names) + foreach ($name in $Names) { + if ([string]::IsNullOrWhiteSpace((Env $name))) { return $false } + } + return $true +} + +function Redact-Value { + param([string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return "" } + if ($Value.Length -le 8) { return "***" } + return "$($Value.Substring(0, 4))...$($Value.Substring($Value.Length - 4))" +} + +function Redact-Text { + param([string]$Text) + if ($null -eq $Text) { return "" } + $redacted = $Text + $names = @( + "FEISHU_WEBHOOK_URL", + "FEISHU_WEBHOOK_SECRET", + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_WIKI_URL", + "FEISHU_WIKI_NODE_TOKEN", + "FEISHU_FOLDER_TOKEN", + "FEISHU_BASE_APP_TOKEN", + "FEISHU_REPORT_TABLE_ID", + "FEISHU_ISSUE_TABLE_ID", + "FEISHU_PR_TABLE_ID", + "FEISHU_CONTRIBUTOR_TABLE_ID", + "FEISHU_TASK_TABLE_ID", + "FEISHU_TASK_PROJECT_ID", + "FEISHU_TASK_SECTION_ID", + "GITLINK_TOKEN" + ) + foreach ($name in $names) { + $value = Env $name + if (-not [string]::IsNullOrWhiteSpace($value)) { + $redacted = [regex]::Replace($redacted, [regex]::Escape($value), (Redact-Value $value)) + } + } + $redacted = [regex]::Replace($redacted, 'tenant_access_token"\s*:\s*"[^"]+', 'tenant_access_token":"REDACTED') + return $redacted +} + +function Add-Result { + param( + [string]$Name, + [string]$Status, + [string]$Details + ) + $Results.Add([pscustomobject]@{ + Name = $Name + Status = $Status + Details = (Redact-Text $Details) + }) | Out-Null +} + +function Write-Log { + param([string]$Text) + Add-Content -LiteralPath $TerminalLog -Value (Redact-Text $Text) +} + +function Invoke-Cmd { + param( + [string]$Name, + [string[]]$CommandArgs, + [bool]$Required = $false + ) + $display = $CommandArgs -join " " + Write-Host "RUN: $Name" + Write-Log "" + Write-Log "## $Name" + Write-Log "COMMAND: $display" + $exe = $CommandArgs[0] + $rest = @() + if ($CommandArgs.Count -gt 1) { + $rest = $CommandArgs[1..($CommandArgs.Count - 1)] + } + $output = & $exe @rest 2>&1 | Out-String + $exit = $LASTEXITCODE + Write-Log $output + if ($exit -eq 0) { + Add-Result $Name "pass" "exit=0" + } else { + Add-Result $Name "fail" ("exit={0}; {1}" -f $exit, (($output -split "`r?`n" | Select-Object -First 4) -join " ")) + if ($Required) { + throw "$Name failed with exit $exit" + } + } +} + +function Invoke-ReportGeneration { + $owner = Env "GITLINK_OWNER" + $repo = Env "GITLINK_REPO" + if ([string]::IsNullOrWhiteSpace($owner) -or [string]::IsNullOrWhiteSpace($repo)) { + $owner = "Gitlink" + $repo = "gitlink-cli" + $Notes.Add("GITLINK_OWNER/GITLINK_REPO were missing. Preview smoke used public Gitlink/gitlink-cli as a fallback.") | Out-Null + } + if (-not [string]::IsNullOrWhiteSpace((Env "GITLINK_TEST_PR_IDS"))) { + $Notes.Add("GITLINK_TEST_PR_IDS was provided, but workflow +repo-report currently does not support explicit PR ID filtering. The smoke test used the real repository report instead.") | Out-Null + } + Write-Host "Generating workflow report for $owner/$repo" + Write-Log "" + Write-Log "## Generate workflow report" + $output = & go run . workflow +repo-report --owner $owner --repo $repo --format json 2>&1 | Out-String + $exit = $LASTEXITCODE + if ($exit -ne 0) { + Write-Log $output + Add-Result "workflow +repo-report" "fail" ("exit={0}; {1}" -f $exit, (($output -split "`r?`n" | Select-Object -First 4) -join " ")) + throw "workflow +repo-report failed" + } + $output | Set-Content -LiteralPath $ReportJSON -Encoding utf8 + Write-Log "Report written to .local/report.json" + Add-Result "workflow +repo-report" "pass" "report=.local/report.json; owner=$owner; repo=$repo" +} + +function Add-Skip { + param([string]$Name, [string]$Reason) + Write-Host "SKIP: $Name - $Reason" + Write-Log "" + Write-Log "## $Name" + Write-Log "SKIP: $Reason" + Add-Result $Name "skip" $Reason +} + +function Env-Status { + param([string]$Name) + if ([string]::IsNullOrWhiteSpace((Env $Name))) { return "missing" } + return "present" +} + +function Escape-Table { + param([string]$Value) + if ($null -eq $Value) { return "" } + return ($Value -replace '\|', '\|' -replace "`r?`n", " ") +} + +function Write-SmokeReport { + $branch = (git branch --show-current | Out-String).Trim() + $commit = (git rev-parse HEAD | Out-String).Trim() + $envNames = @( + "FEISHU_WEBHOOK_URL", + "FEISHU_WEBHOOK_SECRET", + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_WIKI_URL", + "FEISHU_WIKI_NODE_TOKEN", + "FEISHU_FOLDER_TOKEN", + "FEISHU_BASE_APP_TOKEN", + "FEISHU_REPORT_TABLE_ID", + "FEISHU_ISSUE_TABLE_ID", + "FEISHU_PR_TABLE_ID", + "FEISHU_CONTRIBUTOR_TABLE_ID", + "FEISHU_TASK_TABLE_ID", + "FEISHU_TASK_PROJECT_ID", + "FEISHU_TASK_SECTION_ID", + "GITLINK_OWNER", + "GITLINK_REPO", + "GITLINK_TEST_PR_IDS", + "GITLINK_TOKEN" + ) + $lines = @( + "# Feishu Smoke Report", + "", + "Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')", + "", + "## Branch", + "", + '```text', + $branch, + '```', + "", + "## Commit", + "", + '```text', + $commit, + '```', + "", + "## Mode", + "", + '```text', + $Mode, + '```', + "", + "## Redacted Environment Presence", + "", + "| Variable | Present? |", + "| --- | --- |" + ) + foreach ($name in $envNames) { + $lines += ('| `{0}` | {1} |' -f $name, (Env-Status $name)) + } + $lines += @( + "", + "## Results", + "", + "| Command | Result | Details |", + "| --- | --- | --- |" + ) + foreach ($result in $Results) { + $lines += "| $(Escape-Table $result.Name) | $(Escape-Table $result.Status) | $(Escape-Table $result.Details) |" + } + $lines += @( + "", + "## Notes", + "" + ) + if ($Notes.Count -eq 0) { + $lines += "- None." + } else { + foreach ($note in $Notes) { + $lines += "- $(Redact-Text $note)" + } + } + $lines += @( + "", + "## Terminal Log", + "", + 'Local redacted terminal log: `reports/feishu-real-smoke-terminal.log`', + "", + "This log file is ignored and should not be committed after real runs.", + "", + "## Screenshot Checklist", + "", + "Run:", + "", + '```powershell', + ".\scripts\feishu-gitlink-screenshot-check.ps1", + '```', + "", + "Do not fabricate screenshots. Capture missing images manually after real Feishu runs." + ) + $lines | Set-Content -LiteralPath $SmokeReport -Encoding utf8 + Write-Host "Smoke report written: $SmokeReport" +} + +Push-Location $RepoRoot +try { + New-Item -ItemType Directory -Force -Path $LocalDir | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $RepoRoot "reports") | Out-Null + if (Test-Path $LocalEnv) { + . $LocalEnv + Write-Host "Loaded local env: .local/feishu-gitlink.env.ps1" + } else { + $Notes.Add("No .local/feishu-gitlink.env.ps1 file found. Preview smoke can run with public fallback data; real sends are skipped.") | Out-Null + } + "# Feishu/GitLink smoke terminal log $(Get-Date -Format o)" | Set-Content -LiteralPath $TerminalLog -Encoding utf8 + + $helpCommands = @( + "+owner-digest", + "+contributor-digest", + "+bitable-sync", + "+task-preview", + "+task-create" + ) + Invoke-Cmd "feishu help" @("go", "run", ".", "feishu", "--help") $true + foreach ($command in $helpCommands) { + Invoke-Cmd "feishu $command help" @("go", "run", ".", "feishu", $command, "--help") $true + } + + Invoke-ReportGeneration + + $runPreview = $Mode -in @("preview", "stable", "open-platform", "all") + $runStable = $Mode -in @("stable", "all") + $runOpenPlatform = $Mode -in @("open-platform", "all") + + if ($runPreview) { + Invoke-Cmd "notify preview" @("go", "run", ".", "feishu", "+notify", "--from-workflow-json", $ReportJSON, "--format", "table") $true + Invoke-Cmd "weekly report preview" @("go", "run", ".", "feishu", "+weekly-report", "--from-workflow-json", $ReportJSON, "--format", "markdown") $true + Invoke-Cmd "owner digest preview" @("go", "run", ".", "feishu", "+owner-digest", "--from-workflow-json", $ReportJSON, "--format", "table") $true + Invoke-Cmd "contributor digest preview" @("go", "run", ".", "feishu", "+contributor-digest", "--from-workflow-json", $ReportJSON, "--format", "table") $true + Invoke-Cmd "bitable records preview" @("go", "run", ".", "feishu", "+bitable-records", "--from-workflow-json", $ReportJSON, "--tables", "reports,issues,prs,contributors,tasks", "--format", "table") $true + Invoke-Cmd "task preview" @("go", "run", ".", "feishu", "+task-preview", "--from-workflow-json", $ReportJSON, "--format", "table") $true + } + + if ($runStable) { + if (Has-Env @("FEISHU_WEBHOOK_URL")) { + Invoke-Cmd "bot-test send" @("go", "run", ".", "feishu", "+bot-test", "--send", "--format", "table") $false + Invoke-Cmd "notify send" @("go", "run", ".", "feishu", "+notify", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false + Invoke-Cmd "weekly report send" @("go", "run", ".", "feishu", "+weekly-report", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false + Invoke-Cmd "owner digest send" @("go", "run", ".", "feishu", "+owner-digest", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false + Invoke-Cmd "contributor digest send" @("go", "run", ".", "feishu", "+contributor-digest", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false + } else { + Add-Skip "stable webhook send" "FEISHU_WEBHOOK_URL missing" + } + } + + if ($runOpenPlatform) { + if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET") -and (Has-Env @("FEISHU_WIKI_URL") -or Has-Env @("FEISHU_WIKI_NODE_TOKEN") -or Has-Env @("FEISHU_FOLDER_TOKEN"))) { + Invoke-Cmd "doc-export send" @("go", "run", ".", "feishu", "+doc-export", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false + } else { + Add-Skip "doc-export send" "missing FEISHU_APP_ID/FEISHU_APP_SECRET or DocX/Wiki target" + } + + if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID")) { + Invoke-Cmd "bitable-sync send" @("go", "run", ".", "feishu", "+bitable-sync", "--from-workflow-json", $ReportJSON, "--tables", "reports,issues,prs,contributors,tasks", "--send", "--format", "table") $false + } else { + Add-Skip "bitable-sync send" "missing app/base/table variables" + } + + if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET")) { + Invoke-Cmd "task-create send" @("go", "run", ".", "feishu", "+task-create", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false + } else { + Add-Skip "task-create send" "missing FEISHU_APP_ID/FEISHU_APP_SECRET" + } + } + + Write-SmokeReport +} finally { + Pop-Location +} From b67bd33fb7bb255e936b156d58623c220aae5b5f Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Fri, 26 Jun 2026 21:09:12 +0800 Subject: [PATCH 10/16] feat(feishu): validate real exports and zh-CN output --- docs/FEISHU_ENVIRONMENT.md | 16 +- docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md | 19 +- docs/FEISHU_OPENAPI_INVENTORY.md | 582 ++++++++++++++++++ docs/PR_VISUAL_GUIDE.md | 10 +- ...EISHU_API_COLLECTION_CHECKLIST_20260626.md | 192 ++++++ reports/FEISHU_PERMISSION_MATRIX.md | 18 +- reports/FEISHU_SMOKE_20260626.md | 218 +++++-- .../FEISHU_USER_COLLECTION_LIST_20260626.md | 175 ++++++ shortcuts/feishu/bitable_sync.go | 26 +- shortcuts/feishu/card.go | 43 +- shortcuts/feishu/digest.go | 141 +++-- shortcuts/feishu/doc_export.go | 20 +- shortcuts/feishu/feishu.go | 10 +- shortcuts/feishu/feishu_test.go | 50 ++ shortcuts/feishu/l10n.go | 181 ++++++ shortcuts/feishu/task.go | 52 +- 16 files changed, 1597 insertions(+), 156 deletions(-) create mode 100644 docs/FEISHU_OPENAPI_INVENTORY.md create mode 100644 reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md create mode 100644 reports/FEISHU_USER_COLLECTION_LIST_20260626.md create mode 100644 shortcuts/feishu/l10n.go diff --git a/docs/FEISHU_ENVIRONMENT.md b/docs/FEISHU_ENVIRONMENT.md index eeff1c5..b04fe31 100644 --- a/docs/FEISHU_ENVIRONMENT.md +++ b/docs/FEISHU_ENVIRONMENT.md @@ -59,6 +59,7 @@ $env:FEISHU_APP_SECRET="REDACTED" | `FEISHU_WIKI_URL` | Existing Wiki page URL | Optional target | `+doc-export` | Can expose workspace/resource ID | Copy from Feishu Wiki | | `FEISHU_WIKI_NODE_TOKEN` | Existing Wiki node token | Optional target | `+doc-export` | Yes | Parsed from Wiki URL or API | | `FEISHU_FOLDER_TOKEN` | Folder token for creating a new DocX | Optional target | `+doc-export` | Yes | Feishu Drive folder URL / Open Platform docs | +| `FEISHU_DOCUMENT_ID` | Existing DocX document ID for append | Optional target | `+doc-export` | Yes | Existing Feishu DocX URL or Open Platform docs | Legacy compatibility: @@ -71,6 +72,15 @@ Example: ```powershell $env:FEISHU_WIKI_URL="https://example.feishu.cn/wiki/REDACTED" $env:FEISHU_FOLDER_TOKEN="REDACTED" +$env:FEISHU_DOCUMENT_ID="REDACTED" +``` + +For localized output, generate the source workflow report and the Feishu output +with the same language flag: + +```powershell +go run . workflow +repo-report --owner "$env:GITLINK_OWNER" --repo "$env:GITLINK_REPO" --lang zh-CN --format json > .local\report.zh-CN.json +go run . feishu +notify --from-workflow-json .local\report.zh-CN.json --lang zh-CN --format table ``` ## Base / Bitable Variables @@ -106,8 +116,10 @@ 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. +Task project and section IDs are currently collected and redacted in output, +but they are not yet mapped into the create-task request body. +Project/section placement should be wired only after the official request fields +and test-enterprise behavior are confirmed. ``` ## GitLink Test Variables diff --git a/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md b/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md index ea9feb9..e4dade1 100644 --- a/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md +++ b/docs/FEISHU_GITLINK_REDESIGN_RESEARCH.md @@ -226,16 +226,22 @@ Observed real Feishu test: custom bot send: passed notify send: passed weekly-report send: passed +owner-digest send: passed +contributor-digest send: passed Bitable schema / records dry-run: passed tenant_access_token: acquired -Wiki node: resolved -DocX write: blocked by Feishu 403 / 1770032 / forBidden +DocX append: passed after the app and target document had permission +Bitable search/create/update: passed after the test table fields were created +Task create: passed at minimal summary/description level +zh-CN Feishu output: passed for cards, digests, DocX blocks, and task candidates ``` Interpretation: ```text -The app credentials and Wiki read path can work, but document writes still require correct DocX / Drive scopes and target document or folder permissions. +The Open Platform path is practically usable in a configured test enterprise, +but it should stay experimental because resource-level scopes, table fields, +document permissions, and task placement are still operator-managed. ``` ### Current Bitable Usage @@ -1077,6 +1083,13 @@ docs/FEISHU_ACTION_GATEWAY_SECURITY.md docs/FEISHU_LARK_CLI_INTEROP.md ``` +Detailed API collection for the implemented branch is maintained in: + +```text +docs/FEISHU_OPENAPI_INVENTORY.md +reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md +``` + Recommended command planning documents: ```text diff --git a/docs/FEISHU_OPENAPI_INVENTORY.md b/docs/FEISHU_OPENAPI_INVENTORY.md new file mode 100644 index 0000000..14a96ab --- /dev/null +++ b/docs/FEISHU_OPENAPI_INVENTORY.md @@ -0,0 +1,582 @@ +# Feishu OpenAPI Inventory for GitLink CLI + +Date: 2026-06-26 + +This document maps the Feishu / Lark Open Platform APIs collected for the +`gitlink-cli feishu` integration to the current command surface and the next +implementation gaps. + +The inventory is intentionally split into three surfaces: + +```text +Layer 1: Stable custom bot export +Layer 2: Experimental Open Platform validation +Layer 3: Future callback-based GitLink action gateway +``` + +No implemented command in this branch performs GitLink write operations. + +## Source Index + +Official Feishu / Lark references used for this inventory: + +```text +Custom bot: +https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot +https://open.feishu.cn/document/feishu-cards/quick-start/send-message-cards-with-custom-bot?lang=zh-CN + +App authentication: +https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN + +IM app bot: +https://open.feishu.cn/document/server-docs/im-v1/message/create?lang=zh-CN + +DocX / Wiki: +https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/create +https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document-block/create?lang=zh-CN +https://open.feishu.cn/document/server-docs/docs/wiki-v2/space/get_node + +Base / Bitable: +https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/search +https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/create?lang=zh-CN +https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/update + +Task: +https://open.feishu.cn/document/task-v2/task/create?lang=zh-CN + +lark-cli: +https://github.com/larksuite/cli +https://open.larksuite.com/document/mcp_open_tools/feishu-cli-let-ai-actually-do-your-work-in-feishu +https://www.feishu.cn/feishu-cli +``` + +## Current API Usage Summary + +| Area | Endpoint / API family | Current command | Status | Write target | Notes | +| --- | --- | --- | --- | --- | --- | +| Custom bot webhook | `POST /open-apis/bot/v2/hook/{token}` | `+bot-test`, `+notify`, `+weekly-report`, `+owner-digest`, `+contributor-digest` | Implemented stable | Feishu chat message | Requires `--send`; preview by default | +| Custom bot signature | timestamp + HMAC-SHA256 signing secret | same as above | Implemented stable | Request signature only | `FEISHU_WEBHOOK_SECRET` optional | +| Tenant token | `POST /auth/v3/tenant_access_token/internal` | `+doc-export`, `+bitable-sync`, `+task-create` | Implemented experimental | Tenant token | No token cache yet | +| Wiki node resolution | `GET /wiki/v2/spaces/get_node?token=...` | `+doc-export` | Implemented experimental | Wiki metadata read | Used to resolve Wiki node to DocX object token | +| DocX create | `POST /docx/v1/documents` | `+doc-export` | Implemented experimental | New DocX document | Requires folder/resource permission | +| DocX append blocks | `POST /docx/v1/documents/{document_id}/blocks/{block_id}/children` | `+doc-export` | Implemented experimental | DocX block tree | Real write can fail on scope or document permission | +| Bitable search | `POST /bitable/v1/apps/{app_token}/tables/{table_id}/records/search` | `+bitable-sync` | Implemented experimental | Existing Base table | Searches by `unique_key` field | +| Bitable create record | `POST /bitable/v1/apps/{app_token}/tables/{table_id}/records` | `+bitable-sync` | Implemented experimental | Existing Base table | No table/field/view creation | +| Bitable update record | `PUT /bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}` | `+bitable-sync` | Implemented experimental | Existing Base table | Never deletes records | +| Task create | `POST /task/v2/tasks` | `+task-create` | Implemented experimental | Feishu task | Project/section placement is not mapped into request body yet | +| IM app bot send | `POST /im/v1/messages?receive_id_type=...` | none | Planned | App-bot message | Needed for direct/group app bot sends beyond custom bot | +| Card callbacks | Interactive card callback / event subscription | none | Future | Callback server | Required before Feishu-triggered GitLink actions | +| User identity | open_id / union_id / user lookup | none | Future | Identity mapping | Required before personalized contributor routing | + +## Layer 1: Stable Custom Bot Export + +### Implemented APIs + +#### Custom Bot Webhook + +Current commands: + +```text ++bot-test ++notify ++weekly-report ++owner-digest ++contributor-digest +``` + +Inputs: + +```text +FEISHU_WEBHOOK_URL +FEISHU_WEBHOOK_SECRET optional +--send required for real delivery +--dry-run conflicts with --send +``` + +Current behavior: + +```text +Builds Feishu interactive card payloads. +Signs webhook requests when a secret is configured. +Prints local previews by default. +Redacts webhook URLs and secrets from normal output. +Only includes navigation buttons. +``` + +Limits: + +```text +No personalized routing. +No app-level chat_id. +No callback execution. +No Feishu resource write. +No GitLink resource write. +``` + +Next hardening: + +```text +Add more card color/stage variants for PR review state. +Add compact owner card and detailed digest variants. +Add screenshot-backed smoke evidence after real webhook env is restored. +``` + +## Layer 2: Experimental Open Platform Validation + +### App Authentication + +Endpoint: + +```text +POST /auth/v3/tenant_access_token/internal +``` + +Current commands: + +```text ++doc-export ++bitable-sync ++task-create +``` + +Inputs: + +```text +FEISHU_APP_ID +FEISHU_APP_SECRET +``` + +Current behavior: + +```text +Fetches tenant_access_token before Open Platform writes. +Does not persist or cache tenant_access_token. +Does not print the raw token. +``` + +Next hardening: + +```text +Add +app-check. +Cache token in memory during one command execution only. +Add scope diagnostics where official scope names are confirmed. +``` + +### DocX / Wiki + +Endpoints: + +```text +GET /wiki/v2/spaces/get_node?token=... +POST /docx/v1/documents +POST /docx/v1/documents/{document_id}/blocks/{parent_block_id}/children +``` + +Current command: + +```text ++doc-export +``` + +Inputs: + +```text +FEISHU_APP_ID +FEISHU_APP_SECRET +FEISHU_WIKI_URL or FEISHU_WIKI_NODE_TOKEN +FEISHU_FOLDER_TOKEN optional +FEISHU_DOCUMENT_ID optional +--send required for real write +``` + +Current behavior: + +```text +Preview renders workflow report content locally. +Wiki URL can be parsed into a node token. +Wiki node can be resolved to a DocX object token. +Existing DocX / Wiki target is appended when allowed. +Folder token can be used to create a new DocX when allowed. +Diagnostics preserve Feishu errors without leaking tokens. +``` + +Known blockers: + +```text +The app must have approved document scopes. +The app must be able to edit the target Wiki / DocX page. +For folder creation, the app must be able to create files in the target folder. +The command does not modify document permissions. +``` + +Local UI observation: + +```text +The Feishu desktop app currently shows a cloud-doc permission request flow. +This supports the current design decision that resource-level document access +must be handled by the owner/admin outside the CLI. +``` + +Next hardening: + +```text +Add +app-check diagnostics for DocX/Wiki scopes. +Add clearer output for target type: wiki node, existing doc, folder creation. +Add optional markdown-only export for manual paste into Feishu Docs. +``` + +### Base / Bitable + +Endpoints: + +```text +POST /bitable/v1/apps/{app_token}/tables/{table_id}/records/search +POST /bitable/v1/apps/{app_token}/tables/{table_id}/records +PUT /bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id} +``` + +Current commands: + +```text ++bitable-schema ++bitable-records ++bitable-sync +``` + +Inputs: + +```text +FEISHU_APP_ID +FEISHU_APP_SECRET +FEISHU_BASE_APP_TOKEN +FEISHU_REPORT_TABLE_ID +FEISHU_ISSUE_TABLE_ID +FEISHU_PR_TABLE_ID +FEISHU_CONTRIBUTOR_TABLE_ID optional +FEISHU_TASK_TABLE_ID optional +--send required for real sync +``` + +Current tables: + +```text +reports +issues +prs +contributors +tasks +``` + +Current behavior: + +```text ++bitable-schema outputs a dry-run schema. ++bitable-records outputs summary-oriented local records. ++bitable-sync previews by default. ++bitable-sync --send searches by unique_key, updates if found, creates if missing. +If search fails, the command falls back to create-only for that record. +Slice values are flattened into newline-separated text before OpenAPI writes. +No records are deleted. +``` + +Local test-enterprise validation on 2026-06-26: + +```text +The provided Base links resolved to one Base and one table with multiple views. +The table initially contained only default fields. +The missing fields were created manually through OpenAPI for validation. ++bitable-sync then successfully created and updated reports, issues, prs, +contributors, and task records in the test table. +``` + +Known blockers: + +```text +The Base app must already exist. +The target tables must already exist. +The target tables must contain a compatible unique_key field. +Field types must be compatible with generated record values. +No Bitable view creation is implemented. +No table/field creation is implemented. +Current records are summary buckets, not full row-level PR/Issue/CI records. +``` + +Next hardening: + +```text +Add table/field validation before writes. +Add row-level records for PRs, Issues, CI runs, milestones, releases, and audits. +Add optional Bitable view planning output for Kanban, Gantt, Calendar, Gallery, Form, and Dashboard. +Keep real view creation as a separate permissioned task. +``` + +### Task + +Endpoint: + +```text +POST /task/v2/tasks +``` + +Current commands: + +```text ++task-preview ++task-create +``` + +Inputs: + +```text +FEISHU_APP_ID +FEISHU_APP_SECRET +FEISHU_TASK_PROJECT_ID optional +FEISHU_TASK_SECTION_ID optional +--send required for real creation +``` + +Current behavior: + +```text ++task-preview generates local task candidates. ++task-create previews by default and creates tasks only with --send. +Task candidates are derived from workflow recommendations, high-risk issues, +missing-info issues, high-risk PRs, and review-focus items. +Local dedupe uses stable unique_key generation. +``` + +Local test-enterprise validation on 2026-06-26: + +```text ++task-preview generated 7 task candidates from the Gitlink/gitlink-cli report. ++task-create --send created 7 Feishu tasks. +The task result table now shows per-task create status and redacted task IDs. +``` + +Known blockers: + +```text +Feishu-side dedupe/search is not implemented. +Task project and section IDs are collected and redacted in output, but the +current OpenAPI request body only sends summary and description. Project/section +placement must be wired only after the official request fields and tenant +behavior are confirmed in the test enterprise. +``` + +Next hardening: + +```text +Confirm official Task project/section placement fields. +Add Feishu-side dedupe or external unique_key linking when a stable API path exists. +Add scope diagnostics through +app-check. +``` + +## i18n Validation + +Current Feishu commands can consume a Chinese workflow report and render +localized Feishu output: + +```text +workflow +repo-report --lang zh-CN +feishu +notify --lang zh-CN +feishu +owner-digest --lang zh-CN +feishu +contributor-digest --lang zh-CN +feishu +doc-export --lang zh-CN +feishu +task-preview --lang zh-CN +feishu +task-create --lang zh-CN +``` + +Validated output surfaces: + +```text +card field labels +owner/contributor digest headings +common workflow recommendations +DocX block headings +task candidate titles and descriptions +table/markdown preview labels +``` + +Repository-wide i18n check still reports that `internal/i18n/locales/en-US.json` +needs formatting. That is outside the Feishu module and was left untouched to +avoid unrelated locale-file churn. + +## Layer 3: Future Callback-Based GitLink Action Gateway + +No callback server or GitLink write action is implemented in this branch. + +Planned Feishu API families: + +```text +Card callback verification +Event subscription / long connection or callback endpoint +IM message update or follow-up message +User identity lookup: open_id / union_id / email +Chat membership or chat metadata where needed +``` + +Planned GitLink command families: + +```text +Read: +workflow +repo-report +issue +list / +view +pr +list / +view / +files / +diff / +reviews +ci +builds / +logs +pipeline +list / +view / +runs / +results + +Low-risk future writes: +issue +comment +pr +comment +pr +review + +High-risk future writes disabled by default: +pr +merge +issue +close +member +add / +remove / +role +webhook +create / +update / +delete +branch or release deletion +``` + +Required gateway controls: + +```text +Verify Feishu callback signature. +Resolve repo binding. +Map Feishu identity to GitLink identity. +Check GitLink permission. +Generate GitLink dry-run preview. +Require explicit confirmation. +Write audit logs. +Disable high-risk actions by default. +Never execute GitLink writes from a custom bot webhook. +``` + +## GitLink Data Source Inventory + +Current Feishu commands primarily consume: + +```text +workflow +repo-report --format json +``` + +Current source properties: + +```text +Read-only. +Works with local JSON fixture or remote GitLink report generation. +Does not require the Feishu module to know a GitLink token. +Provides summary-level issue, PR, contributor, recommendation, and health fields. +``` + +Required future source expansion: + +```text +PR row source: +pr +list +pr +view +pr +files +pr +diff +pr +versions +pr +reviews + +Issue row source: +issue +list +issue +view +issue metadata commands + +CI / pipeline row source: +ci +builds +ci +logs +pipeline +runs +pipeline +results + +Milestone / release source: +milestone and release commands where available + +Audit source: +future action gateway audit log +``` + +Reason: + +```text +Summary buckets are enough for cards and weekly reports. +Kanban, Gantt, Calendar, Gallery, dashboard, and personal task panels require +row-level GitLink records. +``` + +## Manual Setup Required From User + +Stable webhook validation: + +```text +1. Add a custom bot to the target Feishu test group. +2. Copy the webhook URL into FEISHU_WEBHOOK_URL. +3. Copy the signing secret into FEISHU_WEBHOOK_SECRET if signing is enabled. +4. Run +bot-test or +notify with --send. +``` + +DocX / Wiki validation: + +```text +1. Confirm FEISHU_APP_ID and FEISHU_APP_SECRET for the self-built app. +2. Approve required DocX / Drive scopes in Feishu Open Platform. +3. Grant the app edit access to the target Wiki / DocX page, or provide a + folder token where the app can create documents. +4. Run +doc-export first without --send, then with --send. +``` + +Bitable validation: + +```text +1. Create or choose a Base manually. +2. Create reports, issues, prs, contributors, and tasks tables manually. +3. Add a unique_key field to every table. +4. Copy FEISHU_BASE_APP_TOKEN and each table ID into local env. +5. Grant the self-built app Base/Bitable access. +6. Run +bitable-sync without --send first, then with --send. +``` + +For a quick validation, multiple table env vars can point to the same test +table if that table has every required field. For a real project cockpit, prefer +separate tables or a row-level model that supports Kanban, Gantt, Calendar, +Gallery, Form, and Dashboard views without mixing incompatible record groups. + +Task validation: + +```text +1. Confirm Task API scopes for the self-built app. +2. Decide whether tasks should be created as plain tasks first. +3. Do not rely on project/section placement until request fields are verified. +4. Run +task-preview first, then +task-create --send. +``` + +GitLink real data validation: + +```text +1. Set GITLINK_OWNER and GITLINK_REPO. +2. Set GITLINK_TEST_PR_IDS for smoke report reference. +3. Ensure gitlink-cli can generate workflow +repo-report JSON. +4. Do not commit GitLink tokens or account credentials. +``` + +## Acceptance Checklist For API Collection + +```text +[x] Custom bot webhook API identified. +[x] Custom bot signing behavior mapped to current code. +[x] tenant_access_token API identified and implemented. +[x] Wiki node resolution API identified and implemented. +[x] DocX create and block append APIs identified and implemented. +[x] Bitable record search/create/update APIs identified and implemented. +[x] Task create API identified and implemented at minimal summary/description level. +[x] IM app bot send API identified as planned, not implemented. +[x] Card callback/event subscription identified as future, not implemented. +[x] User identity APIs identified as future, not implemented. +[x] GitLink read data source boundary documented. +[x] GitLink write action boundary documented as not implemented. +[x] Required local env variables documented. +[x] Resource-level permission requirements documented. +[x] Remaining user/manual setup steps documented. +``` diff --git a/docs/PR_VISUAL_GUIDE.md b/docs/PR_VISUAL_GUIDE.md index 1a210f2..e522602 100644 --- a/docs/PR_VISUAL_GUIDE.md +++ b/docs/PR_VISUAL_GUIDE.md @@ -3,6 +3,9 @@ Date: 2026-06-26 This file lists the manual screenshots to capture after local and real smoke testing. +The 2026-06-26 smoke run successfully delivered Feishu cards, appended DocX +content, synced Bitable records, and created Feishu tasks in the test +enterprise. Screenshots still need to be captured manually from the UI. 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`. @@ -19,9 +22,9 @@ Use the helper to check current screenshot status: | 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 | +| Bitable Base after sync | `docs/images/feishu-bitable-sync.png` | Real sync succeeded in the test Base; capture the updated table or target view | +| DocX / Wiki report | `docs/images/feishu-docx-wiki.png` | Real DocX append succeeded; capture the appended report blocks | +| Feishu task list | `docs/images/feishu-task-create.png` | Real task creation succeeded; capture the created task list and redact IDs if visible | | 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 | @@ -31,6 +34,7 @@ Suggested capture commands: 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 +gitlink-cli feishu +notify --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table ``` Manual redaction checklist: diff --git a/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md b/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md new file mode 100644 index 0000000..75ceafb --- /dev/null +++ b/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md @@ -0,0 +1,192 @@ +# Feishu API Collection Checklist + +Date: 2026-06-26 + +Branch: + +```text +feat/feishu-export-clean +``` + +Current commit at collection time: + +```text +73da46c143b37cb2b26e9e624b8c39963ad52d77 +``` + +## Current Local State + +```text +Feishu desktop/web state: test account is logged in. +Local env file: .local/feishu-gitlink.env.ps1 is configured and ignored. +Stable previews: available from .local/report.json and .local/report.zh-CN.json. +Real Feishu sends: passed through custom bot webhook. +Real DocX append: passed through self-built app OpenAPI. +Real Bitable sync: passed after target table fields were created. +Real Task create: passed; project/section placement remains unmapped. +GitLink write operations: not implemented and not tested. +``` + +## API Collection Status + +| Item | Status | Evidence | Next action | +| --- | --- | --- | --- | +| Custom bot webhook | Complete and real-tested | `shortcuts/feishu/client.go`, `sign.go`, `card.go` | Capture screenshots | +| Custom bot signing | Complete and real-tested | `SignCustomBotRequest` unit test plus signed bot smoke | Keep secrets redacted | +| tenant_access_token | Complete and real-tested | `OpenAPIClient.TenantAccessToken` | Add future `+app-check` | +| Wiki node resolution | Complete | `OpenAPIClient.GetWikiNode` | Still depends on target Wiki node permission | +| DocX create | Complete | `OpenAPIClient.CreateDocument` | Requires folder permission when creating new docs | +| DocX block append | Complete and real-tested | `OpenAPIClient.CreateBlocks` | App must have target DocX edit permission | +| Bitable search | Complete and real-tested | `SearchBitableRecord` | Requires `unique_key` field | +| Bitable create | Complete and real-tested | `CreateBitableRecord` | Requires existing table and compatible fields | +| Bitable update | Complete and real-tested | `UpdateBitableRecord` | Never deletes records | +| Task create | Complete at minimal level and real-tested | `CreateTask` sends summary and description | Confirm project/section request fields | +| IM app bot message | Planned | Official API collected | Not needed for stable webhook path | +| Card callbacks | Future | Official capability identified | Requires server, signature validation, identity mapping | +| User identity mapping | Future | Required for personalized contributor routing | Not implemented in this branch | +| GitLink write actions | Explicitly out of scope | Capability boundary docs | Do not implement in this branch | + +## Command Checklist + +| Command | Layer | Current status | Real side effect? | Needs user setup? | +| --- | --- | --- | --- | --- | +| `feishu +bot-test` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` | +| `feishu +notify` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` | +| `feishu +weekly-report` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` | +| `feishu +owner-digest` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` | +| `feishu +contributor-digest` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` | +| `feishu +bitable-schema` | Stable dry-run | Implemented | No | No | +| `feishu +bitable-records` | Stable dry-run | Implemented | No | No | +| `feishu +task-preview` | Stable dry-run | Implemented | No | No | +| `feishu +doc-export` | Experimental | Implemented and real-tested | Only with `--send` | App scopes and document/folder permission | +| `feishu +bitable-sync` | Experimental | Implemented and real-tested | Only with `--send` | Base app token, table IDs, fields, scopes | +| `feishu +task-create` | Experimental | Implemented and real-tested at minimal level | Only with `--send` | Task scopes; project/section placement pending | + +## What Is Complete + +```text +1. Feishu API families are mapped to current commands. +2. Current code endpoints are inventoried. +3. Stable custom bot boundary is clear. +4. Experimental Open Platform boundary is clear. +5. GitLink write action boundary is clear. +6. User-required environment variables are documented. +7. Resource-level permission requirements are documented. +8. Task project/section limitation is explicitly called out. +``` + +## What Still Needs User Action + +These remain manual or owner-side tasks and should not be committed to the +repository. + +```text +1. Capture Feishu UI screenshots for the PR visual guide. +2. Decide whether the test Base should use one table with views or separate + reports/issues/prs/contributors/tasks tables. +3. If separate tables are desired, create them and copy each table ID into the + local env file. +4. Decide whether `+bitable-sync` should stay experimental or be narrowed to + dry-run-only for upstream review. +5. Confirm Feishu Task project/section request fields before placing tasks in + a specific project or section. +6. Keep all real app credentials, webhook URLs, table IDs, and tokens in local + env only. +``` + +## Commands To Run After User Setup + +Preview first: + +```powershell +.\scripts\feishu-gitlink-env-check.ps1 -Layer all + +go run . feishu +notify --from-workflow-json .local\report.json --format table +go run . feishu +owner-digest --from-workflow-json .local\report.json --format table +go run . feishu +contributor-digest --from-workflow-json .local\report.json --format table +go run . feishu +bitable-records --from-workflow-json .local\report.json --format json +go run . feishu +bitable-sync --from-workflow-json .local\report.json --format table +go run . feishu +doc-export --from-workflow-json .local\report.json --format table +go run . feishu +task-preview --from-workflow-json .local\report.json --format markdown +``` + +Real sends/writes only after preview is correct: + +```powershell +go run . feishu +notify --from-workflow-json .local\report.json --send --format table +go run . feishu +weekly-report --from-workflow-json .local\report.json --send --format table +go run . feishu +owner-digest --from-workflow-json .local\report.json --send --format table +go run . feishu +contributor-digest --from-workflow-json .local\report.json --send --format table +go run . feishu +doc-export --from-workflow-json .local\report.json --send --format table +go run . feishu +bitable-sync --from-workflow-json .local\report.json --send --format table +go run . feishu +task-create --from-workflow-json .local\report.json --send --format table +``` + +Test suite: + +```powershell +gofmt -w shortcuts\feishu +go test ./shortcuts/feishu +go test ./shortcuts/workflow +go test ./shortcuts +go test ./... +``` + +## Current Blockers + +```text +Task project/section placement needs official request-field confirmation. +Current Base output is summary-oriented and not yet row-level project cockpit data. +Screenshot evidence still needs manual capture. +``` + +## Verification Run + +Executed on 2026-06-26 after the API inventory update: + +| Check | Result | Notes | +| --- | --- | --- | +| Computer-use Feishu desktop read-only check | Pass | Feishu test account visible | +| `go run . feishu --help` | Pass | Expected stable and experimental commands are registered | +| Env check | Pass | Required stable and Open Platform variables present; task project/section optional | +| `+notify` preview | Pass | Local preview mode | +| `+owner-digest` preview | Pass | Repository `Gitlink/gitlink-cli`, risk `high`, score `49` | +| `+contributor-digest` preview | Pass | Role-oriented digest, not personalized routing | +| `+notify --send` | Pass | Custom bot delivered English/default and Chinese cards | +| `+weekly-report --send` | Pass | Custom bot delivered weekly report | +| `+owner-digest --send` | Pass | Custom bot delivered English/default and Chinese owner digest | +| `+contributor-digest --send` | Pass | Custom bot delivered English/default and Chinese contributor digest | +| `+bitable-sync` preview | Pass | 1 report, 5 issue, 2 PR, 1 contributor, 7 task records | +| `+bitable-sync --send` | Pass | Search/create/update real-tested after field creation | +| `+doc-export` preview | Pass | 9 DocX-ready blocks | +| `+doc-export --send` | Pass | Appended English/default and Chinese DocX blocks | +| `+task-preview` preview | Pass | 7 task candidates | +| `+task-create --send` | Pass | 7 tasks created; reruns may duplicate tasks | +| Feishu command i18n | Pass | zh-CN cards, digest, DocX blocks, and task titles previewed/sent | +| `go run ./internal/i18n/cmd/check` | Expected fail | Existing `en-US.json` formatting issue outside Feishu module | +| `go test ./shortcuts/feishu` | Pass | Includes task preview count regression test | +| `go test ./shortcuts/workflow` | Pass | Workflow report source remains valid | +| `go test ./shortcuts` | Pass | Shortcut package regression passed | +| `go test ./...` | Pass | Full repository test suite passed | +| Raw secret scan | Pass | No raw secret values found in tracked/unignored candidate files | +| Screenshot checklist | Expected fail | Real send/write screenshots still need manual capture | + +## Do Not Commit + +```text +FEISHU_WEBHOOK_URL +FEISHU_WEBHOOK_SECRET +FEISHU_APP_ID +FEISHU_APP_SECRET +tenant_access_token +user_access_token +FEISHU_BASE_APP_TOKEN +table IDs +Wiki node token +folder token +chat_id +open_id +union_id +GITLINK_TOKEN +personal account credentials +``` diff --git a/reports/FEISHU_PERMISSION_MATRIX.md b/reports/FEISHU_PERMISSION_MATRIX.md index 2671e4b..c781b18 100644 --- a/reports/FEISHU_PERMISSION_MATRIX.md +++ b/reports/FEISHU_PERMISSION_MATRIX.md @@ -6,15 +6,15 @@ 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 | +| Custom bot test | `feishu +bot-test` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit/mock and real send passed | Custom bot only posts to configured chat | +| Workflow card | `feishu +notify` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | preview and real send passed, including zh-CN | Consumes workflow JSON; no direct Feishu identity routing | +| Weekly report | `feishu +weekly-report` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | preview and real send passed | Card is summary-level | +| Owner digest | `feishu +owner-digest` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit, preview, and real send passed, including zh-CN | Role-oriented, not personalized | +| Contributor digest | `feishu +contributor-digest` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit, preview, and real send passed, including zh-CN | Role-oriented, not open_id routed | | 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 | +| Task preview | `feishu +task-preview` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed, including zh-CN | Local candidates only | +| DocX / Wiki export | `feishu +doc-export` | Experimental Open Platform | No | Yes for `--send` | Yes | No | No | No | No | Yes | mock, preview, and real DocX append passed, including zh-CN | App must have scopes and document/folder permission | +| Bitable sync | `feishu +bitable-sync` | Experimental Open Platform | No | Yes for `--send` | No | Yes | No | No | No | Yes | mock, preview, and real search/create/update passed | Requires existing tables and compatible fields; one-table test used multiple record groups | +| Task create | `feishu +task-create` | Experimental Open Platform | No | Yes for `--send` | No | No | Yes | No | No | Yes | mock, preview, and real create passed | Dedupe is local unique_key only; project/section IDs are collected but not mapped into the request body yet | | 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 index 2acba26..f396e9d 100644 --- a/reports/FEISHU_SMOKE_20260626.md +++ b/reports/FEISHU_SMOKE_20260626.md @@ -1,6 +1,6 @@ -# Feishu Smoke Report +# Feishu Smoke Report -Date: 2026-06-26 16:29:18 +08:00 +Date: 2026-06-26 20:58:49 +08:00 ## Branch @@ -11,67 +11,178 @@ feat/feishu-export-clean ## Commit ```text -9255518304e1a6b0fba9f9e5eee9bdf4f62d8e04 +73da46c143b37cb2b26e9e624b8c39963ad52d77 ``` +The worktree was dirty during this smoke run because the Feishu implementation +and documentation were still being updated. + ## Mode ```text -preview +real Feishu test enterprise plus local previews ``` +## Test Environment + +```text +Feishu test enterprise: used +Custom bot in test group: used +Self-built app with broad test permissions: used +Feishu DocX target: used +Feishu Base target: used +Feishu Task API: used +GitLink real repository data: Gitlink/gitlink-cli +Reference PR IDs for smoke notes: 95, 29, 75 +GitLink write operations: not used +``` + +All Feishu resource IDs, tokens, webhook URLs, app credentials, table IDs, and +document IDs were kept in `.local/feishu-gitlink.env.ps1` and are not committed. + ## Redacted Environment Presence -| Variable | Present? | -| --- | --- | -| `FEISHU_WEBHOOK_URL` | missing | -| `FEISHU_WEBHOOK_SECRET` | missing | -| `FEISHU_APP_ID` | missing | -| `FEISHU_APP_SECRET` | missing | -| `FEISHU_WIKI_URL` | missing | -| `FEISHU_WIKI_NODE_TOKEN` | missing | -| `FEISHU_FOLDER_TOKEN` | missing | -| `FEISHU_BASE_APP_TOKEN` | missing | -| `FEISHU_REPORT_TABLE_ID` | missing | -| `FEISHU_ISSUE_TABLE_ID` | missing | -| `FEISHU_PR_TABLE_ID` | missing | -| `FEISHU_CONTRIBUTOR_TABLE_ID` | missing | -| `FEISHU_TASK_TABLE_ID` | missing | -| `FEISHU_TASK_PROJECT_ID` | missing | -| `FEISHU_TASK_SECTION_ID` | missing | -| `GITLINK_OWNER` | missing | -| `GITLINK_REPO` | missing | -| `GITLINK_TEST_PR_IDS` | missing | -| `GITLINK_TOKEN` | missing | +| Variable | Present? | Notes | +| --- | --- | --- | +| `FEISHU_WEBHOOK_URL` | present | redacted in CLI output | +| `FEISHU_WEBHOOK_SECRET` | present | redacted in CLI output | +| `FEISHU_APP_ID` | present | redacted where printed | +| `FEISHU_APP_SECRET` | present | never printed | +| `FEISHU_FOLDER_TOKEN` | present | redacted | +| `FEISHU_DOCUMENT_ID` | present | redacted | +| `FEISHU_BASE_APP_TOKEN` | present | redacted | +| `FEISHU_REPORT_TABLE_ID` | present | same test table as other table envs | +| `FEISHU_ISSUE_TABLE_ID` | present | same test table as other table envs | +| `FEISHU_PR_TABLE_ID` | present | same test table as other table envs | +| `FEISHU_CONTRIBUTOR_TABLE_ID` | present | same test table as other table envs | +| `FEISHU_TASK_TABLE_ID` | present | same test table as other table envs | +| `FEISHU_TASK_PROJECT_ID` | missing | optional; current request body does not place tasks into project/section | +| `FEISHU_TASK_SECTION_ID` | missing | optional; current request body does not place tasks into project/section | +| `GITLINK_OWNER` | present | `Gitlink` | +| `GITLINK_REPO` | present | `gitlink-cli` | +| `GITLINK_TEST_PR_IDS` | present | `95,29,75` | +| `GITLINK_TOKEN` | missing | not required for the read-only workflow report in this run | -## Results +## GitLink Report Source + +Command: + +```powershell +go run . workflow +repo-report --owner $env:GITLINK_OWNER --repo $env:GITLINK_REPO --format json > .local\report.json +go run . workflow +repo-report --owner $env:GITLINK_OWNER --repo $env:GITLINK_REPO --lang zh-CN --format json > .local\report.zh-CN.json +``` + +Result: + +| Item | Value | +| --- | --- | +| Repository | `Gitlink/gitlink-cli` | +| Report score | `49` | +| Risk level | `high` | +| Health score | `58` | +| Issues | `19` | +| Pull requests | `10` | +| Source | `remote-read-only-fetch` | + +The workflow command does not currently filter the report by explicit PR IDs, so +`GITLINK_TEST_PR_IDS` is recorded as smoke context rather than a hard filter. + +## Real Feishu Results | Command | Result | Details | | --- | --- | --- | -| feishu help | pass | exit=0 | -| feishu +owner-digest help | pass | exit=0 | -| feishu +contributor-digest help | pass | exit=0 | -| feishu +bitable-sync help | pass | exit=0 | -| feishu +task-preview help | pass | exit=0 | -| feishu +task-create help | pass | exit=0 | -| workflow +repo-report | pass | report=.local/report.json; owner=Gitlink; repo=gitlink-cli | -| notify preview | pass | exit=0 | -| weekly report preview | pass | exit=0 | -| owner digest preview | pass | exit=0 | -| contributor digest preview | pass | exit=0 | -| bitable records preview | pass | exit=0 | -| task preview | pass | exit=0 | +| `feishu +bot-test --send` | pass | custom bot returned Feishu code `0` | +| `feishu +notify --send` | pass | English/default workflow card delivered | +| `feishu +weekly-report --send` | pass | weekly report card delivered | +| `feishu +owner-digest --send` | pass | owner digest card delivered | +| `feishu +contributor-digest --send` | pass | contributor digest card delivered | +| `feishu +notify --lang zh-CN --send` | pass | Chinese workflow card delivered | +| `feishu +owner-digest --lang zh-CN --send` | pass | Chinese owner digest delivered | +| `feishu +contributor-digest --lang zh-CN --send` | pass | Chinese contributor digest delivered | +| `feishu +doc-export --send` | pass | appended 9 DocX blocks to the configured document | +| `feishu +doc-export --lang zh-CN --send` | pass | appended 9 localized DocX blocks | +| `feishu +bitable-sync --tables reports --send` | pass after table fields were added | created the report record | +| `feishu +bitable-sync --tables reports,issues,prs,contributors,tasks --send` | pass | updated 1 report, created 5 issue buckets, 2 PR buckets, 1 contributor bucket, 7 task buckets | +| `feishu +bitable-sync --lang zh-CN --send` | pass | updated existing records from the Chinese workflow JSON | +| `feishu +task-preview --lang zh-CN` | pass | generated 7 Chinese task candidates | +| `feishu +task-create --lang zh-CN --send` | pass | created 7 Feishu tasks | -## Notes +## Bitable Setup Observation -- No .local/feishu-gitlink.env.ps1 file found. Preview smoke can run with public fallback data; real sends are skipped. -- GITLINK_OWNER/GITLINK_REPO were missing. Preview smoke used public Gitlink/gitlink-cli as a fallback. +The provided Feishu Base URLs pointed to one Base and one table with multiple +views. The test enterprise initially had only the default fields. A direct +OpenAPI inspection found one table and the default fields only, so the test +table was expanded with the fields expected by the CLI records: -## Terminal Log +```text +unique_key, repository, health_score, risk_level, report_score, +issue_total, issue_high_risk, issue_missing_info, pr_total, pr_high_risk, +review_focus_count, generated_at, source, doc_url, issue_group, priority, +count, risk_reason, recommended_action, gitlink_url, pr_group, review_focus, +contributor, role, open_items, risk_items, task_title, task_type, source_type, +source_key, recommended_owner, status, due_hint +``` -Local redacted terminal log: `reports/feishu-real-smoke-terminal.log` +This confirms that `+bitable-sync` can search, create, and update records when +the target table already has compatible fields. It does not yet create Base +tables or views itself. -This log file is ignored and should not be committed after real runs. +## i18n Result + +Feishu command-level Chinese output is usable: + +```text +workflow +repo-report --lang zh-CN +feishu +notify --lang zh-CN +feishu +owner-digest --lang zh-CN +feishu +contributor-digest --lang zh-CN +feishu +doc-export --lang zh-CN +feishu +task-preview --lang zh-CN +feishu +task-create --lang zh-CN +``` + +The Feishu module localizes stable card labels, digest headings, common +recommendations, DocX block headings, and task candidate titles. For best +results, generate the source workflow report with `--lang zh-CN` and pass +`--lang zh-CN` again to the Feishu command. + +Repository-wide i18n formatting check: + +```text +go run ./internal/i18n/cmd/check +``` + +Result: + +```text +fail: internal/i18n/locales/en-US.json is not formatted +``` + +That appears to be an existing locale formatting issue outside the Feishu +module. It was not fixed in this smoke run to avoid unrelated locale churn. + +## Tests + +| Check | Result | +| --- | --- | +| `go test ./shortcuts/feishu` | pass | +| `go test ./shortcuts/workflow` | pass | +| `go test ./shortcuts` | pass | +| `go test ./...` | pass | +| Raw secret scan over tracked/unignored candidate files | pass | + +## Known Limitations + +```text +1. Bitable sync requires existing Base/table/fields; CLI does not create tables or views. +2. The current smoke used one test table for all record groups because the provided links were one table with multiple views. +3. Current Bitable records are summary buckets, not row-level PR/Issue/CI records. +4. Feishu task creation does not yet map project/section placement into the request body. +5. Feishu-side task dedupe/search is not implemented; avoid repeated real task-create runs unless duplicates are acceptable. +6. No Feishu callback server is implemented. +7. No GitLink write operation is implemented. +8. Screenshots still need to be captured manually from the Feishu UI. +``` ## Screenshot Checklist @@ -81,4 +192,19 @@ Run: .\scripts\feishu-gitlink-screenshot-check.ps1 ``` -Do not fabricate screenshots. Capture missing images manually after real Feishu runs. +Manual captures still needed: + +```text +docs/images/feishu-bot-card.png +docs/images/feishu-weekly-report.png +docs/images/feishu-owner-digest.png +docs/images/feishu-contributor-digest.png +docs/images/feishu-bitable-preview.png +docs/images/feishu-bitable-sync.png +docs/images/feishu-docx-wiki.png +docs/images/feishu-task-create.png +docs/images/feishu-smoke-terminal.png +docs/images/feishu-env-redacted.png +``` + +Do not fabricate screenshots. Redact IDs and tokens before committing any image. diff --git a/reports/FEISHU_USER_COLLECTION_LIST_20260626.md b/reports/FEISHU_USER_COLLECTION_LIST_20260626.md new file mode 100644 index 0000000..0227b90 --- /dev/null +++ b/reports/FEISHU_USER_COLLECTION_LIST_20260626.md @@ -0,0 +1,175 @@ +# 飞书 / GitLink 本地验证信息收集清单 + +Date: 2026-06-26 + +用途:这份清单只说明需要从飞书和 GitLink 页面收集哪些值。真实值不要写进本文件,也不要提交到仓库。真实值只放到本地忽略文件: + +```text +.local/feishu-gitlink.env.ps1 +``` + +## 当前状态 + +```text +自定义机器人 webhook:已配置并真实发送通过。 +自建应用 app_id/app_secret:已配置并获取 tenant_access_token 通过。 +DocX 目标:已配置并真实追加报告通过。 +多维表格 Base:已配置;当前测试链接是同一个 Base 的同一张表的多个视图。 +多维表格字段:已通过 OpenAPI 为测试表补齐。 +Bitable search/create/update:已真实通过。 +飞书任务创建:已真实通过;项目/分组归属尚未接入请求体。 +GitLink 仓库:已使用 Gitlink/gitlink-cli 生成真实 workflow report。 +i18n:feishu 命令 zh-CN 输出可用;仓库全局 i18n check 仍有既有 en-US.json 格式化问题。 +截图:仍需从飞书 UI 手工截取。 +``` + +## 1. 稳定层:飞书自定义机器人 + +这些值用于真实发送飞书群卡片。 + +| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | +| --- | --- | --- | --- | --- | +| 自定义机器人 Webhook URL | `FEISHU_WEBHOOK_URL` | 是 | 飞书群聊 -> 群设置 -> 机器人 -> 自定义机器人 | `+bot-test`, `+notify`, `+weekly-report`, `+owner-digest`, `+contributor-digest --send` | +| 自定义机器人签名密钥 | `FEISHU_WEBHOOK_SECRET` | 是 | 自定义机器人安全设置,若开启签名 | 同上 | + +最小可验证: + +```text +只要有 FEISHU_WEBHOOK_URL,就可以先测试稳定消息卡片。 +如果机器人开启了签名,还必须填 FEISHU_WEBHOOK_SECRET。 +``` + +## 2. 飞书开放平台自建应用 + +这些值用于 DocX、Wiki、多维表格、任务等实验性 OpenAPI 写入。 + +| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | +| --- | --- | --- | --- | --- | +| App ID | `FEISHU_APP_ID` | 是 | 飞书开放平台 -> 自建应用 -> 凭证与基础信息 | `+doc-export`, `+bitable-sync`, `+task-create --send` | +| App Secret | `FEISHU_APP_SECRET` | 是 | 同上 | 获取 `tenant_access_token` | + +需要确认: + +```text +1. 应用已经创建。 +2. 应用在测试企业内可用。 +3. 需要的 API 权限已经申请或开通。 +4. 目标文档、知识库、多维表格或任务空间已经给应用必要权限。 +``` + +## 3. DocX / Wiki 验证目标 + +这些值用于把 GitLink workflow report 写入飞书云文档或知识库。 + +| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | +| --- | --- | --- | --- | --- | +| Wiki 页面 URL | `FEISHU_WIKI_URL` | 可能敏感 | 目标飞书知识库页面地址栏 | `+doc-export --wiki-url ... --send` | +| Wiki node token | `FEISHU_WIKI_NODE_TOKEN` | 是 | 可从 Wiki URL 解析,或 OpenAPI 返回 | `+doc-export` | +| 文件夹 token | `FEISHU_FOLDER_TOKEN` | 是 | 飞书云空间文件夹 URL | 创建新 DocX | +| 已有 DocX document ID | `FEISHU_DOCUMENT_ID` | 是 | DocX URL 或 OpenAPI 返回 | 追加已有 DocX | + +三选一即可开始: + +```text +方案 A:提供 FEISHU_WIKI_URL,让命令解析 Wiki node。 +方案 B:提供 FEISHU_FOLDER_TOKEN,让命令新建 DocX。 +方案 C:提供 FEISHU_DOCUMENT_ID,追加已有 DocX。 +``` + +必须人工处理: + +```text +gitlink-cli 不会替你修改飞书文档权限。 +你需要在飞书里给自建应用目标文档、知识库或文件夹的编辑权限。 +``` + +## 4. 多维表格 Base / Bitable + +这些值用于实验性真实同步记录。 + +| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | +| --- | --- | --- | --- | --- | +| Base app token | `FEISHU_BASE_APP_TOKEN` | 是 | 多维表格 URL 或开发者工具 API | `+bitable-sync --send` | +| reports 表 ID | `FEISHU_REPORT_TABLE_ID` | 是 | 多维表格表设置/API | 报告汇总行 | +| issues 表 ID | `FEISHU_ISSUE_TABLE_ID` | 是 | 同上 | Issue 汇总行 | +| prs 表 ID | `FEISHU_PR_TABLE_ID` | 是 | 同上 | PR 汇总行 | +| contributors 表 ID | `FEISHU_CONTRIBUTOR_TABLE_ID` | 是 | 同上 | 贡献者汇总行,可选 | +| tasks 表 ID | `FEISHU_TASK_TABLE_ID` | 是 | 同上 | 任务候选行,可选 | + +当前测试说明: + +```text +你提供的多维表格链接当前是同一个 Base 的同一张表,只是不同视图。 +为了验证 OpenAPI 写入,我把 reports/issues/prs/contributors/tasks 都指向了同一张测试表,并补齐了需要字段。 +这适合验证 search/create/update,但不是最终项目驾驶舱模型。 +``` + +正式模型建议: + +```text +1. 要么拆成 reports / issues / prs / contributors / tasks 多张表。 +2. 要么改成更强的行级统一模型,支持看板、甘特图、日历、画册、表单和仪表盘。 +3. 当前 CLI 不自动创建 Base、表、字段或视图。 +4. Kanban / Gantt / Calendar / Gallery / Dashboard 视图先建议人工配置。 +``` + +## 5. 飞书任务 + +这些值用于实验性创建飞书任务。 + +| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | +| --- | --- | --- | --- | --- | +| 任务项目 ID | `FEISHU_TASK_PROJECT_ID` | 是 | 飞书任务项目设置/API | 当前仅收集和脱敏输出 | +| 任务分组/section ID | `FEISHU_TASK_SECTION_ID` | 是 | 飞书任务项目设置/API | 当前仅收集和脱敏输出 | + +当前限制: + +```text ++task-create 真实请求目前只发送任务 summary 和 description。 +project / section 设置字段还没有接入请求体。 +已验证普通任务创建;后续再确认项目/分组字段。 +``` + +## 6. GitLink 真实仓库数据 + +这些值用于生成真实 workflow report。 + +| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | +| --- | --- | --- | --- | --- | +| 仓库 owner | `GITLINK_OWNER` | 否 | GitLink 仓库 URL | `workflow +repo-report` | +| 仓库名 | `GITLINK_REPO` | 否 | GitLink 仓库 URL | `workflow +repo-report` | +| 测试 PR IDs | `GITLINK_TEST_PR_IDS` | 否 | 之前 3 个 PR URL/编号 | 烟测报告记录 | +| GitLink Token | `GITLINK_TOKEN` | 是 | GitLink 账号设置/API token | 若本地未登录且需要远程读取 | + +示例,不要照抄: + +```powershell +$env:GITLINK_OWNER="OWNER" +$env:GITLINK_REPO="REPO" +$env:GITLINK_TEST_PR_IDS="1,2,3" +$env:GITLINK_TOKEN="REDACTED" +``` + +## 7. 仍需人工完成 + +```text +1. 从飞书群里截取 bot card、weekly report、owner digest、contributor digest。 +2. 从飞书多维表格里截取同步后的记录或视图。 +3. 从飞书 DocX 里截取追加后的报告内容。 +4. 从飞书任务里截取创建后的任务列表。 +5. 截图前确认没有暴露 app secret、webhook、token、table id、open_id 或 union_id。 +``` + +截图目标路径见: + +```text +docs/PR_VISUAL_GUIDE.md +``` + +## 8. 安全提醒 + +```text +不要把 app secret、webhook、token、table id、wiki token、folder token 发到公开聊天或提交到仓库。 +真实值只放在 .local/feishu-gitlink.env.ps1。 +如果需要继续真实验证,优先复用本地 env 文件,不要把值写进 docs、reports、README。 +``` diff --git a/shortcuts/feishu/bitable_sync.go b/shortcuts/feishu/bitable_sync.go index 19bf2f5..cb6cbec 100644 --- a/shortcuts/feishu/bitable_sync.go +++ b/shortcuts/feishu/bitable_sync.go @@ -119,10 +119,11 @@ func syncBitableOrPreview(ctx *common.RuntimeContext, opts BitableSyncOptions, r } for _, record := range records.Tables[tableResult.Table] { result := BitableSyncRecordResult{UniqueKey: record.UniqueKey} + fields := normalizeBitableWriteFields(record.Fields) search, err := client.SearchBitableRecord(context.Background(), token.Value, opts.BaseAppToken, tableID, record.UniqueKey) if err != nil { output.Warnings = append(output.Warnings, diagnoseOpenAPIError(err, "bitable", tableResult.Table)+"; falling back to create-only for this record") - created, createErr := client.CreateBitableRecord(context.Background(), token.Value, opts.BaseAppToken, tableID, record.Fields) + created, createErr := client.CreateBitableRecord(context.Background(), token.Value, opts.BaseAppToken, tableID, fields) if createErr != nil { result.Action = "create" result.Error = diagnoseOpenAPIError(createErr, "bitable", tableResult.Table) @@ -137,7 +138,7 @@ func syncBitableOrPreview(ctx *common.RuntimeContext, opts BitableSyncOptions, r continue } if search.Found { - updated, err := client.UpdateBitableRecord(context.Background(), token.Value, opts.BaseAppToken, tableID, search.RecordID, record.Fields) + updated, err := client.UpdateBitableRecord(context.Background(), token.Value, opts.BaseAppToken, tableID, search.RecordID, fields) if err != nil { result.Action = "update" result.RecordID = redactToken(search.RecordID) @@ -150,7 +151,7 @@ func syncBitableOrPreview(ctx *common.RuntimeContext, opts BitableSyncOptions, r result.RecordID = redactToken(updated.RecordID) output.Tables[i].Updated++ } else { - created, err := client.CreateBitableRecord(context.Background(), token.Value, opts.BaseAppToken, tableID, record.Fields) + created, err := client.CreateBitableRecord(context.Background(), token.Value, opts.BaseAppToken, tableID, fields) if err != nil { result.Action = "create" result.Error = diagnoseOpenAPIError(err, "bitable", tableResult.Table) @@ -168,6 +169,25 @@ func syncBitableOrPreview(ctx *common.RuntimeContext, opts BitableSyncOptions, r return renderBitableSyncOutput(os.Stdout, output, formatOrDefault(ctx, "json")) } +func normalizeBitableWriteFields(fields map[string]interface{}) map[string]interface{} { + normalized := make(map[string]interface{}, len(fields)) + for key, value := range fields { + switch typed := value.(type) { + case []string: + normalized[key] = strings.Join(typed, "\n") + case []interface{}: + parts := make([]string, 0, len(typed)) + for _, item := range typed { + parts = append(parts, fmt.Sprint(item)) + } + normalized[key] = strings.Join(parts, "\n") + default: + normalized[key] = value + } + } + return normalized +} + func renderBitableSyncOutput(w io.Writer, output BitableSyncOutput, format string) error { switch normalizeFormat(format) { case "markdown": diff --git a/shortcuts/feishu/card.go b/shortcuts/feishu/card.go index 7ca41f6..f3ecb4a 100644 --- a/shortcuts/feishu/card.go +++ b/shortcuts/feishu/card.go @@ -25,23 +25,23 @@ func NewInteractivePayload(card Card) WebhookPayload { } func BuildBotTestCard(title, message, lang string) Card { - title = firstNonEmpty(title, "GitLink Feishu integration test") - message = firstNonEmpty(message, "gitlink-cli can build and send Feishu custom bot cards.") + title = firstNonEmpty(title, feishuLabel(lang, "bot_title")) + message = firstNonEmpty(message, feishuLabel(lang, "bot_message")) return baseCard(title, "blue", []interface{}{ - div("**Status**\nReady"), + div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "bot_status"), feishuLabel(lang, "ready"))), div(message), - note("Generated by gitlink-cli feishu +bot-test."), + note(feishuLabel(lang, "bot_generated")), }) } func BuildWorkflowCard(report workflow.RepoReportResult, include []string, title string, lang string, docURL string) Card { title = firstNonEmpty(title, reportTitle(report, lang)) elements := []interface{}{ - div(fmt.Sprintf("**Repository**\n%s", escapeMD(report.Repository))), + div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "repository"), escapeMD(report.Repository))), fields([]fieldValue{ - {Label: "Report score", Value: fmt.Sprintf("%d", report.ReportScore)}, - {Label: "Risk level", Value: report.RiskLevel}, - {Label: "Source", Value: report.Source}, + {Label: feishuLabel(lang, "report_score"), Value: fmt.Sprintf("%d", report.ReportScore)}, + {Label: feishuLabel(lang, "risk_level"), Value: report.RiskLevel}, + {Label: feishuLabel(lang, "source"), Value: report.Source}, }), } if hasItem(include, "health") { @@ -52,41 +52,38 @@ func BuildWorkflowCard(report workflow.RepoReportResult, include []string, title healthRisk = report.Health.RiskLevel } elements = append(elements, fields([]fieldValue{ - {Label: "Health score", Value: healthScore}, - {Label: "Health risk", Value: healthRisk}, + {Label: feishuLabel(lang, "health_score"), Value: healthScore}, + {Label: feishuLabel(lang, "health_risk"), Value: healthRisk}, })) } if hasItem(include, "issues") { elements = append(elements, fields([]fieldValue{ - {Label: "Issues", Value: fmt.Sprintf("%d", report.IssueSummary.Total)}, - {Label: "High risk issues", Value: fmt.Sprintf("%d", report.IssueSummary.HighRisk)}, - {Label: "Missing info", Value: fmt.Sprintf("%d", report.IssueSummary.MissingInfo)}, + {Label: feishuLabel(lang, "issues"), Value: fmt.Sprintf("%d", report.IssueSummary.Total)}, + {Label: feishuLabel(lang, "high_risk_issues"), Value: fmt.Sprintf("%d", report.IssueSummary.HighRisk)}, + {Label: feishuLabel(lang, "missing_info"), Value: fmt.Sprintf("%d", report.IssueSummary.MissingInfo)}, })) } if hasItem(include, "prs") { elements = append(elements, fields([]fieldValue{ - {Label: "Pull requests", Value: fmt.Sprintf("%d", report.PRSummary.Total)}, - {Label: "High risk PRs", Value: fmt.Sprintf("%d", report.PRSummary.HighRisk)}, + {Label: feishuLabel(lang, "pull_requests"), Value: fmt.Sprintf("%d", report.PRSummary.Total)}, + {Label: feishuLabel(lang, "high_risk_prs"), Value: fmt.Sprintf("%d", report.PRSummary.HighRisk)}, })) if len(report.PRSummary.ReviewFocus) > 0 { - elements = append(elements, div("**Review focus**\n"+bulletList(report.PRSummary.ReviewFocus, 4))) + elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "review_focus"), bulletList(localizeFeishuLines(report.PRSummary.ReviewFocus, lang), 4)))) } } if len(report.Recommendations) > 0 { - elements = append(elements, div("**Recommendations**\n"+bulletList(report.Recommendations, 5))) + elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "recommendations"), bulletList(localizeFeishuLines(report.Recommendations, lang), 5)))) } if strings.TrimSpace(docURL) != "" { - elements = append(elements, actionButton("Open Feishu report", docURL)) + elements = append(elements, actionButton(feishuLabel(lang, "open_feishu_report"), docURL)) } - elements = append(elements, note("Preview is read-only. Bitable records are generated locally by +bitable-records.")) + elements = append(elements, note(feishuLabel(lang, "preview_note"))) return baseCard(title, templateForRisk(report.RiskLevel), elements) } func reportTitle(report workflow.RepoReportResult, lang string) string { - if lang == "zh-CN" { - return "GitLink workflow report: " + report.Repository - } - return "GitLink workflow report: " + report.Repository + return fmt.Sprintf(feishuLabel(lang, "workflow_report_title"), report.Repository) } func baseCard(title string, template string, elements []interface{}) Card { diff --git a/shortcuts/feishu/digest.go b/shortcuts/feishu/digest.go index a937bc2..967f990 100644 --- a/shortcuts/feishu/digest.go +++ b/shortcuts/feishu/digest.go @@ -126,49 +126,49 @@ func BuildContributorDigest(report workflow.RepoReportResult, docURL string) Rol } } -func BuildOwnerDigestCard(digest RoleDigest, title string, _ string) Card { - return buildDigestCard(digest, firstNonEmpty(title, "GitLink owner digest: "+digest.Repository), "owner") +func BuildOwnerDigestCard(digest RoleDigest, title string, lang string) Card { + return buildDigestCard(digest, firstNonEmpty(title, fmt.Sprintf(feishuLabel(lang, "owner_digest_title"), digest.Repository)), "owner", lang) } -func BuildContributorDigestCard(digest RoleDigest, title string, _ string) Card { - return buildDigestCard(digest, firstNonEmpty(title, "GitLink contributor digest: "+digest.Repository), "contributor") +func BuildContributorDigestCard(digest RoleDigest, title string, lang string) Card { + return buildDigestCard(digest, firstNonEmpty(title, fmt.Sprintf(feishuLabel(lang, "contributor_digest_title"), digest.Repository)), "contributor", lang) } -func buildDigestCard(digest RoleDigest, title string, role string) Card { +func buildDigestCard(digest RoleDigest, title string, role string, lang string) Card { elements := []interface{}{ - div(fmt.Sprintf("**Repository**\n%s", escapeMD(digest.Repository))), + div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "repository"), 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)}, + {Label: feishuLabel(lang, "report_score"), Value: fmt.Sprintf("%d", digest.ReportScore)}, + {Label: feishuLabel(lang, "risk_level"), Value: digest.RiskLevel}, + {Label: feishuLabel(lang, "issues"), Value: fmt.Sprintf("%d", digest.IssueTotal)}, + {Label: feishuLabel(lang, "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))}, + {Label: feishuLabel(lang, "high_risk_issues"), Value: fmt.Sprintf("%d", digest.IssueHighRisk)}, + {Label: feishuLabel(lang, "missing_info_issues"), Value: fmt.Sprintf("%d", digest.IssueMissingInfo)}, + {Label: feishuLabel(lang, "high_risk_prs"), Value: fmt.Sprintf("%d", digest.PRHighRisk)}, + {Label: feishuLabel(lang, "review_focus"), Value: fmt.Sprintf("%d", len(digest.ReviewFocus))}, }), } if digest.HealthScore != nil { elements = append(elements, fields([]fieldValue{ - {Label: "Health score", Value: fmt.Sprintf("%d", *digest.HealthScore)}, - {Label: "Health risk", Value: digest.HealthRisk}, + {Label: feishuLabel(lang, "health_score"), Value: fmt.Sprintf("%d", *digest.HealthScore)}, + {Label: feishuLabel(lang, "health_risk"), Value: digest.HealthRisk}, })) } if len(digest.AttentionItems) > 0 { - elements = append(elements, div("**Attention**\n"+bulletList(digest.AttentionItems, 5))) + elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "attention"), bulletList(localizeFeishuLines(digest.AttentionItems, lang), 5)))) } if len(digest.NextSteps) > 0 { - elements = append(elements, div("**Suggested next steps**\n"+bulletList(digest.NextSteps, 5))) + elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "suggested_next_steps"), bulletList(localizeFeishuLines(digest.NextSteps, lang), 5)))) } if digest.RepositoryURL != "" { - elements = append(elements, actionButton("Open GitLink repository", digest.RepositoryURL)) + elements = append(elements, actionButton(feishuLabel(lang, "open_gitlink_repository"), digest.RepositoryURL)) } if digest.DocURL != "" { - elements = append(elements, actionButton("Open Feishu report", digest.DocURL)) + elements = append(elements, actionButton(feishuLabel(lang, "open_feishu_report"), digest.DocURL)) } - elements = append(elements, note(digest.BoundaryDescription)) + elements = append(elements, note(localizedBoundary(digest, lang))) template := templateForRisk(digest.RiskLevel) if role == "contributor" && digest.PRSummaryNeedsAttention() { template = "yellow" @@ -176,60 +176,121 @@ func buildDigestCard(digest RoleDigest, title string, role string) Card { return baseCard(title, template, elements) } +func localizedBoundary(digest RoleDigest, lang string) string { + if !isChineseLang(lang) { + return digest.BoundaryDescription + } + switch digest.Role { + case "owner": + return feishuLabel(lang, "boundary_owner") + case "contributor": + return feishuLabel(lang, "boundary_contributor") + default: + return localizeFeishuText(digest.BoundaryDescription, lang) + } +} + func (d RoleDigest) PRSummaryNeedsAttention() bool { return d.PRHighRisk > 0 || len(d.ReviewFocus) > 0 } -func renderDigest(w io.Writer, digest RoleDigest, format string) error { +func renderDigest(w io.Writer, digest RoleDigest, format string, lang string) error { switch normalizeFormat(format) { case "markdown": - return writeDigestMarkdown(w, digest) + return writeDigestMarkdown(w, digest, lang) case "table": - return writeDigestTable(w, digest) + return writeDigestTable(w, digest, lang) 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 { +func writeDigestMarkdown(w io.Writer, digest RoleDigest, lang string) error { + title := fmt.Sprintf("# GitLink %s digest: %s\n\n", digest.Role, digest.Repository) + if isChineseLang(lang) { + role := "角色" + if digest.Role == "owner" { + role = "Owner" + } + if digest.Role == "contributor" { + role = "贡献者" + } + title = fmt.Sprintf("# GitLink %s摘要:%s\n\n", role, digest.Repository) + } + if _, err := fmt.Fprint(w, title); err != nil { return err } - lines := []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), - } + lines := digestMarkdownLines(digest, lang) if digest.HealthScore != nil { - lines = append(lines, fmt.Sprintf("- Health score: `%d`; health risk: `%s`", *digest.HealthScore, firstNonEmpty(digest.HealthRisk, "unknown"))) + if isChineseLang(lang) { + lines = append(lines, fmt.Sprintf("- 健康分:`%d`;健康风险:`%s`", *digest.HealthScore, firstNonEmpty(digest.HealthRisk, "unknown"))) + } else { + lines = append(lines, fmt.Sprintf("- Health score: `%d`; health risk: `%s`", *digest.HealthScore, firstNonEmpty(digest.HealthRisk, "unknown"))) + } } if digest.RepositoryURL != "" { - lines = append(lines, "- GitLink repository: "+digest.RepositoryURL) + if isChineseLang(lang) { + lines = append(lines, "- GitLink 仓库:"+digest.RepositoryURL) + } else { + lines = append(lines, "- GitLink repository: "+digest.RepositoryURL) + } } if digest.DocURL != "" { - lines = append(lines, "- Feishu report: "+digest.DocURL) + if isChineseLang(lang) { + lines = append(lines, "- 飞书报告:"+digest.DocURL) + } else { + lines = append(lines, "- Feishu report: "+digest.DocURL) + } } if _, err := fmt.Fprintln(w, strings.Join(lines, "\n")); err != nil { return err } if len(digest.AttentionItems) > 0 { - if _, err := fmt.Fprint(w, "\n## Attention\n\n"+bulletList(digest.AttentionItems, 8)+"\n"); err != nil { + heading := "Attention" + if isChineseLang(lang) { + heading = "需要关注" + } + if _, err := fmt.Fprintf(w, "\n## %s\n\n%s\n", heading, bulletList(localizeFeishuLines(digest.AttentionItems, lang), 8)); err != nil { return err } } if len(digest.NextSteps) > 0 { - if _, err := fmt.Fprint(w, "\n## Suggested next steps\n\n"+bulletList(digest.NextSteps, 8)+"\n"); err != nil { + heading := "Suggested next steps" + if isChineseLang(lang) { + heading = "建议下一步" + } + if _, err := fmt.Fprintf(w, "\n## %s\n\n%s\n", heading, bulletList(localizeFeishuLines(digest.NextSteps, lang), 8)); err != nil { return err } } - _, err := fmt.Fprintf(w, "\n> %s\n", digest.BoundaryDescription) + _, err := fmt.Fprintf(w, "\n> %s\n", localizedBoundary(digest, lang)) return err } -func writeDigestTable(w io.Writer, digest RoleDigest) error { +func digestMarkdownLines(digest RoleDigest, lang string) []string { + if isChineseLang(lang) { + return []string{ + fmt.Sprintf("- 报告分数:`%d`", digest.ReportScore), + fmt.Sprintf("- 风险等级:`%s`", firstNonEmpty(digest.RiskLevel, "unknown")), + fmt.Sprintf("- Issue:总数 `%d`,高风险 `%d`,信息缺失 `%d`", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo), + fmt.Sprintf("- PR:总数 `%d`,高风险 `%d`", digest.PRTotal, digest.PRHighRisk), + } + } + return []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), + } +} + +func writeDigestTable(w io.Writer, digest RoleDigest, lang string) 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 { + header := "ROLE\tREPOSITORY\tRISK\tSCORE\tISSUES\tHIGH_RISK_ISSUES\tPRS\tHIGH_RISK_PRS\tATTENTION" + if isChineseLang(lang) { + header = "角色\t仓库\t风险\t分数\tIssue\t高风险Issue\tPR\t高风险PR\t关注项" + } + if _, err := fmt.Fprintln(tw, header); 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", diff --git a/shortcuts/feishu/doc_export.go b/shortcuts/feishu/doc_export.go index bb71cbd..0eb6452 100644 --- a/shortcuts/feishu/doc_export.go +++ b/shortcuts/feishu/doc_export.go @@ -183,23 +183,23 @@ func BuildDocBlocks(report workflow.RepoReportResult, lang string) []DocBlock { healthRisk = report.Health.RiskLevel } blocks := []DocBlock{ - textBlock("GitLink workflow report: " + report.Repository), - textBlock(fmt.Sprintf("Report score: %d", report.ReportScore)), - textBlock("Risk level: " + firstNonEmpty(report.RiskLevel, "unknown")), - textBlock(fmt.Sprintf("Health score: %s; health risk: %s", healthScore, healthRisk)), - textBlock(fmt.Sprintf("Issues: total=%d, high_risk=%d, missing_info=%d", report.IssueSummary.Total, report.IssueSummary.HighRisk, report.IssueSummary.MissingInfo)), - textBlock(fmt.Sprintf("Pull Requests: total=%d, high_risk=%d", report.PRSummary.Total, report.PRSummary.HighRisk)), + textBlock(fmt.Sprintf(feishuLabel(lang, "doc_title"), report.Repository)), + textBlock(fmt.Sprintf(feishuLabel(lang, "doc_report_score"), report.ReportScore)), + textBlock(fmt.Sprintf(feishuLabel(lang, "doc_risk"), firstNonEmpty(report.RiskLevel, "unknown"))), + textBlock(fmt.Sprintf(feishuLabel(lang, "doc_health"), healthScore, healthRisk)), + textBlock(fmt.Sprintf(feishuLabel(lang, "doc_issues"), report.IssueSummary.Total, report.IssueSummary.HighRisk, report.IssueSummary.MissingInfo)), + textBlock(fmt.Sprintf(feishuLabel(lang, "doc_prs"), report.PRSummary.Total, report.PRSummary.HighRisk)), } if len(report.PRSummary.ReviewFocus) > 0 { - blocks = append(blocks, textBlock("Review focus:\n"+joinLines(report.PRSummary.ReviewFocus, 6))) + blocks = append(blocks, textBlock(feishuLabel(lang, "doc_review_focus")+":\n"+joinLines(localizeFeishuLines(report.PRSummary.ReviewFocus, lang), 6))) } if len(report.Recommendations) > 0 { - blocks = append(blocks, textBlock("Recommendations:\n"+joinLines(report.Recommendations, 8))) + blocks = append(blocks, textBlock(feishuLabel(lang, "doc_recommendations")+":\n"+joinLines(localizeFeishuLines(report.Recommendations, lang), 8))) } if len(report.Reasoning) > 0 { - blocks = append(blocks, textBlock("Reasoning:\n"+joinLines(report.Reasoning, 8))) + blocks = append(blocks, textBlock(feishuLabel(lang, "doc_reasoning")+":\n"+joinLines(localizeFeishuLines(report.Reasoning, lang), 8))) } - blocks = append(blocks, textBlock("Source: "+firstNonEmpty(report.Source, "workflow-json"))) + blocks = append(blocks, textBlock(fmt.Sprintf(feishuLabel(lang, "doc_source"), firstNonEmpty(report.Source, "workflow-json")))) return blocks } diff --git a/shortcuts/feishu/feishu.go b/shortcuts/feishu/feishu.go index 413e47f..6ca5ad7 100644 --- a/shortcuts/feishu/feishu.go +++ b/shortcuts/feishu/feishu.go @@ -279,7 +279,7 @@ func runOwnerDigest(ctx *common.RuntimeContext) error { 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")) + return renderDigest(os.Stdout, digest, formatOrDefault(ctx, "markdown"), normalizeLang(ctx.Arg("lang"))) } func runContributorDigest(ctx *common.RuntimeContext) error { @@ -296,7 +296,7 @@ func runContributorDigest(ctx *common.RuntimeContext) error { 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")) + return renderDigest(os.Stdout, digest, formatOrDefault(ctx, "markdown"), normalizeLang(ctx.Arg("lang"))) } func runDocExport(ctx *common.RuntimeContext) error { @@ -343,8 +343,8 @@ func runTaskPreview(ctx *common.RuntimeContext) error { 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")) + tasks := BuildTaskCandidatesLocalized(report, ctx.Arg("doc-url"), normalizeLang(ctx.Arg("lang"))) + return renderTaskOutput(os.Stdout, taskPreviewOutput(tasks), formatOrDefault(ctx, "markdown")) } func runTaskCreate(ctx *common.RuntimeContext) error { @@ -356,7 +356,7 @@ func runTaskCreate(ctx *common.RuntimeContext) error { if err != nil { return err } - tasks := BuildTaskCandidates(report, ctx.Arg("doc-url")) + tasks := BuildTaskCandidatesLocalized(report, ctx.Arg("doc-url"), normalizeLang(ctx.Arg("lang"))) return createTasksOrPreview(ctx, opts, tasks) } diff --git a/shortcuts/feishu/feishu_test.go b/shortcuts/feishu/feishu_test.go index ae69dfb..666216a 100644 --- a/shortcuts/feishu/feishu_test.go +++ b/shortcuts/feishu/feishu_test.go @@ -221,6 +221,21 @@ func TestTaskCandidatesAreStable(t *testing.T) { } } +func TestTaskPreviewOutputCountsTasks(t *testing.T) { + report := workflowReportFixture(t) + tasks := BuildTaskCandidates(report, "") + output := taskPreviewOutput(tasks) + if output.TaskCount != len(tasks) { + t.Fatalf("TaskCount = %d, want %d", output.TaskCount, len(tasks)) + } + if output.Send { + t.Fatal("preview output must not be marked as send") + } + if !output.DryRun { + t.Fatal("preview output must be dry-run") + } +} + func TestBitableSyncOptionsRejectSendDryRun(t *testing.T) { ctx := &common.RuntimeContext{Args: map[string]string{ "send": "true", @@ -234,6 +249,24 @@ func TestBitableSyncOptionsRejectSendDryRun(t *testing.T) { } } +func TestNormalizeBitableWriteFieldsFlattensStringSlices(t *testing.T) { + fields := normalizeBitableWriteFields(map[string]interface{}{ + "unique_key": "issue:test", + "recommended_action": []string{"first", "second"}, + "review_focus": []interface{}{"focus-a", "focus-b"}, + "count": 2, + }) + if fields["recommended_action"] != "first\nsecond" { + t.Fatalf("recommended_action = %#v", fields["recommended_action"]) + } + if fields["review_focus"] != "focus-a\nfocus-b" { + t.Fatalf("review_focus = %#v", fields["review_focus"]) + } + if fields["count"] != 2 { + t.Fatalf("count changed: %#v", fields["count"]) + } +} + func TestBitableSyncMockHTTP(t *testing.T) { report := workflowReportFixture(t) records := BuildBitableRecords(report, []string{"reports"}, "") @@ -344,6 +377,23 @@ func TestTaskCreateMockHTTP(t *testing.T) { } } +func TestTaskCreateTableShowsResults(t *testing.T) { + var out strings.Builder + output := TaskOutput{Results: []TaskCreateResult{{ + UniqueKey: "task:test", + Title: "Review report", + TaskID: "task_guid_123456", + Created: true, + }}} + if err := renderTaskOutput(&out, output, "table"); err != nil { + t.Fatalf("renderTaskOutput returned error: %v", err) + } + rendered := out.String() + if !strings.Contains(rendered, "CREATED") || !strings.Contains(rendered, "task...3456") { + t.Fatalf("task table did not show result details: %s", rendered) + } +} + func TestWikiNodeTokenFromURL(t *testing.T) { got := wikiNodeTokenFromURL("https://tenant.feishu.cn/wiki/NodeToken123?from=from_copylink") if got != "NodeToken123" { diff --git a/shortcuts/feishu/l10n.go b/shortcuts/feishu/l10n.go new file mode 100644 index 0000000..f7d0022 --- /dev/null +++ b/shortcuts/feishu/l10n.go @@ -0,0 +1,181 @@ +package feishu + +import ( + "fmt" + "regexp" + "strings" +) + +func isChineseLang(lang string) bool { + return strings.EqualFold(strings.TrimSpace(lang), "zh-CN") +} + +func feishuLabel(lang string, key string) string { + if !isChineseLang(lang) { + return feishuLabelsEN[key] + } + if value := feishuLabelsZH[key]; value != "" { + return value + } + return feishuLabelsEN[key] +} + +func localizeFeishuText(value string, lang string) string { + value = strings.TrimSpace(value) + if value == "" || !isChineseLang(lang) { + return value + } + if translated := knownFeishuTranslations[value]; translated != "" { + return translated + } + for _, pattern := range knownFeishuPatterns { + if match := pattern.re.FindStringSubmatch(value); len(match) > 1 { + return fmt.Sprintf(pattern.format, match[1]) + } + } + return value +} + +func localizeFeishuLines(values []string, lang string) []string { + if !isChineseLang(lang) { + return values + } + result := make([]string, 0, len(values)) + for _, value := range values { + result = append(result, localizeFeishuText(value, lang)) + } + return result +} + +var feishuLabelsEN = map[string]string{ + "attention": "Attention", + "boundary_contributor": "Contributor digest is role-oriented, not personalized. It does not use Feishu open_id or union_id routing.", + "boundary_owner": "Owner digest is a read-only summary. It does not modify GitLink or Feishu resources.", + "bot_generated": "Generated by gitlink-cli feishu +bot-test.", + "bot_message": "gitlink-cli can build and send Feishu custom bot cards.", + "bot_status": "Status", + "bot_title": "GitLink Feishu integration test", + "doc_health": "Health score: %s; health risk: %s", + "doc_issues": "Issues: total=%d, high_risk=%d, missing_info=%d", + "doc_prs": "Pull Requests: total=%d, high_risk=%d", + "doc_reasoning": "Reasoning", + "doc_recommendations": "Recommendations", + "doc_report_score": "Report score: %d", + "doc_review_focus": "Review focus", + "doc_risk": "Risk level: %s", + "doc_source": "Source: %s", + "doc_title": "GitLink workflow report: %s", + "health_risk": "Health risk", + "health_score": "Health score", + "high_risk_issues": "High-risk issues", + "high_risk_prs": "High-risk PRs", + "issues": "Issues", + "missing_info": "Missing info", + "missing_info_issues": "Missing-info issues", + "open_feishu_report": "Open Feishu report", + "open_gitlink_repository": "Open GitLink repository", + "owner_digest_title": "GitLink owner digest: %s", + "contributor_digest_title": "GitLink contributor digest: %s", + "preview_note": "Preview is read-only. Bitable records are generated locally by +bitable-records.", + "pull_requests": "Pull requests", + "ready": "Ready", + "recommendations": "Recommendations", + "report_score": "Report score", + "repository": "Repository", + "review_focus": "Review focus", + "risk_level": "Risk level", + "source": "Source", + "suggested_next_steps": "Suggested next steps", + "task_description_default": "Workflow recommendation from gitlink-cli repo report.", + "task_missing_info_desc": "Some issues need reproduction steps, logs, version details, or command output.", + "task_pr_high_risk_desc": "High-risk PR bucket from workflow report. Check review focus and merge readiness.", + "task_review_focus_desc": "Review focus items from the workflow report.", + "task_review_report_desc": "No high-risk task candidates were detected. Keep a regular owner review cadence.", + "workflow_report_title": "GitLink workflow report: %s", +} + +var feishuLabelsZH = map[string]string{ + "attention": "需要关注", + "boundary_contributor": "贡献者摘要是按角色生成的汇总,不是基于飞书 open_id 或 union_id 的个人定向推送。", + "boundary_owner": "Owner 摘要是只读汇总,不会修改 GitLink 或飞书资源。", + "bot_generated": "由 gitlink-cli feishu +bot-test 生成。", + "bot_message": "gitlink-cli 可以构建并发送飞书自定义机器人卡片。", + "bot_status": "状态", + "bot_title": "GitLink 飞书集成测试", + "doc_health": "健康分:%s;健康风险:%s", + "doc_issues": "Issue:总数=%d,高风险=%d,信息缺失=%d", + "doc_prs": "PR:总数=%d,高风险=%d", + "doc_reasoning": "判断依据", + "doc_recommendations": "建议操作", + "doc_report_score": "报告分数:%d", + "doc_review_focus": "审查重点", + "doc_risk": "风险等级:%s", + "doc_source": "来源:%s", + "doc_title": "GitLink 工作流报告:%s", + "health_risk": "健康风险", + "health_score": "健康分", + "high_risk_issues": "高风险 Issue", + "high_risk_prs": "高风险 PR", + "issues": "Issue", + "missing_info": "信息缺失", + "missing_info_issues": "信息缺失 Issue", + "open_feishu_report": "打开飞书报告", + "open_gitlink_repository": "打开 GitLink 仓库", + "owner_digest_title": "GitLink Owner 摘要:%s", + "contributor_digest_title": "GitLink 贡献者摘要:%s", + "preview_note": "当前为只读预览。多维表格记录由 +bitable-records 在本地生成。", + "pull_requests": "PR", + "ready": "就绪", + "recommendations": "建议操作", + "report_score": "报告分数", + "repository": "仓库", + "review_focus": "审查重点", + "risk_level": "风险等级", + "source": "来源", + "suggested_next_steps": "建议下一步", + "task_description_default": "来自 gitlink-cli 仓库报告的工作流建议。", + "task_missing_info_desc": "部分 Issue 需要补充复现步骤、日志、版本信息或命令输出。", + "task_pr_high_risk_desc": "工作流报告识别到高风险 PR,请检查审查重点和合并准备状态。", + "task_review_focus_desc": "来自工作流报告的 PR 审查重点。", + "task_review_report_desc": "未识别到高风险任务候选,建议保持定期 owner 复查节奏。", + "workflow_report_title": "GitLink 工作流报告:%s", +} + +var knownFeishuTranslations = map[string]string{ + "Add LICENSE and CONTRIBUTING files for contributor clarity.": "补充 LICENSE 和 CONTRIBUTING,降低贡献者理解成本。", + "Add missing reproduction steps, logs, or screenshots when requested.": "按需补充复现步骤、日志或截图。", + "Add or improve README and contribution guidance.": "补充或改进 README 与贡献指南。", + "Check PR review focus and update the related branch or description.": "检查 PR 审查重点,并更新相关分支或描述。", + "Keep GitLink write actions outside this digest; card buttons are navigation-only.": "此摘要不执行 GitLink 写操作;卡片按钮仅用于跳转。", + "Maintain the current workflow and review the repository report regularly.": "保持当前维护节奏,并定期复查仓库报告。", + "No contributor-specific blocker was detected in the workflow report.": "工作流报告未识别到明确的贡献者阻塞项。", + "No critical owner action was detected in the workflow report.": "工作流报告未识别到紧急 owner 动作。", + "Open the GitLink repository or Feishu report link for details.": "打开 GitLink 仓库或飞书报告查看详情。", + "Prioritize high or critical risk pull requests.": "优先审阅 high / critical 风险 PR。", + "Prioritize high-risk pull request feedback before new work.": "先处理高风险 PR 反馈,再开始新工作。", + "Reduce stale issues and add response labels or next actions.": "减少长期未处理的 Issue,并补充响应标签或下一步动作。", + "Request missing information for 3 issues": "为 3 个 Issue 补充缺失信息", + "Request missing reproduction steps, version, command output, or logs.": "要求补充复现步骤、版本、命令输出或日志。", + "Review PR focus areas": "审查 PR 重点区域", + "Review high-risk issues and PRs first.": "优先处理高风险 Issue 和 PR。", + "Review report risks and schedule the next maintenance actions.": "复查报告中的风险项,并安排下一步维护动作。", + "Review stale pull requests and clarify merge blockers.": "审查长期未处理的 PR,并明确合并阻塞点。", + "Use the Feishu report document for full context when available.": "如有飞书报告文档,优先查看完整上下文。", + "Use the health recommendations to reduce repository governance risk.": "根据健康度建议降低仓库治理风险。", + "Workflow recommendation from gitlink-cli repo report.": "来自 gitlink-cli 仓库报告的工作流建议。", +} + +var knownFeishuPatterns = []struct { + re *regexp.Regexp + format string +}{ + {regexp.MustCompile(`^(\d+) high-risk issues need maintainer triage$`), "%s 个高风险 Issue 需要维护者分诊"}, + {regexp.MustCompile(`^(\d+) issues are missing required information$`), "%s 个 Issue 缺少必要信息"}, + {regexp.MustCompile(`^(\d+) high-risk pull requests need owner review$`), "%s 个高风险 PR 需要 owner 审阅"}, + {regexp.MustCompile(`^repository health score is (\d+)$`), "仓库健康分为 %s"}, + {regexp.MustCompile(`^(\d+) high-risk pull requests may need contributor updates$`), "%s 个高风险 PR 可能需要贡献者更新"}, + {regexp.MustCompile(`^(\d+) issues need clearer reproduction details or missing information$`), "%s 个 Issue 需要更清晰的复现信息或缺失信息"}, + {regexp.MustCompile(`^(\d+) high-risk issues may need focused follow-up$`), "%s 个高风险 Issue 需要重点跟进"}, + {regexp.MustCompile(`^Request missing information for (\d+) issues$`), "为 %s 个 Issue 补充缺失信息"}, + {regexp.MustCompile(`^Review (\d+) high-risk pull requests$`), "审查 %s 个高风险 PR"}, +} diff --git a/shortcuts/feishu/task.go b/shortcuts/feishu/task.go index 2a06b91..037d505 100644 --- a/shortcuts/feishu/task.go +++ b/shortcuts/feishu/task.go @@ -58,14 +58,22 @@ type TaskCreateResult struct { } func BuildTaskCandidates(report workflow.RepoReportResult, docURL string) []TaskCandidate { + return buildTaskCandidates(report, docURL, defaultLang) +} + +func BuildTaskCandidatesLocalized(report workflow.RepoReportResult, docURL string, lang string) []TaskCandidate { + return buildTaskCandidates(report, docURL, lang) +} + +func buildTaskCandidates(report workflow.RepoReportResult, docURL string, lang string) []TaskCandidate { tasks := []TaskCandidate{} repoURL := gitlinkRepoURL(report.Repository) for i, recommendation := range report.Recommendations { - title := firstNonEmpty(recommendation, "Review workflow recommendation") + title := firstNonEmpty(localizeFeishuText(recommendation, lang), "Review workflow recommendation") tasks = append(tasks, TaskCandidate{ UniqueKey: stableKey("task", report.Repository, "recommendation", fmt.Sprintf("%d", i+1)), Title: title, - Description: "Workflow recommendation from gitlink-cli repo report.", + Description: feishuLabel(lang, "task_description_default"), SourceType: "recommendation", SourceKey: fmt.Sprintf("recommendation-%d", i+1), Repository: report.Repository, @@ -80,8 +88,8 @@ func BuildTaskCandidates(report workflow.RepoReportResult, docURL string) []Task 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.", + Title: localizeFeishuText(fmt.Sprintf("Triage %d high-risk GitLink issues", report.IssueSummary.HighRisk), lang), + Description: localizeFeishuText("High-risk issue bucket from workflow report. Review GitLink issues before routine work.", lang), SourceType: "issues", SourceKey: "issues-high-risk", Repository: report.Repository, @@ -96,8 +104,8 @@ func BuildTaskCandidates(report workflow.RepoReportResult, docURL string) []Task 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.", + Title: localizeFeishuText(fmt.Sprintf("Request missing information for %d issues", report.IssueSummary.MissingInfo), lang), + Description: feishuLabel(lang, "task_missing_info_desc"), SourceType: "issues", SourceKey: "issues-missing-info", Repository: report.Repository, @@ -112,8 +120,8 @@ func BuildTaskCandidates(report workflow.RepoReportResult, docURL string) []Task 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.", + Title: localizeFeishuText(fmt.Sprintf("Review %d high-risk pull requests", report.PRSummary.HighRisk), lang), + Description: feishuLabel(lang, "task_pr_high_risk_desc"), SourceType: "prs", SourceKey: "prs-high-risk", Repository: report.Repository, @@ -128,8 +136,8 @@ func BuildTaskCandidates(report workflow.RepoReportResult, docURL string) []Task 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"), + Title: localizeFeishuText("Review PR focus areas", lang), + Description: strings.Join(limitStrings(localizeFeishuLines(report.PRSummary.ReviewFocus, lang), 8), "\n"), SourceType: "prs", SourceKey: "prs-review-focus", Repository: report.Repository, @@ -144,8 +152,8 @@ func BuildTaskCandidates(report workflow.RepoReportResult, docURL string) []Task 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.", + Title: localizeFeishuText("Review GitLink workflow report", lang), + Description: feishuLabel(lang, "task_review_report_desc"), SourceType: "report", SourceKey: "report-review", Repository: report.Repository, @@ -160,6 +168,15 @@ func BuildTaskCandidates(report workflow.RepoReportResult, docURL string) []Task return dedupeTasks(tasks) } +func taskPreviewOutput(tasks []TaskCandidate) TaskOutput { + return TaskOutput{ + Mode: "preview", + DryRun: true, + TaskCount: len(tasks), + Tasks: tasks, + } +} + func taskCreateOptionsFromContext(ctx *common.RuntimeContext) (TaskCreateOptions, error) { opts := TaskCreateOptions{ AppID: firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")), @@ -262,6 +279,17 @@ func writeTaskMarkdown(w io.Writer, output TaskOutput) error { func writeTaskTable(w io.Writer, output TaskOutput) error { tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if len(output.Results) > 0 { + if _, err := fmt.Fprintln(tw, "KEY\tCREATED\tTASK_ID\tTITLE\tERROR"); err != nil { + return err + } + for _, result := range output.Results { + if _, err := fmt.Fprintf(tw, "%s\t%t\t%s\t%s\t%s\n", result.UniqueKey, result.Created, redactToken(result.TaskID), result.Title, result.Error); err != nil { + return err + } + } + return tw.Flush() + } if _, err := fmt.Fprintln(tw, "KEY\tPRIORITY\tSOURCE\tTITLE"); err != nil { return err } From 278b496118e8c19bb20321acd487e8f79de627bc Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Fri, 26 Jun 2026 22:33:33 +0800 Subject: [PATCH 11/16] docs(feishu): finalize text-only validation evidence --- README.zh-CN.md | 35 ++- docs/FEISHU_CAPABILITY_LAYERS.md | 20 ++ docs/FEISHU_OPENAPI_INVENTORY.md | 20 +- docs/PR_VISUAL_GUIDE.md | 52 ----- docs/images/.gitkeep | 1 - ...EISHU_API_COLLECTION_CHECKLIST_20260626.md | 20 +- reports/FEISHU_LOCAL_TESTING_GUIDE.md | 11 +- reports/FEISHU_PERMISSION_MATRIX.md | 4 +- reports/FEISHU_SMOKE_20260626.md | 74 ++++--- .../FEISHU_USER_COLLECTION_LIST_20260626.md | 209 ++++++------------ scripts/feishu-gitlink-screenshot-check.ps1 | 35 --- scripts/feishu-gitlink-smoke.ps1 | 11 +- 12 files changed, 205 insertions(+), 287 deletions(-) delete mode 100644 docs/PR_VISUAL_GUIDE.md delete mode 100644 docs/images/.gitkeep delete mode 100644 scripts/feishu-gitlink-screenshot-check.ps1 diff --git a/README.zh-CN.md b/README.zh-CN.md index 6571f96..8f66dd5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -555,7 +555,9 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --ye `feishu` 将 `workflow +repo-report` JSON 转成飞书协作内容。 -稳定用法: +#### 稳定层:自定义机器人通知 + +稳定层只依赖飞书群自定义机器人。它适合把 GitLink 项目状态、周报、Owner 摘要和贡献者摘要推送到群里。默认只预览,真实发送必须显式传 `--send`。 ```bash gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --format json > report.json @@ -570,7 +572,19 @@ gitlink-cli feishu +bitable-records --from-workflow-json report.json --format js gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown ``` -实验性开放平台用法: +中文输出建议同时给 workflow 和 feishu 命令传 `--lang zh-CN`: + +```bash +gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --lang zh-CN --format json > report.zh-CN.json + +gitlink-cli feishu +notify --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table +gitlink-cli feishu +owner-digest --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table +gitlink-cli feishu +contributor-digest --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table +``` + +#### 实验层:飞书开放平台写入 + +实验层使用飞书开放平台自建应用。当前已在测试企业中验证 DocX 追加、多维表格写入和飞书任务创建,但这部分不是零配置稳定能力。真实写入仍然必须显式传 `--send`,并且要求自建应用有对应 API scope 和目标资源权限。 ```bash gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "$FEISHU_WIKI_URL" --send --format table @@ -578,8 +592,25 @@ gitlink-cli feishu +bitable-sync --from-workflow-json report.json --tables repor gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table ``` +为了完成端到端验证,测试企业里的自建应用授予了较宽的权限。正式部署时不建议照搬测试权限,应由维护者或管理员按命令实际需要开最小权限。 + +多维表格已完成两类真实验证: + +- 单表多视图验证:把 `reports/issues/prs/contributors/tasks` 写入同一张测试表,证明字段和写入链路可用。 +- 独立表验证:拆成 `gitlink_reports`、`gitlink_issues`、`gitlink_prs`、`gitlink_contributors`、`gitlink_tasks` 五张表,分别写入 `1/5/2/1/7` 条记录,证明每类记录都能写入独立表。 + +#### 当前边界 + 本分支不实现 GitLink 写操作。飞书卡片按钮仅用于跳转。开放平台能力必须显式传 `--send`,并要求自建应用具备对应资源权限。是否在正式部署中启用这些实验能力,由 GitLink 维护者和部署管理员决定。 +下一阶段再考虑: + +- 飞书任务项目/分组归属。 +- 飞书任务执行者/关注人。 +- 飞书侧任务去重或搜索。 +- 多维表格自动建 Base、建表、建字段、建视图。 +- 飞书卡片回调和 GitLink 低风险写动作。 + 详细文档: - [飞书集成](./docs/feishu-integration.md) diff --git a/docs/FEISHU_CAPABILITY_LAYERS.md b/docs/FEISHU_CAPABILITY_LAYERS.md index d5a513f..a264741 100644 --- a/docs/FEISHU_CAPABILITY_LAYERS.md +++ b/docs/FEISHU_CAPABILITY_LAYERS.md @@ -91,6 +91,15 @@ Required Feishu permission: Self-built app with approved scopes and resource-level access. ``` +Test-enterprise note: + +```text +The local validation enterprise used a self-built app with broad permissions so +that DocX, Base, and Task APIs could be tested end to end. This is only a +validation setup. Production deployments should use least-privilege scopes and +resource-level access selected by maintainers or administrators. +``` + Required environment variables: ```text @@ -135,10 +144,21 @@ What it cannot do: Create Base apps, tables, fields, or views. Modify Feishu document permissions. Guarantee Task deduplication against existing Feishu tasks. +Place created Feishu tasks into a specific Task project or section. +Assign task executors or followers. Guarantee Bitable upsert if unique_key is missing from the target table. Treat Open Platform writes as stable zero-config behavior. ``` +Next-stage Open Platform boundary: + +```text +Task project placement, section placement, executors, followers, and Feishu-side +dedupe/search should be implemented in a later stage after the exact Task API +request fields and tenant behavior are confirmed. They are not part of the +current stable or experimental surface. +``` + Testing: ```text diff --git a/docs/FEISHU_OPENAPI_INVENTORY.md b/docs/FEISHU_OPENAPI_INVENTORY.md index 14a96ab..1071a74 100644 --- a/docs/FEISHU_OPENAPI_INVENTORY.md +++ b/docs/FEISHU_OPENAPI_INVENTORY.md @@ -118,7 +118,7 @@ Next hardening: ```text Add more card color/stage variants for PR review state. Add compact owner card and detailed digest variants. -Add screenshot-backed smoke evidence after real webhook env is restored. +Keep image evidence deferred for this upload; use text smoke evidence instead. ``` ## Layer 2: Experimental Open Platform Validation @@ -289,6 +289,19 @@ The missing fields were created manually through OpenAPI for validation. contributors, and task records in the test table. ``` +Follow-up split-table validation on 2026-06-26: + +```text +Five dedicated test tables were created or reused in the same Base: +gitlink_reports, gitlink_issues, gitlink_prs, gitlink_contributors, gitlink_tasks. +Each table received the fields required by its record group. ++bitable-sync --send then wrote every group to its own table: +reports=1, issues=5, prs=2, contributors=1, tasks=7. +``` + +The split-table run proves that the CLI can write each supported Bitable record +group to an independent table when the table IDs are configured separately. + Known blockers: ```text @@ -363,6 +376,11 @@ placement must be wired only after the official request fields and tenant behavior are confirmed in the test enterprise. ``` +This is a next-stage capability boundary, not a current implementation gap to +hide. The current branch proves basic Task API creation; project placement, +section placement, executors, followers, and Feishu-side task dedupe should be +added in a later implementation stage. + Next hardening: ```text diff --git a/docs/PR_VISUAL_GUIDE.md b/docs/PR_VISUAL_GUIDE.md deleted file mode 100644 index e522602..0000000 --- a/docs/PR_VISUAL_GUIDE.md +++ /dev/null @@ -1,52 +0,0 @@ -# PR Visual Guide - -Date: 2026-06-26 - -This file lists the manual screenshots to capture after local and real smoke testing. -The 2026-06-26 smoke run successfully delivered Feishu cards, appended DocX -content, synced Bitable records, and created Feishu tasks in the test -enterprise. Screenshots still need to be captured manually from the UI. - -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`. - -Use the helper to check current screenshot status: - -```powershell -.\scripts\feishu-gitlink-screenshot-check.ps1 -``` - -| 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` | Real sync succeeded in the test Base; capture the updated table or target view | -| DocX / Wiki report | `docs/images/feishu-docx-wiki.png` | Real DocX append succeeded; capture the appended report blocks | -| Feishu task list | `docs/images/feishu-task-create.png` | Real task creation succeeded; capture the created task list and redact IDs if visible | -| 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 -gitlink-cli feishu +notify --from-workflow-json report.zh-CN.json --lang zh-CN --send --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/images/.gitkeep b/docs/images/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/docs/images/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md b/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md index 75ceafb..0063c3a 100644 --- a/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md +++ b/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md @@ -23,22 +23,24 @@ Stable previews: available from .local/report.json and .local/report.zh-CN.json. Real Feishu sends: passed through custom bot webhook. Real DocX append: passed through self-built app OpenAPI. Real Bitable sync: passed after target table fields were created. +Split Bitable sync: passed with five separate tables. Real Task create: passed; project/section placement remains unmapped. GitLink write operations: not implemented and not tested. +Test enterprise permissions: intentionally broad for validation; production should use minimum scopes. ``` ## API Collection Status | Item | Status | Evidence | Next action | | --- | --- | --- | --- | -| Custom bot webhook | Complete and real-tested | `shortcuts/feishu/client.go`, `sign.go`, `card.go` | Capture screenshots | +| Custom bot webhook | Complete and real-tested | `shortcuts/feishu/client.go`, `sign.go`, `card.go` | Image evidence deferred | | Custom bot signing | Complete and real-tested | `SignCustomBotRequest` unit test plus signed bot smoke | Keep secrets redacted | | tenant_access_token | Complete and real-tested | `OpenAPIClient.TenantAccessToken` | Add future `+app-check` | | Wiki node resolution | Complete | `OpenAPIClient.GetWikiNode` | Still depends on target Wiki node permission | | DocX create | Complete | `OpenAPIClient.CreateDocument` | Requires folder permission when creating new docs | | DocX block append | Complete and real-tested | `OpenAPIClient.CreateBlocks` | App must have target DocX edit permission | | Bitable search | Complete and real-tested | `SearchBitableRecord` | Requires `unique_key` field | -| Bitable create | Complete and real-tested | `CreateBitableRecord` | Requires existing table and compatible fields | +| Bitable create | Complete and real-tested | `CreateBitableRecord` | Requires existing table and compatible fields; split-table write passed | | Bitable update | Complete and real-tested | `UpdateBitableRecord` | Never deletes records | | Task create | Complete at minimal level and real-tested | `CreateTask` sends summary and description | Confirm project/section request fields | | IM app bot message | Planned | Official API collected | Not needed for stable webhook path | @@ -81,11 +83,10 @@ These remain manual or owner-side tasks and should not be committed to the repository. ```text -1. Capture Feishu UI screenshots for the PR visual guide. -2. Decide whether the test Base should use one table with views or separate - reports/issues/prs/contributors/tasks tables. -3. If separate tables are desired, create them and copy each table ID into the - local env file. +1. Defer Feishu UI screenshots and image evidence for this upload. +2. Keep the split-table validation as text evidence. +3. Decide later whether the final demonstration should use split tables only or + also keep the one-table/multiple-view proof as background evidence. 4. Decide whether `+bitable-sync` should stay experimental or be narrowed to dry-run-only for upstream review. 5. Confirm Feishu Task project/section request fields before placing tasks in @@ -137,7 +138,7 @@ go test ./... ```text Task project/section placement needs official request-field confirmation. Current Base output is summary-oriented and not yet row-level project cockpit data. -Screenshot evidence still needs manual capture. +Image evidence is deferred and is not part of this upload. ``` ## Verification Run @@ -158,6 +159,7 @@ Executed on 2026-06-26 after the API inventory update: | `+contributor-digest --send` | Pass | Custom bot delivered English/default and Chinese contributor digest | | `+bitable-sync` preview | Pass | 1 report, 5 issue, 2 PR, 1 contributor, 7 task records | | `+bitable-sync --send` | Pass | Search/create/update real-tested after field creation | +| Split-table `+bitable-sync --send` | Pass | Created 1 report, 5 issue, 2 PR, 1 contributor, and 7 task records across five separate Bitable tables | | `+doc-export` preview | Pass | 9 DocX-ready blocks | | `+doc-export --send` | Pass | Appended English/default and Chinese DocX blocks | | `+task-preview` preview | Pass | 7 task candidates | @@ -169,7 +171,7 @@ Executed on 2026-06-26 after the API inventory update: | `go test ./shortcuts` | Pass | Shortcut package regression passed | | `go test ./...` | Pass | Full repository test suite passed | | Raw secret scan | Pass | No raw secret values found in tracked/unignored candidate files | -| Screenshot checklist | Expected fail | Real send/write screenshots still need manual capture | +| Image evidence | Deferred | No screenshots or image files are included in this upload | ## Do Not Commit diff --git a/reports/FEISHU_LOCAL_TESTING_GUIDE.md b/reports/FEISHU_LOCAL_TESTING_GUIDE.md index 19b4405..f5c1240 100644 --- a/reports/FEISHU_LOCAL_TESTING_GUIDE.md +++ b/reports/FEISHU_LOCAL_TESTING_GUIDE.md @@ -239,13 +239,10 @@ FEISHU_TASK_PROJECT_ID optional FEISHU_TASK_SECTION_ID optional ``` -## 21. Run Screenshot Check +## 21. Image Evidence -```powershell -.\scripts\feishu-gitlink-screenshot-check.ps1 -``` - -The script lists missing screenshots. It does not fabricate images. +Image evidence is deferred for this round. Do not add screenshots or image files +to the upload. ## 22. Run Go Tests @@ -259,6 +256,6 @@ go test ./... ## 23. Capture Evidence -Capture terminal logs and screenshots listed in `docs/PR_VISUAL_GUIDE.md`. +Capture terminal logs and command output only. Do not capture raw secrets. Redact webhook URLs, app secrets, app tokens, table IDs, Wiki node tokens, folder tokens, GitLink tokens, tenant tokens, open IDs, and union IDs. diff --git a/reports/FEISHU_PERMISSION_MATRIX.md b/reports/FEISHU_PERMISSION_MATRIX.md index c781b18..4642ec7 100644 --- a/reports/FEISHU_PERMISSION_MATRIX.md +++ b/reports/FEISHU_PERMISSION_MATRIX.md @@ -15,6 +15,6 @@ GitLink write permission is `No` for every implemented command in this branch. | Bitable records | `feishu +bitable-records` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed | Summary records, not one row per raw issue/PR | | Task preview | `feishu +task-preview` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed, including zh-CN | Local candidates only | | DocX / Wiki export | `feishu +doc-export` | Experimental Open Platform | No | Yes for `--send` | Yes | No | No | No | No | Yes | mock, preview, and real DocX append passed, including zh-CN | App must have scopes and document/folder permission | -| Bitable sync | `feishu +bitable-sync` | Experimental Open Platform | No | Yes for `--send` | No | Yes | No | No | No | Yes | mock, preview, and real search/create/update passed | Requires existing tables and compatible fields; one-table test used multiple record groups | -| Task create | `feishu +task-create` | Experimental Open Platform | No | Yes for `--send` | No | No | Yes | No | No | Yes | mock, preview, and real create passed | Dedupe is local unique_key only; project/section IDs are collected but not mapped into the request body yet | +| Bitable sync | `feishu +bitable-sync` | Experimental Open Platform | No | Yes for `--send` | No | Yes | No | No | No | Yes | mock, preview, one-table real write, and split-table real write passed | Requires existing tables and compatible fields; CLI does not create production tables/views | +| Task create | `feishu +task-create` | Experimental Open Platform | No | Yes for `--send` | No | No | Yes | No | No | Yes | mock, preview, and real create passed | Dedupe is local unique_key only; project/section/assignee/follower support is next-stage boundary | | GitLink action gateway | not implemented | Future planning | No | Planned | No | No | No | Planned | Yes | No | not implemented | Requires official authorization model | diff --git a/reports/FEISHU_SMOKE_20260626.md b/reports/FEISHU_SMOKE_20260626.md index f396e9d..6dedeea 100644 --- a/reports/FEISHU_SMOKE_20260626.md +++ b/reports/FEISHU_SMOKE_20260626.md @@ -40,6 +40,11 @@ GitLink write operations: not used All Feishu resource IDs, tokens, webhook URLs, app credentials, table IDs, and document IDs were kept in `.local/feishu-gitlink.env.ps1` and are not committed. +The Feishu self-built app in this test enterprise was intentionally granted +broad permissions for validation. This is not the recommended production +permission model. A production deployment should use the smallest scopes and +resource permissions required by the enabled commands. + ## Redacted Environment Presence | Variable | Present? | Notes | @@ -51,11 +56,11 @@ document IDs were kept in `.local/feishu-gitlink.env.ps1` and are not committed. | `FEISHU_FOLDER_TOKEN` | present | redacted | | `FEISHU_DOCUMENT_ID` | present | redacted | | `FEISHU_BASE_APP_TOKEN` | present | redacted | -| `FEISHU_REPORT_TABLE_ID` | present | same test table as other table envs | -| `FEISHU_ISSUE_TABLE_ID` | present | same test table as other table envs | -| `FEISHU_PR_TABLE_ID` | present | same test table as other table envs | -| `FEISHU_CONTRIBUTOR_TABLE_ID` | present | same test table as other table envs | -| `FEISHU_TASK_TABLE_ID` | present | same test table as other table envs | +| `FEISHU_REPORT_TABLE_ID` | present | split test table | +| `FEISHU_ISSUE_TABLE_ID` | present | split test table | +| `FEISHU_PR_TABLE_ID` | present | split test table | +| `FEISHU_CONTRIBUTOR_TABLE_ID` | present | split test table | +| `FEISHU_TASK_TABLE_ID` | present | split test table | | `FEISHU_TASK_PROJECT_ID` | missing | optional; current request body does not place tasks into project/section | | `FEISHU_TASK_SECTION_ID` | missing | optional; current request body does not place tasks into project/section | | `GITLINK_OWNER` | present | `Gitlink` | @@ -104,6 +109,7 @@ The workflow command does not currently filter the report by explicit PR IDs, so | `feishu +bitable-sync --tables reports --send` | pass after table fields were added | created the report record | | `feishu +bitable-sync --tables reports,issues,prs,contributors,tasks --send` | pass | updated 1 report, created 5 issue buckets, 2 PR buckets, 1 contributor bucket, 7 task buckets | | `feishu +bitable-sync --lang zh-CN --send` | pass | updated existing records from the Chinese workflow JSON | +| split-table `feishu +bitable-sync --send` | pass | wrote to 5 separate Bitable tables: reports=1, issues=5, prs=2, contributors=1, tasks=7 | | `feishu +task-preview --lang zh-CN` | pass | generated 7 Chinese task candidates | | `feishu +task-create --lang zh-CN --send` | pass | created 7 Feishu tasks | @@ -127,6 +133,31 @@ This confirms that `+bitable-sync` can search, create, and update records when the target table already has compatible fields. It does not yet create Base tables or views itself. +After the first one-table validation, five dedicated test tables were created +or reused in the same Base: + +```text +gitlink_reports +gitlink_issues +gitlink_prs +gitlink_contributors +gitlink_tasks +``` + +Each table was populated with its own required fields and then validated with +`+bitable-sync --send`. The split-table run created records in every table: + +```text +reports: 1 +issues: 5 +prs: 2 +contributors: 1 +tasks: 7 +``` + +This split-table validation is better evidence for the project-management model +than the earlier one-table/multiple-view validation. + ## i18n Result Feishu command-level Chinese output is usable: @@ -175,36 +206,19 @@ module. It was not fixed in this smoke run to avoid unrelated locale churn. ```text 1. Bitable sync requires existing Base/table/fields; CLI does not create tables or views. -2. The current smoke used one test table for all record groups because the provided links were one table with multiple views. +2. The first smoke used one test table for all record groups; a later smoke created split tables and proved every record group can write to its own table. 3. Current Bitable records are summary buckets, not row-level PR/Issue/CI records. -4. Feishu task creation does not yet map project/section placement into the request body. +4. Feishu task creation does not yet map project/section placement into the request body; this is a next-stage capability boundary. 5. Feishu-side task dedupe/search is not implemented; avoid repeated real task-create runs unless duplicates are acceptable. 6. No Feishu callback server is implemented. 7. No GitLink write operation is implemented. -8. Screenshots still need to be captured manually from the Feishu UI. +8. Image evidence is deferred and is not part of this upload. ``` -## Screenshot Checklist +## Image Evidence -Run: +Image files are intentionally not included in this upload. -```powershell -.\scripts\feishu-gitlink-screenshot-check.ps1 -``` - -Manual captures still needed: - -```text -docs/images/feishu-bot-card.png -docs/images/feishu-weekly-report.png -docs/images/feishu-owner-digest.png -docs/images/feishu-contributor-digest.png -docs/images/feishu-bitable-preview.png -docs/images/feishu-bitable-sync.png -docs/images/feishu-docx-wiki.png -docs/images/feishu-task-create.png -docs/images/feishu-smoke-terminal.png -docs/images/feishu-env-redacted.png -``` - -Do not fabricate screenshots. Redact IDs and tokens before committing any image. +The validation evidence for this round is command output, real OpenAPI results, +the permission matrix, and the smoke report. UI screenshots can be collected in +a later documentation pass if needed. diff --git a/reports/FEISHU_USER_COLLECTION_LIST_20260626.md b/reports/FEISHU_USER_COLLECTION_LIST_20260626.md index 0227b90..736d839 100644 --- a/reports/FEISHU_USER_COLLECTION_LIST_20260626.md +++ b/reports/FEISHU_USER_COLLECTION_LIST_20260626.md @@ -1,175 +1,104 @@ -# 飞书 / GitLink 本地验证信息收集清单 +# Feishu / GitLink Project Completion Checklist Date: 2026-06-26 -用途:这份清单只说明需要从飞书和 GitLink 页面收集哪些值。真实值不要写进本文件,也不要提交到仓库。真实值只放到本地忽略文件: +This checklist records what still needs manual evidence or product decisions +after the real Feishu validation run. Do not write real secrets, webhook URLs, +app secrets, table IDs, document IDs, open IDs, union IDs, or GitLink tokens in +this file. + +## Current Validation State ```text -.local/feishu-gitlink.env.ps1 +Custom bot webhook: configured and real send passed. +Self-built app credentials: configured and tenant_access_token passed. +DocX target: configured and real append passed. +Bitable Base: configured. +Bitable one-table validation: passed. +Bitable split-table validation: passed. +Feishu task creation: basic task create passed. +GitLink repository source: Gitlink/gitlink-cli real workflow report generated. +zh-CN output: available for Feishu cards, digests, DocX blocks, and task candidates. +Image evidence: deferred and not part of this upload. ``` -## 当前状态 +## Image Evidence + +Screenshots and other image files are intentionally deferred for this round. ```text -自定义机器人 webhook:已配置并真实发送通过。 -自建应用 app_id/app_secret:已配置并获取 tenant_access_token 通过。 -DocX 目标:已配置并真实追加报告通过。 -多维表格 Base:已配置;当前测试链接是同一个 Base 的同一张表的多个视图。 -多维表格字段:已通过 OpenAPI 为测试表补齐。 -Bitable search/create/update:已真实通过。 -飞书任务创建:已真实通过;项目/分组归属尚未接入请求体。 -GitLink 仓库:已使用 Gitlink/gitlink-cli 生成真实 workflow report。 -i18n:feishu 命令 zh-CN 输出可用;仓库全局 i18n check 仍有既有 en-US.json 格式化问题。 -截图:仍需从飞书 UI 手工截取。 +Do not add screenshots or image files in this upload. +Use the text smoke report and permission matrix as current evidence. ``` -## 1. 稳定层:飞书自定义机器人 +If visual evidence is needed later, capture it in a separate documentation pass +and redact all visible IDs or secrets before committing. -这些值用于真实发送飞书群卡片。 +## Bitable Demonstration State -| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | -| --- | --- | --- | --- | --- | -| 自定义机器人 Webhook URL | `FEISHU_WEBHOOK_URL` | 是 | 飞书群聊 -> 群设置 -> 机器人 -> 自定义机器人 | `+bot-test`, `+notify`, `+weekly-report`, `+owner-digest`, `+contributor-digest --send` | -| 自定义机器人签名密钥 | `FEISHU_WEBHOOK_SECRET` | 是 | 自定义机器人安全设置,若开启签名 | 同上 | - -最小可验证: +The first validation used one test table with multiple views. The follow-up +validation created or reused five separate tables: ```text -只要有 FEISHU_WEBHOOK_URL,就可以先测试稳定消息卡片。 -如果机器人开启了签名,还必须填 FEISHU_WEBHOOK_SECRET。 +gitlink_reports +gitlink_issues +gitlink_prs +gitlink_contributors +gitlink_tasks ``` -## 2. 飞书开放平台自建应用 - -这些值用于 DocX、Wiki、多维表格、任务等实验性 OpenAPI 写入。 - -| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | -| --- | --- | --- | --- | --- | -| App ID | `FEISHU_APP_ID` | 是 | 飞书开放平台 -> 自建应用 -> 凭证与基础信息 | `+doc-export`, `+bitable-sync`, `+task-create --send` | -| App Secret | `FEISHU_APP_SECRET` | 是 | 同上 | 获取 `tenant_access_token` | - -需要确认: +Real split-table write result: ```text -1. 应用已经创建。 -2. 应用在测试企业内可用。 -3. 需要的 API 权限已经申请或开通。 -4. 目标文档、知识库、多维表格或任务空间已经给应用必要权限。 +reports: 1 record +issues: 5 records +prs: 2 records +contributors: 1 record +tasks: 7 records ``` -## 3. DocX / Wiki 验证目标 +Use the split-table text evidence for this upload because it demonstrates that +each supported table can receive records independently. -这些值用于把 GitLink workflow report 写入飞书云文档或知识库。 +## Test Permission Note -| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | -| --- | --- | --- | --- | --- | -| Wiki 页面 URL | `FEISHU_WIKI_URL` | 可能敏感 | 目标飞书知识库页面地址栏 | `+doc-export --wiki-url ... --send` | -| Wiki node token | `FEISHU_WIKI_NODE_TOKEN` | 是 | 可从 Wiki URL 解析,或 OpenAPI 返回 | `+doc-export` | -| 文件夹 token | `FEISHU_FOLDER_TOKEN` | 是 | 飞书云空间文件夹 URL | 创建新 DocX | -| 已有 DocX document ID | `FEISHU_DOCUMENT_ID` | 是 | DocX URL 或 OpenAPI 返回 | 追加已有 DocX | +The test enterprise used a self-built Feishu app with broad permissions so the +CLI could validate DocX, Base, and Task APIs end to end. This is only for local +validation. Production deployments should use least-privilege scopes and +resource-level access. -三选一即可开始: +## Next-Stage Capability Boundary + +These are intentionally not implemented in the current branch: ```text -方案 A:提供 FEISHU_WIKI_URL,让命令解析 Wiki node。 -方案 B:提供 FEISHU_FOLDER_TOKEN,让命令新建 DocX。 -方案 C:提供 FEISHU_DOCUMENT_ID,追加已有 DocX。 +Feishu Task project placement +Feishu Task section placement +Feishu Task assignees and followers +Feishu-side task dedupe/search +Bitable Base/table/field/view creation as a product command +Feishu card callback server +Feishu-triggered GitLink writes +GitLink issue comments from Feishu +GitLink PR reviews from Feishu +GitLink merge/close/member/webhook actions from Feishu ``` -必须人工处理: +## Manual Decisions ```text -gitlink-cli 不会替你修改飞书文档权限。 -你需要在飞书里给自建应用目标文档、知识库或文件夹的编辑权限。 +1. Decide later whether visual evidence is needed at all. +2. Decide whether experimental Open Platform writes should remain enabled in + the submitted branch or stay documented as validation-only. +3. Decide whether the next implementation stage should prioritize Task + assignees/followers or Bitable view/table automation. ``` -## 4. 多维表格 Base / Bitable - -这些值用于实验性真实同步记录。 - -| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | -| --- | --- | --- | --- | --- | -| Base app token | `FEISHU_BASE_APP_TOKEN` | 是 | 多维表格 URL 或开发者工具 API | `+bitable-sync --send` | -| reports 表 ID | `FEISHU_REPORT_TABLE_ID` | 是 | 多维表格表设置/API | 报告汇总行 | -| issues 表 ID | `FEISHU_ISSUE_TABLE_ID` | 是 | 同上 | Issue 汇总行 | -| prs 表 ID | `FEISHU_PR_TABLE_ID` | 是 | 同上 | PR 汇总行 | -| contributors 表 ID | `FEISHU_CONTRIBUTOR_TABLE_ID` | 是 | 同上 | 贡献者汇总行,可选 | -| tasks 表 ID | `FEISHU_TASK_TABLE_ID` | 是 | 同上 | 任务候选行,可选 | - -当前测试说明: +## Safety ```text -你提供的多维表格链接当前是同一个 Base 的同一张表,只是不同视图。 -为了验证 OpenAPI 写入,我把 reports/issues/prs/contributors/tasks 都指向了同一张测试表,并补齐了需要字段。 -这适合验证 search/create/update,但不是最终项目驾驶舱模型。 -``` - -正式模型建议: - -```text -1. 要么拆成 reports / issues / prs / contributors / tasks 多张表。 -2. 要么改成更强的行级统一模型,支持看板、甘特图、日历、画册、表单和仪表盘。 -3. 当前 CLI 不自动创建 Base、表、字段或视图。 -4. Kanban / Gantt / Calendar / Gallery / Dashboard 视图先建议人工配置。 -``` - -## 5. 飞书任务 - -这些值用于实验性创建飞书任务。 - -| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | -| --- | --- | --- | --- | --- | -| 任务项目 ID | `FEISHU_TASK_PROJECT_ID` | 是 | 飞书任务项目设置/API | 当前仅收集和脱敏输出 | -| 任务分组/section ID | `FEISHU_TASK_SECTION_ID` | 是 | 飞书任务项目设置/API | 当前仅收集和脱敏输出 | - -当前限制: - -```text -+task-create 真实请求目前只发送任务 summary 和 description。 -project / section 设置字段还没有接入请求体。 -已验证普通任务创建;后续再确认项目/分组字段。 -``` - -## 6. GitLink 真实仓库数据 - -这些值用于生成真实 workflow report。 - -| 需要收集 | 填入变量 | 是否敏感 | 获取位置 | 当前用途 | -| --- | --- | --- | --- | --- | -| 仓库 owner | `GITLINK_OWNER` | 否 | GitLink 仓库 URL | `workflow +repo-report` | -| 仓库名 | `GITLINK_REPO` | 否 | GitLink 仓库 URL | `workflow +repo-report` | -| 测试 PR IDs | `GITLINK_TEST_PR_IDS` | 否 | 之前 3 个 PR URL/编号 | 烟测报告记录 | -| GitLink Token | `GITLINK_TOKEN` | 是 | GitLink 账号设置/API token | 若本地未登录且需要远程读取 | - -示例,不要照抄: - -```powershell -$env:GITLINK_OWNER="OWNER" -$env:GITLINK_REPO="REPO" -$env:GITLINK_TEST_PR_IDS="1,2,3" -$env:GITLINK_TOKEN="REDACTED" -``` - -## 7. 仍需人工完成 - -```text -1. 从飞书群里截取 bot card、weekly report、owner digest、contributor digest。 -2. 从飞书多维表格里截取同步后的记录或视图。 -3. 从飞书 DocX 里截取追加后的报告内容。 -4. 从飞书任务里截取创建后的任务列表。 -5. 截图前确认没有暴露 app secret、webhook、token、table id、open_id 或 union_id。 -``` - -截图目标路径见: - -```text -docs/PR_VISUAL_GUIDE.md -``` - -## 8. 安全提醒 - -```text -不要把 app secret、webhook、token、table id、wiki token、folder token 发到公开聊天或提交到仓库。 -真实值只放在 .local/feishu-gitlink.env.ps1。 -如果需要继续真实验证,优先复用本地 env 文件,不要把值写进 docs、reports、README。 +Keep .local/feishu-gitlink.env.ps1 ignored. +Do not commit real Feishu or GitLink credentials. +Do not commit screenshots containing secrets or raw IDs. +Do not rerun task-create repeatedly unless duplicate test tasks are acceptable. ``` diff --git a/scripts/feishu-gitlink-screenshot-check.ps1 b/scripts/feishu-gitlink-screenshot-check.ps1 deleted file mode 100644 index 957b728..0000000 --- a/scripts/feishu-gitlink-screenshot-check.ps1 +++ /dev/null @@ -1,35 +0,0 @@ -$ErrorActionPreference = "Stop" -$RepoRoot = Split-Path -Parent $PSScriptRoot -$Expected = @( - "docs/images/feishu-bot-card.png", - "docs/images/feishu-weekly-report.png", - "docs/images/feishu-owner-digest.png", - "docs/images/feishu-contributor-digest.png", - "docs/images/feishu-bitable-preview.png", - "docs/images/feishu-bitable-sync.png", - "docs/images/feishu-docx-wiki.png", - "docs/images/feishu-task-create.png", - "docs/images/feishu-smoke-terminal.png", - "docs/images/feishu-env-redacted.png" -) - -$missing = @() -foreach ($relative in $Expected) { - $path = Join-Path $RepoRoot $relative - if (Test-Path $path) { - Write-Host "Found screenshot: $relative" - } else { - Write-Host "Missing screenshot: $relative" - Write-Host "Open Feishu or terminal and capture this screenshot manually." - $missing += $relative - } -} - -if ($missing.Count -gt 0) { - Write-Host "" - Write-Host "Missing screenshots: $($missing.Count)" - exit 1 -} - -Write-Host "All expected screenshots are present." -exit 0 diff --git a/scripts/feishu-gitlink-smoke.ps1 b/scripts/feishu-gitlink-smoke.ps1 index 80587a1..7604a38 100644 --- a/scripts/feishu-gitlink-smoke.ps1 +++ b/scripts/feishu-gitlink-smoke.ps1 @@ -245,15 +245,10 @@ function Write-SmokeReport { "", "This log file is ignored and should not be committed after real runs.", "", - "## Screenshot Checklist", + "## Image Evidence", "", - "Run:", - "", - '```powershell', - ".\scripts\feishu-gitlink-screenshot-check.ps1", - '```', - "", - "Do not fabricate screenshots. Capture missing images manually after real Feishu runs." + "Image files are intentionally not part of this smoke output.", + "Use the command results, permission matrix, and redacted terminal log as evidence for this round." ) $lines | Set-Content -LiteralPath $SmokeReport -Encoding utf8 Write-Host "Smoke report written: $SmokeReport" From d7812df1af49519f9eb84def218bd3d5a9fdf02f Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Sat, 27 Jun 2026 10:41:52 +0800 Subject: [PATCH 12/16] feat(feishu): add Open Platform readiness diagnostics --- .local/feishu-gitlink.env.example.ps1 | 1 + README.zh-CN.md | 11 + docs/FEISHU_CAPABILITY_LAYERS.md | 50 +++ docs/FEISHU_ENVIRONMENT.md | 28 +- docs/FEISHU_OPENAPI_INVENTORY.md | 31 +- ...EISHU_API_COLLECTION_CHECKLIST_20260626.md | 12 +- reports/FEISHU_LOCAL_TESTING_GUIDE.md | 65 ++- reports/FEISHU_PERMISSION_MATRIX.md | 4 + reports/FEISHU_SMOKE_20260626.md | 17 +- scripts/feishu-gitlink-env-check.ps1 | 2 +- scripts/feishu-gitlink-setup.ps1 | 4 +- scripts/feishu-gitlink-smoke.ps1 | 25 +- shortcuts/feishu/diagnostics.go | 394 ++++++++++++++++++ shortcuts/feishu/feishu.go | 87 ++++ shortcuts/feishu/feishu_test.go | 99 ++++- 15 files changed, 778 insertions(+), 52 deletions(-) create mode 100644 shortcuts/feishu/diagnostics.go diff --git a/.local/feishu-gitlink.env.example.ps1 b/.local/feishu-gitlink.env.example.ps1 index 61d98a5..202c7a9 100644 --- a/.local/feishu-gitlink.env.example.ps1 +++ b/.local/feishu-gitlink.env.example.ps1 @@ -10,6 +10,7 @@ $env:FEISHU_APP_SECRET="" $env:FEISHU_WIKI_URL="" $env:FEISHU_WIKI_NODE_TOKEN="" $env:FEISHU_FOLDER_TOKEN="" +$env:FEISHU_DOCUMENT_ID="" # Feishu Base / Bitable $env:FEISHU_BASE_APP_TOKEN="" diff --git a/README.zh-CN.md b/README.zh-CN.md index 8f66dd5..cc48e55 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -582,6 +582,17 @@ gitlink-cli feishu +owner-digest --from-workflow-json report.zh-CN.json --lang z gitlink-cli feishu +contributor-digest --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table ``` +#### 配置诊断层:先检查再写入 + +诊断命令用于降低飞书开放平台配置成本。默认只检查本地变量和目标配置;传 `--remote` 后会调用飞书只读/检查接口,例如获取 `tenant_access_token`、解析 Wiki node、搜索 Bitable sentinel `unique_key`。这些命令不会创建文档、不会写入多维表格、不会创建任务,也不会修改 GitLink。 + +```bash +gitlink-cli feishu +app-check --format table +gitlink-cli feishu +doc-check --remote --format table +gitlink-cli feishu +bitable-check --tables reports,issues,prs,tasks --remote --format table +gitlink-cli feishu +task-check --remote --format table +``` + #### 实验层:飞书开放平台写入 实验层使用飞书开放平台自建应用。当前已在测试企业中验证 DocX 追加、多维表格写入和飞书任务创建,但这部分不是零配置稳定能力。真实写入仍然必须显式传 `--send`,并且要求自建应用有对应 API scope 和目标资源权限。 diff --git a/docs/FEISHU_CAPABILITY_LAYERS.md b/docs/FEISHU_CAPABILITY_LAYERS.md index a264741..b43b89c 100644 --- a/docs/FEISHU_CAPABILITY_LAYERS.md +++ b/docs/FEISHU_CAPABILITY_LAYERS.md @@ -75,6 +75,52 @@ Unit and mock tests are implemented. Real custom bot sending can be tested when FEISHU_WEBHOOK_URL exists. ``` +## Layer 1.5: Configuration Diagnostics + +Status: stable read/check surface. + +Purpose: + +```text +Help users discover missing env vars, invalid tokens, missing table IDs, missing +unique_key fields, and next-stage Task limitations before running --send writes. +``` + +Implemented commands: + +```text +gitlink-cli feishu +app-check +gitlink-cli feishu +doc-check +gitlink-cli feishu +bitable-check +gitlink-cli feishu +task-check +``` + +Default behavior: + +```text +Local checks only. No Feishu OpenAPI call is made unless --remote is explicit. +``` + +Remote behavior: + +```text +--remote acquires tenant_access_token. ++doc-check --remote may resolve a Wiki node. ++bitable-check --remote searches a sentinel unique_key to verify table access +and the unique_key field. ++task-check --remote verifies app credentials only; it does not create tasks. +``` + +What it cannot do: + +```text +Create Feishu resources. +Modify Feishu resources. +Modify GitLink resources. +Guarantee DocX edit permission without an actual append. +Guarantee Feishu Task project/section placement. +``` + ## Layer 2: Experimental Open Platform Validation Status: experimental validation surface. @@ -120,6 +166,10 @@ FEISHU_TASK_SECTION_ID optional Implemented commands: ```text +gitlink-cli feishu +app-check +gitlink-cli feishu +doc-check +gitlink-cli feishu +bitable-check +gitlink-cli feishu +task-check gitlink-cli feishu +doc-export gitlink-cli feishu +bitable-sync gitlink-cli feishu +task-create diff --git a/docs/FEISHU_ENVIRONMENT.md b/docs/FEISHU_ENVIRONMENT.md index b04fe31..bfed7d0 100644 --- a/docs/FEISHU_ENVIRONMENT.md +++ b/docs/FEISHU_ENVIRONMENT.md @@ -42,8 +42,8 @@ $env:FEISHU_WEBHOOK_SECRET="REDACTED" | Name | Purpose | Required | Used by | Sensitive | How to obtain | | --- | --- | --- | --- | --- | --- | -| `FEISHU_APP_ID` | Self-built app ID | Required for Open Platform `--send` | `+doc-export`, `+bitable-sync`, `+task-create` | Yes | Feishu Open Platform app page | -| `FEISHU_APP_SECRET` | Self-built app secret | Required for Open Platform `--send` | same as above | Yes | Feishu Open Platform app credentials | +| `FEISHU_APP_ID` | Self-built app ID | Required for Open Platform `--send` or diagnostic `--remote` | `+app-check`, `+doc-check`, `+bitable-check`, `+task-check`, `+doc-export`, `+bitable-sync`, `+task-create` | Yes | Feishu Open Platform app page | +| `FEISHU_APP_SECRET` | Self-built app secret | Required for Open Platform `--send` or diagnostic `--remote` | same as above | Yes | Feishu Open Platform app credentials | Example: @@ -56,10 +56,10 @@ $env:FEISHU_APP_SECRET="REDACTED" | Name | Purpose | Required | Used by | Sensitive | How to obtain | | --- | --- | --- | --- | --- | --- | -| `FEISHU_WIKI_URL` | Existing Wiki page URL | Optional target | `+doc-export` | Can expose workspace/resource ID | Copy from Feishu Wiki | -| `FEISHU_WIKI_NODE_TOKEN` | Existing Wiki node token | Optional target | `+doc-export` | Yes | Parsed from Wiki URL or API | -| `FEISHU_FOLDER_TOKEN` | Folder token for creating a new DocX | Optional target | `+doc-export` | Yes | Feishu Drive folder URL / Open Platform docs | -| `FEISHU_DOCUMENT_ID` | Existing DocX document ID for append | Optional target | `+doc-export` | Yes | Existing Feishu DocX URL or Open Platform docs | +| `FEISHU_WIKI_URL` | Existing Wiki page URL | Optional target | `+doc-check`, `+doc-export` | Can expose workspace/resource ID | Copy from Feishu Wiki | +| `FEISHU_WIKI_NODE_TOKEN` | Existing Wiki node token | Optional target | `+doc-check`, `+doc-export` | Yes | Parsed from Wiki URL or API | +| `FEISHU_FOLDER_TOKEN` | Folder token for creating a new DocX | Optional target | `+doc-check`, `+doc-export` | Yes | Feishu Drive folder URL / Open Platform docs | +| `FEISHU_DOCUMENT_ID` | Existing DocX document ID for append | Optional target | `+doc-check`, `+doc-export` | Yes | Existing Feishu DocX URL or Open Platform docs | Legacy compatibility: @@ -87,12 +87,12 @@ go run . feishu +notify --from-workflow-json .local\report.zh-CN.json --lang zh- | Name | Purpose | Required | Used by | Sensitive | How to obtain | | --- | --- | --- | --- | --- | --- | -| `FEISHU_BASE_APP_TOKEN` | Base app token | Required for `+bitable-sync --send` | `+bitable-sync` | Yes | Feishu Base URL / Open Platform docs | -| `FEISHU_REPORT_TABLE_ID` | Reports table ID | Required when syncing `reports` | `+bitable-sync` | Yes | Base table settings / API | -| `FEISHU_ISSUE_TABLE_ID` | Issues table ID | Required when syncing `issues` | `+bitable-sync` | Yes | Base table settings / API | -| `FEISHU_PR_TABLE_ID` | Pull request table ID | Required when syncing `prs` | `+bitable-sync` | Yes | Base table settings / API | -| `FEISHU_CONTRIBUTOR_TABLE_ID` | Contributors table ID | Optional | `+bitable-sync` | Yes | Base table settings / API | -| `FEISHU_TASK_TABLE_ID` | Task-candidate table ID | Optional | `+bitable-sync` | Yes | Base table settings / API | +| `FEISHU_BASE_APP_TOKEN` | Base app token | Required for `+bitable-check` and `+bitable-sync --send` | `+bitable-check`, `+bitable-sync` | Yes | Feishu Base URL / Open Platform docs | +| `FEISHU_REPORT_TABLE_ID` | Reports table ID | Required when checking or syncing `reports` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API | +| `FEISHU_ISSUE_TABLE_ID` | Issues table ID | Required when checking or syncing `issues` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API | +| `FEISHU_PR_TABLE_ID` | Pull request table ID | Required when checking or syncing `prs` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API | +| `FEISHU_CONTRIBUTOR_TABLE_ID` | Contributors table ID | Optional unless selected | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API | +| `FEISHU_TASK_TABLE_ID` | Task-candidate table ID | Optional unless selected | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API | Example: @@ -109,8 +109,8 @@ $env:FEISHU_TASK_TABLE_ID="REDACTED" | Name | Purpose | Required | Used by | Sensitive | How to obtain | | --- | --- | --- | --- | --- | --- | -| `FEISHU_TASK_PROJECT_ID` | Optional task project target | Optional | `+task-create` | Yes | Feishu Task project settings / API | -| `FEISHU_TASK_SECTION_ID` | Optional task section target | Optional | `+task-create` | Yes | Feishu Task section settings / API | +| `FEISHU_TASK_PROJECT_ID` | Optional task project target | Optional | `+task-check`, `+task-create` | Yes | Feishu Task project settings / API | +| `FEISHU_TASK_SECTION_ID` | Optional task section target | Optional | `+task-check`, `+task-create` | Yes | Feishu Task section settings / API | Current limitation: diff --git a/docs/FEISHU_OPENAPI_INVENTORY.md b/docs/FEISHU_OPENAPI_INVENTORY.md index 1071a74..d1c8c2e 100644 --- a/docs/FEISHU_OPENAPI_INVENTORY.md +++ b/docs/FEISHU_OPENAPI_INVENTORY.md @@ -56,11 +56,11 @@ https://www.feishu.cn/feishu-cli | --- | --- | --- | --- | --- | --- | | Custom bot webhook | `POST /open-apis/bot/v2/hook/{token}` | `+bot-test`, `+notify`, `+weekly-report`, `+owner-digest`, `+contributor-digest` | Implemented stable | Feishu chat message | Requires `--send`; preview by default | | Custom bot signature | timestamp + HMAC-SHA256 signing secret | same as above | Implemented stable | Request signature only | `FEISHU_WEBHOOK_SECRET` optional | -| Tenant token | `POST /auth/v3/tenant_access_token/internal` | `+doc-export`, `+bitable-sync`, `+task-create` | Implemented experimental | Tenant token | No token cache yet | -| Wiki node resolution | `GET /wiki/v2/spaces/get_node?token=...` | `+doc-export` | Implemented experimental | Wiki metadata read | Used to resolve Wiki node to DocX object token | +| Tenant token | `POST /auth/v3/tenant_access_token/internal` | `+app-check --remote`, `+doc-check --remote`, `+bitable-check --remote`, `+task-check --remote`, `+doc-export`, `+bitable-sync`, `+task-create` | Implemented | Tenant token | No token cache yet | +| Wiki node resolution | `GET /wiki/v2/spaces/get_node?token=...` | `+doc-check --remote`, `+doc-export` | Implemented | Wiki metadata read | Used to resolve Wiki node to DocX object token | | DocX create | `POST /docx/v1/documents` | `+doc-export` | Implemented experimental | New DocX document | Requires folder/resource permission | | DocX append blocks | `POST /docx/v1/documents/{document_id}/blocks/{block_id}/children` | `+doc-export` | Implemented experimental | DocX block tree | Real write can fail on scope or document permission | -| Bitable search | `POST /bitable/v1/apps/{app_token}/tables/{table_id}/records/search` | `+bitable-sync` | Implemented experimental | Existing Base table | Searches by `unique_key` field | +| Bitable search | `POST /bitable/v1/apps/{app_token}/tables/{table_id}/records/search` | `+bitable-check --remote`, `+bitable-sync` | Implemented | Existing Base table | `+bitable-check` searches a sentinel key without writing | | Bitable create record | `POST /bitable/v1/apps/{app_token}/tables/{table_id}/records` | `+bitable-sync` | Implemented experimental | Existing Base table | No table/field/view creation | | Bitable update record | `PUT /bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}` | `+bitable-sync` | Implemented experimental | Existing Base table | Never deletes records | | Task create | `POST /task/v2/tasks` | `+task-create` | Implemented experimental | Feishu task | Project/section placement is not mapped into request body yet | @@ -137,6 +137,10 @@ Current commands: +doc-export +bitable-sync +task-create ++app-check ++doc-check ++bitable-check ++task-check ``` Inputs: @@ -157,7 +161,8 @@ Does not print the raw token. Next hardening: ```text -Add +app-check. +`+app-check` now exists. Add richer scope-name hints once official scope names +are mapped in the code. Cache token in memory during one command execution only. Add scope diagnostics where official scope names are confirmed. ``` @@ -220,7 +225,8 @@ must be handled by the owner/admin outside the CLI. Next hardening: ```text -Add +app-check diagnostics for DocX/Wiki scopes. +`+doc-check` now validates DocX/Wiki target configuration and can resolve Wiki +nodes with --remote. Add clearer output for target type: wiki node, existing doc, folder creation. Add optional markdown-only export for manual paste into Feishu Docs. ``` @@ -240,6 +246,7 @@ Current commands: ```text +bitable-schema +bitable-records ++bitable-check +bitable-sync ``` @@ -272,6 +279,9 @@ Current behavior: ```text +bitable-schema outputs a dry-run schema. +bitable-records outputs summary-oriented local records. ++bitable-check validates configured table IDs and expected fields. ++bitable-check --remote searches a sentinel unique_key to verify table access +and the unique_key field without writing records. +bitable-sync previews by default. +bitable-sync --send searches by unique_key, updates if found, creates if missing. If search fails, the command falls back to create-only for that record. @@ -317,7 +327,8 @@ Current records are summary buckets, not full row-level PR/Issue/CI records. Next hardening: ```text -Add table/field validation before writes. +`+bitable-check` now provides pre-write table ID and unique_key search +diagnostics. Field type validation still depends on richer Feishu field metadata. Add row-level records for PRs, Issues, CI runs, milestones, releases, and audits. Add optional Bitable view planning output for Kanban, Gantt, Calendar, Gallery, Form, and Dashboard. Keep real view creation as a separate permissioned task. @@ -335,6 +346,7 @@ Current commands: ```text +task-preview ++task-check +task-create ``` @@ -352,6 +364,8 @@ Current behavior: ```text +task-preview generates local task candidates. ++task-check validates app credentials and documents current project/section and +dedupe limitations without creating tasks. +task-create previews by default and creates tasks only with --send. Task candidates are derived from workflow recommendations, high-risk issues, missing-info issues, high-risk PRs, and review-focus items. @@ -386,7 +400,9 @@ Next hardening: ```text Confirm official Task project/section placement fields. Add Feishu-side dedupe or external unique_key linking when a stable API path exists. -Add scope diagnostics through +app-check. +`+task-check --remote` validates app credentials without creating tasks. Add +scope diagnostics and project/section request mapping after the official fields +are confirmed. ``` ## i18n Validation @@ -589,6 +605,7 @@ GitLink real data validation: [x] DocX create and block append APIs identified and implemented. [x] Bitable record search/create/update APIs identified and implemented. [x] Task create API identified and implemented at minimal summary/description level. +[x] Read/check diagnostics implemented for app, DocX/Wiki, Bitable, and Task setup. [x] IM app bot send API identified as planned, not implemented. [x] Card callback/event subscription identified as future, not implemented. [x] User identity APIs identified as future, not implemented. diff --git a/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md b/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md index 0063c3a..92098ec 100644 --- a/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md +++ b/reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md @@ -25,6 +25,7 @@ Real DocX append: passed through self-built app OpenAPI. Real Bitable sync: passed after target table fields were created. Split Bitable sync: passed with five separate tables. Real Task create: passed; project/section placement remains unmapped. +Read/check diagnostics: passed for app, DocX target, Bitable tables, and Task credentials. GitLink write operations: not implemented and not tested. Test enterprise permissions: intentionally broad for validation; production should use minimum scopes. ``` @@ -35,7 +36,7 @@ Test enterprise permissions: intentionally broad for validation; production shou | --- | --- | --- | --- | | Custom bot webhook | Complete and real-tested | `shortcuts/feishu/client.go`, `sign.go`, `card.go` | Image evidence deferred | | Custom bot signing | Complete and real-tested | `SignCustomBotRequest` unit test plus signed bot smoke | Keep secrets redacted | -| tenant_access_token | Complete and real-tested | `OpenAPIClient.TenantAccessToken` | Add future `+app-check` | +| tenant_access_token | Complete and real-tested | `OpenAPIClient.TenantAccessToken`, `+app-check --remote` | Add richer scope hints later | | Wiki node resolution | Complete | `OpenAPIClient.GetWikiNode` | Still depends on target Wiki node permission | | DocX create | Complete | `OpenAPIClient.CreateDocument` | Requires folder permission when creating new docs | | DocX block append | Complete and real-tested | `OpenAPIClient.CreateBlocks` | App must have target DocX edit permission | @@ -57,6 +58,10 @@ Test enterprise permissions: intentionally broad for validation; production shou | `feishu +weekly-report` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` | | `feishu +owner-digest` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` | | `feishu +contributor-digest` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` | +| `feishu +app-check` | Stable diagnostics | Implemented | No writes; optional read/check with `--remote` | App credentials for remote token check | +| `feishu +doc-check` | Stable diagnostics | Implemented | No writes; optional Wiki read with `--remote` | App credentials and DocX/Wiki target | +| `feishu +bitable-check` | Stable diagnostics | Implemented | No writes; optional Bitable search with `--remote` | Base app token, table IDs, `unique_key` | +| `feishu +task-check` | Stable diagnostics | Implemented | No writes; optional token check with `--remote` | App credentials | | `feishu +bitable-schema` | Stable dry-run | Implemented | No | No | | `feishu +bitable-records` | Stable dry-run | Implemented | No | No | | `feishu +task-preview` | Stable dry-run | Implemented | No | No | @@ -75,6 +80,7 @@ Test enterprise permissions: intentionally broad for validation; production shou 6. User-required environment variables are documented. 7. Resource-level permission requirements are documented. 8. Task project/section limitation is explicitly called out. +9. Feishu configuration diagnostics are available before running --send writes. ``` ## What Still Needs User Action @@ -157,6 +163,10 @@ Executed on 2026-06-26 after the API inventory update: | `+weekly-report --send` | Pass | Custom bot delivered weekly report | | `+owner-digest --send` | Pass | Custom bot delivered English/default and Chinese owner digest | | `+contributor-digest --send` | Pass | Custom bot delivered English/default and Chinese contributor digest | +| `+app-check --remote` | Pass | Custom bot, app credentials, and tenant_access_token validated with redacted output | +| `+doc-check --remote` | Pass | App credentials and DocX/folder targets checked; write permission not probed without appending | +| `+bitable-check --remote` | Pass | Five split tables passed sentinel `unique_key` search without record writes | +| `+task-check --remote` | Pass with warnings | Tenant token passed; project/section/dedupe remain next-stage boundaries | | `+bitable-sync` preview | Pass | 1 report, 5 issue, 2 PR, 1 contributor, 7 task records | | `+bitable-sync --send` | Pass | Search/create/update real-tested after field creation | | Split-table `+bitable-sync --send` | Pass | Created 1 report, 5 issue, 2 PR, 1 contributor, and 7 task records across five separate Bitable tables | diff --git a/reports/FEISHU_LOCAL_TESTING_GUIDE.md b/reports/FEISHU_LOCAL_TESTING_GUIDE.md index f5c1240..40d3131 100644 --- a/reports/FEISHU_LOCAL_TESTING_GUIDE.md +++ b/reports/FEISHU_LOCAL_TESTING_GUIDE.md @@ -45,7 +45,30 @@ This file is ignored and must not be committed. A tracked empty example is avail The checker prints only redacted values. -## 3. Configure GitLink Test Repository Manually If Needed +## 3. Run Feishu CLI Diagnostics + +Local diagnostics: + +```bash +gitlink-cli feishu +app-check --format table +gitlink-cli feishu +doc-check --format table +gitlink-cli feishu +bitable-check --tables reports,issues,prs,contributors,tasks --format table +gitlink-cli feishu +task-check --format table +``` + +Optional remote diagnostics: + +```bash +gitlink-cli feishu +app-check --remote --format table +gitlink-cli feishu +doc-check --remote --format table +gitlink-cli feishu +bitable-check --tables reports,issues,prs,contributors,tasks --remote --format table +gitlink-cli feishu +task-check --remote --format table +``` + +Remote diagnostics call only read/check endpoints. They do not create DocX +blocks, Bitable records, Feishu tasks, or GitLink writes. + +## 4. Configure GitLink Test Repository Manually If Needed ```powershell $env:GITLINK_OWNER="OWNER" @@ -58,7 +81,7 @@ If the current workflow command cannot filter specific PR IDs, keep the PR IDs i $env:GITLINK_TEST_PR_IDS="1,2,3" ``` -## 4. Run Scripted Smoke Tests +## 5. Run Scripted Smoke Tests Preview only: @@ -94,7 +117,7 @@ reports/feishu-real-smoke-terminal.log The terminal log is ignored and must not be committed after real runs. -## 5. Generate Workflow Report JSON Manually +## 6. Generate Workflow Report JSON Manually ```bash gitlink-cli workflow +repo-report \ @@ -105,13 +128,13 @@ gitlink-cli workflow +repo-report \ Windows PowerShell redirection may produce UTF-16 with BOM. The Feishu workflow JSON reader supports UTF-8 and UTF-16 BOM inputs. -## 6. Preview Feishu Notify Card +## 7. Preview Feishu Notify Card ```bash gitlink-cli feishu +notify --from-workflow-json report.json --format json ``` -## 7. Send Feishu Notify Card +## 8. Send Feishu Notify Card ```bash gitlink-cli feishu +notify --from-workflow-json report.json --send --format table @@ -124,50 +147,50 @@ FEISHU_WEBHOOK_URL FEISHU_WEBHOOK_SECRET optional ``` -## 8. Render Weekly Report +## 9. Render Weekly Report ```bash gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown ``` -## 9. Send Weekly Report +## 10. Send Weekly Report ```bash gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table ``` -## 10. Generate Owner Digest +## 11. Generate Owner Digest ```bash gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown ``` -## 11. Send Owner Digest +## 12. Send Owner Digest ```bash gitlink-cli feishu +owner-digest --from-workflow-json report.json --send --format table ``` -## 12. Generate Contributor Digest +## 13. Generate Contributor Digest ```bash gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown ``` -## 13. Send Contributor Digest +## 14. Send Contributor Digest ```bash gitlink-cli feishu +contributor-digest --from-workflow-json report.json --send --format table ``` -## 14. Generate Bitable-Ready Records +## 15. Generate Bitable-Ready Records ```bash gitlink-cli feishu +bitable-schema --tables reports,issues,prs,contributors,tasks --format markdown gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json ``` -## 15. Preview Bitable Sync +## 16. Preview Bitable Sync ```bash gitlink-cli feishu +bitable-sync \ @@ -176,7 +199,7 @@ gitlink-cli feishu +bitable-sync \ --format table ``` -## 16. Execute Bitable Sync +## 17. Execute Bitable Sync ```bash gitlink-cli feishu +bitable-sync \ @@ -199,7 +222,7 @@ FEISHU_CONTRIBUTOR_TABLE_ID optional FEISHU_TASK_TABLE_ID optional ``` -## 17. Preview DocX / Wiki Export +## 18. Preview DocX / Wiki Export ```bash gitlink-cli feishu +doc-export \ @@ -208,7 +231,7 @@ gitlink-cli feishu +doc-export \ --format markdown ``` -## 18. Execute DocX / Wiki Export +## 19. Execute DocX / Wiki Export ```bash gitlink-cli feishu +doc-export \ @@ -218,13 +241,13 @@ gitlink-cli feishu +doc-export \ --format table ``` -## 19. Preview Feishu Tasks +## 20. Preview Feishu Tasks ```bash gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown ``` -## 20. Create Feishu Tasks +## 21. Create Feishu Tasks ```bash gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table @@ -239,12 +262,12 @@ FEISHU_TASK_PROJECT_ID optional FEISHU_TASK_SECTION_ID optional ``` -## 21. Image Evidence +## 22. Image Evidence Image evidence is deferred for this round. Do not add screenshots or image files to the upload. -## 22. Run Go Tests +## 23. Run Go Tests ```bash gofmt -w shortcuts/feishu @@ -254,7 +277,7 @@ go test ./shortcuts go test ./... ``` -## 23. Capture Evidence +## 24. Capture Evidence Capture terminal logs and command output only. diff --git a/reports/FEISHU_PERMISSION_MATRIX.md b/reports/FEISHU_PERMISSION_MATRIX.md index 4642ec7..5e0c7a8 100644 --- a/reports/FEISHU_PERMISSION_MATRIX.md +++ b/reports/FEISHU_PERMISSION_MATRIX.md @@ -11,6 +11,10 @@ GitLink write permission is `No` for every implemented command in this branch. | Weekly report | `feishu +weekly-report` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | preview and real send passed | Card is summary-level | | Owner digest | `feishu +owner-digest` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit, preview, and real send passed, including zh-CN | Role-oriented, not personalized | | Contributor digest | `feishu +contributor-digest` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit, preview, and real send passed, including zh-CN | Role-oriented, not open_id routed | +| App diagnostics | `feishu +app-check` | Stable diagnostics | No | Yes for `--remote` | No | No | No | No | No | Yes | unit, mock remote, and real remote passed | Remote mode only gets tenant_access_token; no writes | +| Doc diagnostics | `feishu +doc-check` | Stable diagnostics | No | Yes for `--remote` | Yes for Wiki node read | No | No | No | No | Yes | local and real remote diagnostics passed | Does not prove edit permission without append | +| Bitable diagnostics | `feishu +bitable-check` | Stable diagnostics | No | Yes for `--remote` | No | Yes for remote search | No | No | No | Yes | unit, mock remote, and five-table real remote passed | Checks table access and unique_key search; does not create fields | +| Task diagnostics | `feishu +task-check` | Stable diagnostics | No | Yes for `--remote` | No | No | No; token check only | No | No | Yes | mock remote and real remote passed with expected warnings | Does not create tasks; project/section still next-stage | | Bitable schema | `feishu +bitable-schema` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed | Does not create tables or views | | Bitable records | `feishu +bitable-records` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed | Summary records, not one row per raw issue/PR | | Task preview | `feishu +task-preview` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed, including zh-CN | Local candidates only | diff --git a/reports/FEISHU_SMOKE_20260626.md b/reports/FEISHU_SMOKE_20260626.md index 6dedeea..bc47d3a 100644 --- a/reports/FEISHU_SMOKE_20260626.md +++ b/reports/FEISHU_SMOKE_20260626.md @@ -104,6 +104,10 @@ The workflow command does not currently filter the report by explicit PR IDs, so | `feishu +notify --lang zh-CN --send` | pass | Chinese workflow card delivered | | `feishu +owner-digest --lang zh-CN --send` | pass | Chinese owner digest delivered | | `feishu +contributor-digest --lang zh-CN --send` | pass | Chinese contributor digest delivered | +| `feishu +app-check --remote` | pass | custom bot, app credentials, and tenant_access_token checked with redacted output | +| `feishu +doc-check --remote` | pass | app credentials and configured DocX/folder targets checked; edit/create permission intentionally not checked without writing | +| `feishu +bitable-check --remote` | pass | five split Bitable tables passed sentinel `unique_key` search without writing records | +| `feishu +task-check --remote` | pass with warnings | app credentials and tenant_access_token checked; project/section/dedupe remain next-stage boundaries | | `feishu +doc-export --send` | pass | appended 9 DocX blocks to the configured document | | `feishu +doc-export --lang zh-CN --send` | pass | appended 9 localized DocX blocks | | `feishu +bitable-sync --tables reports --send` | pass after table fields were added | created the report record | @@ -200,6 +204,8 @@ module. It was not fixed in this smoke run to avoid unrelated locale churn. | `go test ./shortcuts/workflow` | pass | | `go test ./shortcuts` | pass | | `go test ./...` | pass | +| `go build .` | pass | +| `go vet ./...` | pass | | Raw secret scan over tracked/unignored candidate files | pass | ## Known Limitations @@ -208,11 +214,12 @@ module. It was not fixed in this smoke run to avoid unrelated locale churn. 1. Bitable sync requires existing Base/table/fields; CLI does not create tables or views. 2. The first smoke used one test table for all record groups; a later smoke created split tables and proved every record group can write to its own table. 3. Current Bitable records are summary buckets, not row-level PR/Issue/CI records. -4. Feishu task creation does not yet map project/section placement into the request body; this is a next-stage capability boundary. -5. Feishu-side task dedupe/search is not implemented; avoid repeated real task-create runs unless duplicates are acceptable. -6. No Feishu callback server is implemented. -7. No GitLink write operation is implemented. -8. Image evidence is deferred and is not part of this upload. +4. `+bitable-check --remote` can verify table access and unique_key search before writes, but it still does not create fields or validate every field type. +5. Feishu task creation does not yet map project/section placement into the request body; this is a next-stage capability boundary. +6. Feishu-side task dedupe/search is not implemented; avoid repeated real task-create runs unless duplicates are acceptable. +7. No Feishu callback server is implemented. +8. No GitLink write operation is implemented. +9. Image evidence is deferred and is not part of this upload. ``` ## Image Evidence diff --git a/scripts/feishu-gitlink-env-check.ps1 b/scripts/feishu-gitlink-env-check.ps1 index e1b0f5f..45f94a6 100644 --- a/scripts/feishu-gitlink-env-check.ps1 +++ b/scripts/feishu-gitlink-env-check.ps1 @@ -68,7 +68,7 @@ if ($Layer -eq "stable" -or $Layer -eq "all") { if ($Layer -eq "open-platform" -or $Layer -eq "all") { $allMissing += Test-Group -Title "Open Platform app" -Names @("FEISHU_APP_ID", "FEISHU_APP_SECRET") - $allMissing += Test-Group -Title "DocX / Wiki" -Names @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN") -Optional @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN") + $allMissing += Test-Group -Title "DocX / Wiki" -Names @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN", "FEISHU_DOCUMENT_ID") -Optional @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN", "FEISHU_DOCUMENT_ID") $allMissing += Test-Group -Title "Base / Bitable" -Names @("FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID", "FEISHU_CONTRIBUTOR_TABLE_ID", "FEISHU_TASK_TABLE_ID") -Optional @("FEISHU_CONTRIBUTOR_TABLE_ID", "FEISHU_TASK_TABLE_ID") $allMissing += Test-Group -Title "Feishu Task" -Names @("FEISHU_TASK_PROJECT_ID", "FEISHU_TASK_SECTION_ID") -Optional @("FEISHU_TASK_PROJECT_ID", "FEISHU_TASK_SECTION_ID") } diff --git a/scripts/feishu-gitlink-setup.ps1 b/scripts/feishu-gitlink-setup.ps1 index 476762a..fb331e9 100644 --- a/scripts/feishu-gitlink-setup.ps1 +++ b/scripts/feishu-gitlink-setup.ps1 @@ -112,12 +112,13 @@ if (Missing @("FEISHU_APP_ID", "FEISHU_APP_SECRET")) { $env:FEISHU_APP_SECRET = Read-SecretValue "FEISHU_APP_SECRET" (Env "FEISHU_APP_SECRET") "Self-built app secret." } -if (Missing @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN")) { +if (Missing @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN", "FEISHU_DOCUMENT_ID")) { Open-Url "https://www.feishu.cn/" "Feishu Docs / Wiki" Pause-User "Open your target Wiki/Doc page or folder and copy the URL/token. Press Enter when ready." $env:FEISHU_WIKI_URL = Read-OptionalValue "FEISHU_WIKI_URL" (Env "FEISHU_WIKI_URL") "Existing Wiki or Doc URL for +doc-export." $env:FEISHU_WIKI_NODE_TOKEN = Read-OptionalValue "FEISHU_WIKI_NODE_TOKEN" (Env "FEISHU_WIKI_NODE_TOKEN") "Optional. Usually parsed from FEISHU_WIKI_URL." $env:FEISHU_FOLDER_TOKEN = Read-OptionalValue "FEISHU_FOLDER_TOKEN" (Env "FEISHU_FOLDER_TOKEN") "Optional. Used when creating a new DocX in a folder." + $env:FEISHU_DOCUMENT_ID = Read-OptionalValue "FEISHU_DOCUMENT_ID" (Env "FEISHU_DOCUMENT_ID") "Optional. Existing DocX document ID for append." } if (Missing @("FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID")) { @@ -165,6 +166,7 @@ $vars = @( "FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN", + "FEISHU_DOCUMENT_ID", "FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", diff --git a/scripts/feishu-gitlink-smoke.ps1 b/scripts/feishu-gitlink-smoke.ps1 index 7604a38..48bc361 100644 --- a/scripts/feishu-gitlink-smoke.ps1 +++ b/scripts/feishu-gitlink-smoke.ps1 @@ -269,8 +269,12 @@ try { $helpCommands = @( "+owner-digest", "+contributor-digest", + "+app-check", + "+doc-check", + "+bitable-check", "+bitable-sync", "+task-preview", + "+task-check", "+task-create" ) Invoke-Cmd "feishu help" @("go", "run", ".", "feishu", "--help") $true @@ -306,7 +310,26 @@ try { } if ($runOpenPlatform) { - if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET") -and (Has-Env @("FEISHU_WIKI_URL") -or Has-Env @("FEISHU_WIKI_NODE_TOKEN") -or Has-Env @("FEISHU_FOLDER_TOKEN"))) { + if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET")) { + Invoke-Cmd "app-check remote" @("go", "run", ".", "feishu", "+app-check", "--remote", "--format", "table") $false + Invoke-Cmd "task-check remote" @("go", "run", ".", "feishu", "+task-check", "--remote", "--format", "table") $false + } else { + Add-Skip "app/task diagnostics" "missing FEISHU_APP_ID/FEISHU_APP_SECRET" + } + + if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET") -and (Has-Env @("FEISHU_WIKI_URL") -or Has-Env @("FEISHU_WIKI_NODE_TOKEN") -or Has-Env @("FEISHU_FOLDER_TOKEN") -or Has-Env @("FEISHU_DOCUMENT_ID"))) { + Invoke-Cmd "doc-check remote" @("go", "run", ".", "feishu", "+doc-check", "--remote", "--format", "table") $false + } else { + Add-Skip "doc diagnostics" "missing FEISHU_APP_ID/FEISHU_APP_SECRET or DocX/Wiki target" + } + + if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID")) { + Invoke-Cmd "bitable-check remote" @("go", "run", ".", "feishu", "+bitable-check", "--tables", "reports,issues,prs,contributors,tasks", "--remote", "--format", "table") $false + } else { + Add-Skip "bitable diagnostics" "missing Feishu app credentials, base app token, or required table IDs" + } + + if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET") -and (Has-Env @("FEISHU_WIKI_URL") -or Has-Env @("FEISHU_WIKI_NODE_TOKEN") -or Has-Env @("FEISHU_FOLDER_TOKEN") -or Has-Env @("FEISHU_DOCUMENT_ID"))) { Invoke-Cmd "doc-export send" @("go", "run", ".", "feishu", "+doc-export", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false } else { Add-Skip "doc-export send" "missing FEISHU_APP_ID/FEISHU_APP_SECRET or DocX/Wiki target" diff --git a/shortcuts/feishu/diagnostics.go b/shortcuts/feishu/diagnostics.go new file mode 100644 index 0000000..541dd3a --- /dev/null +++ b/shortcuts/feishu/diagnostics.go @@ -0,0 +1,394 @@ +package feishu + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type DiagnosticOutput struct { + Mode string `json:"mode"` + Remote bool `json:"remote"` + Layer string `json:"layer"` + Summary DiagnosticSummary `json:"summary"` + Checks []DiagnosticCheck `json:"checks"` + Warnings []string `json:"warnings,omitempty"` +} + +type DiagnosticSummary struct { + Passed int `json:"passed"` + Warned int `json:"warned"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` +} + +type DiagnosticCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Required bool `json:"required"` + Target string `json:"target,omitempty"` + Value string `json:"value,omitempty"` + Detail string `json:"detail,omitempty"` + Hint string `json:"hint,omitempty"` +} + +func runFeishuAppCheck(ctx *common.RuntimeContext) error { + output := DiagnosticOutput{ + Mode: "check", + Remote: parseBool(ctx.Arg("remote")), + Layer: "app", + Warnings: []string{ + "Diagnostic commands never write Feishu resources or GitLink resources.", + "Use --remote to call Feishu OpenAPI read/check endpoints.", + }, + } + webhookURL := firstNonEmpty(ctx.Arg("webhook-url"), os.Getenv("FEISHU_WEBHOOK_URL")) + webhookSecret := firstNonEmpty(ctx.Arg("secret"), os.Getenv("FEISHU_WEBHOOK_SECRET")) + appID := firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")) + appSecret := firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET")) + + if strings.TrimSpace(webhookURL) == "" { + output.addCheck(warnCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", "missing", "stable webhook send commands need FEISHU_WEBHOOK_URL")) + } else if err := validateWebhookURL(webhookURL); err != nil { + output.addCheck(failCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", redactWebhookURL(webhookURL), err.Error(), "copy the full custom bot webhook URL from the Feishu group bot settings")) + } else { + output.addCheck(passCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", redactWebhookURL(webhookURL), "configured")) + } + if strings.TrimSpace(webhookSecret) == "" { + output.addCheck(warnCheck("custom bot signing secret", "FEISHU_WEBHOOK_SECRET", "missing", "only required when the custom bot enables signature verification")) + } else { + output.addCheck(passCheck("custom bot signing secret", "FEISHU_WEBHOOK_SECRET", redactToken(webhookSecret), "configured")) + } + output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", appID, "required for DocX, Bitable, and Task OpenAPI validation")) + output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", appSecret, "required for tenant_access_token")) + if output.Remote && output.Summary.Failed == 0 { + output.remoteTenantToken(appID, appSecret) + } else if output.Remote { + output.addCheck(skipCheck("tenant_access_token", "Feishu OpenAPI", "skipped because app credentials are incomplete")) + } + return finishDiagnostic(ctx, output) +} + +func runFeishuDocCheck(ctx *common.RuntimeContext) error { + opts := DocExportOptions{ + AppID: firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")), + AppSecret: firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET")), + FolderToken: firstNonEmpty(ctx.Arg("folder-token"), os.Getenv("FEISHU_FOLDER_TOKEN"), os.Getenv("FEISHU_DOC_FOLDER_TOKEN")), + DocumentID: firstNonEmpty(ctx.Arg("document-id"), os.Getenv("FEISHU_DOCUMENT_ID")), + WikiURL: firstNonEmpty(ctx.Arg("wiki-url"), os.Getenv("FEISHU_WIKI_URL")), + WikiNodeToken: firstNonEmpty(ctx.Arg("wiki-node-token"), os.Getenv("FEISHU_WIKI_NODE_TOKEN")), + } + if opts.WikiNodeToken == "" && opts.WikiURL != "" { + opts.WikiNodeToken = wikiNodeTokenFromURL(opts.WikiURL) + } + if opts.DocumentID == "" && opts.WikiURL != "" { + opts.DocumentID = docxTokenFromURL(opts.WikiURL) + } + output := DiagnosticOutput{ + Mode: "check", + Remote: parseBool(ctx.Arg("remote")), + Layer: "docx", + Warnings: []string{ + "Doc check does not append blocks or create documents.", + "Actual DocX/Wiki writes still require --send on +doc-export.", + }, + } + output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for DocX/Wiki OpenAPI")) + output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token")) + + targets := 0 + if opts.DocumentID != "" { + targets++ + output.addCheck(passCheck("existing document target", "FEISHU_DOCUMENT_ID", redactToken(opts.DocumentID), "configured for append path")) + } + if opts.WikiNodeToken != "" { + targets++ + output.addCheck(passCheck("wiki node target", "FEISHU_WIKI_NODE_TOKEN", redactToken(opts.WikiNodeToken), "configured or parsed from wiki URL")) + } + if opts.FolderToken != "" { + targets++ + output.addCheck(passCheck("folder target", "FEISHU_FOLDER_TOKEN", redactToken(opts.FolderToken), "configured for create path")) + } + if opts.WikiURL != "" { + output.addCheck(passCheck("wiki url", "FEISHU_WIKI_URL", redactResourceURL(opts.WikiURL), "configured")) + } + if targets == 0 { + output.addCheck(failCheck("docx target", "DocX/Wiki", "missing", "no document, wiki, or folder target configured", "set FEISHU_DOCUMENT_ID, FEISHU_WIKI_URL, FEISHU_WIKI_NODE_TOKEN, or FEISHU_FOLDER_TOKEN")) + } + if output.Remote && output.Summary.Failed == 0 { + token, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret) + if ok && opts.WikiNodeToken != "" { + output.remoteWikiNode(token.Value, opts.WikiNodeToken) + } + if ok && opts.WikiNodeToken == "" { + output.addCheck(skipCheck("wiki node read", "Feishu Wiki", "skipped because no wiki node token was configured")) + } + if ok && opts.DocumentID != "" { + output.addCheck(skipCheck("document edit permission", "Feishu DocX", "not checked without writing blocks")) + } + if ok && opts.FolderToken != "" { + output.addCheck(skipCheck("folder create permission", "Feishu Drive", "not checked without creating a document")) + } + } else if output.Remote { + output.addCheck(skipCheck("remote docx check", "Feishu OpenAPI", "skipped because required config is incomplete")) + } + return finishDiagnostic(ctx, output) +} + +func runFeishuBitableCheck(ctx *common.RuntimeContext) error { + opts, err := bitableSyncOptionsFromContext(ctx) + if err != nil { + return err + } + output := DiagnosticOutput{ + Mode: "check", + Remote: parseBool(ctx.Arg("remote")), + Layer: "bitable", + Warnings: []string{ + "Bitable check does not create, update, or delete records.", + "Remote mode searches a sentinel unique_key to verify table access and the unique_key field.", + }, + } + output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for Base/Bitable OpenAPI")) + output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token")) + output.addCheck(requiredSecretCheck("base app token", "FEISHU_BASE_APP_TOKEN", opts.BaseAppToken, "required for Bitable tables")) + + for _, table := range opts.Tables { + envName := tableEnvName(table) + tableID := opts.TableIDs[table] + output.addCheck(requiredSecretCheck(table+" table id", envName, tableID, "required for "+table+" sync")) + fields := schemaForTable(table).Fields + output.addCheck(passCheck(table+" expected fields", table, strings.Join(fieldNames(fields), ","), "schema expected by +bitable-sync")) + } + if output.Remote && output.Summary.Failed == 0 { + token, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret) + if ok { + client := NewOpenAPIClient(http.DefaultClient) + checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for _, table := range opts.Tables { + _, err := client.SearchBitableRecord(checkCtx, token.Value, opts.BaseAppToken, opts.TableIDs[table], "__gitlink_cli_check__") + if err != nil { + output.addCheck(failCheck("remote bitable table search", table, redactToken(opts.TableIDs[table]), sanitizeDiagnostic(diagnoseOpenAPIError(err, "bitable", table), opts.AppID, opts.AppSecret, opts.BaseAppToken, opts.TableIDs[table]), "grant Base scopes, share the Base with the app, and ensure unique_key exists")) + continue + } + output.addCheck(passCheck("remote bitable table search", table, redactToken(opts.TableIDs[table]), "table accessible and unique_key search completed")) + } + } + } else if output.Remote { + output.addCheck(skipCheck("remote bitable check", "Feishu Base", "skipped because required config is incomplete")) + } + return finishDiagnostic(ctx, output) +} + +func runFeishuTaskCheck(ctx *common.RuntimeContext) error { + opts, err := taskCreateOptionsFromContext(&common.RuntimeContext{Args: map[string]string{ + "app-id": ctx.Arg("app-id"), + "app-secret": ctx.Arg("app-secret"), + "task-project-id": ctx.Arg("task-project-id"), + "task-section-id": ctx.Arg("task-section-id"), + }}) + if err != nil { + return err + } + output := DiagnosticOutput{ + Mode: "check", + Remote: parseBool(ctx.Arg("remote")), + Layer: "task", + Warnings: []string{ + "Task check does not create tasks.", + "Current +task-create only sends basic summary and description.", + }, + } + output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for Task OpenAPI")) + output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token")) + if opts.TaskProjectID == "" { + output.addCheck(warnCheck("task project id", "FEISHU_TASK_PROJECT_ID", "missing", "currently collected for future placement; not mapped into +task-create request body")) + } else { + output.addCheck(passCheck("task project id", "FEISHU_TASK_PROJECT_ID", redactToken(opts.TaskProjectID), "configured but not yet mapped into +task-create request body")) + } + if opts.TaskSectionID == "" { + output.addCheck(warnCheck("task section id", "FEISHU_TASK_SECTION_ID", "missing", "currently collected for future placement; not mapped into +task-create request body")) + } else { + output.addCheck(passCheck("task section id", "FEISHU_TASK_SECTION_ID", redactToken(opts.TaskSectionID), "configured but not yet mapped into +task-create request body")) + } + output.addCheck(warnCheck("task dedupe", "Feishu Task", "not implemented", "dedupe is local unique_key only; no Feishu-side task search/linking")) + if output.Remote && output.Summary.Failed == 0 { + if _, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret); ok { + output.addCheck(skipCheck("remote task create permission", "Feishu Task", "not checked without creating a task")) + } else { + output.addCheck(skipCheck("remote task create permission", "Feishu Task", "not checked because tenant_access_token acquisition failed")) + } + } else if output.Remote { + output.addCheck(skipCheck("remote task check", "Feishu Task", "skipped because app credentials are incomplete")) + } + return finishDiagnostic(ctx, output) +} + +func finishDiagnostic(ctx *common.RuntimeContext, output DiagnosticOutput) error { + if err := renderDiagnosticOutput(os.Stdout, output, formatOrDefault(ctx, "table")); err != nil { + return err + } + if output.Summary.Failed > 0 { + return fmt.Errorf("Feishu %s check failed: %d failed check(s)", output.Layer, output.Summary.Failed) + } + return nil +} + +func (o *DiagnosticOutput) remoteTenantToken(appID, appSecret string) (TenantToken, bool) { + client := NewOpenAPIClient(http.DefaultClient) + checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + token, err := client.TenantAccessToken(checkCtx, appID, appSecret) + if err != nil { + o.addCheck(failCheck("tenant_access_token", "Feishu OpenAPI", "failed", sanitizeDiagnostic(diagnoseOpenAPIError(err, "tenant token", "self-built app"), appID, appSecret), "verify app_id/app_secret and app availability")) + return TenantToken{}, false + } + o.addCheck(passCheck("tenant_access_token", "Feishu OpenAPI", fmt.Sprintf("expire=%d", token.Expire), "acquired")) + return token, true +} + +func (o *DiagnosticOutput) remoteWikiNode(token string, wikiNodeToken string) { + client := NewOpenAPIClient(http.DefaultClient) + checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + node, err := client.GetWikiNode(checkCtx, token, wikiNodeToken) + if err != nil { + o.addCheck(failCheck("wiki node read", "Feishu Wiki", redactToken(wikiNodeToken), sanitizeDiagnostic(diagnoseOpenAPIError(err, "docx", "wiki node"), wikiNodeToken, token), "grant Wiki/DocX scopes and make the target Wiki node visible to the app")) + return + } + detail := "obj_type=" + firstNonEmpty(node.ObjType, "unknown") + if node.Title != "" { + detail += "; title=" + node.Title + } + o.addCheck(passCheck("wiki node read", "Feishu Wiki", redactToken(wikiNodeToken), detail)) +} + +func (o *DiagnosticOutput) addCheck(check DiagnosticCheck) { + o.Checks = append(o.Checks, check) + switch check.Status { + case "pass": + o.Summary.Passed++ + case "warn": + o.Summary.Warned++ + case "fail": + o.Summary.Failed++ + case "skip": + o.Summary.Skipped++ + } +} + +func requiredSecretCheck(name, target, value, hint string) DiagnosticCheck { + if strings.TrimSpace(value) == "" { + return failCheck(name, target, "missing", "required value is not configured", hint) + } + return passCheck(name, target, redactToken(value), "configured") +} + +func passCheck(name, target, value, detail string) DiagnosticCheck { + return DiagnosticCheck{Name: name, Status: "pass", Target: target, Value: value, Detail: detail} +} + +func warnCheck(name, target, value, hint string) DiagnosticCheck { + return DiagnosticCheck{Name: name, Status: "warn", Target: target, Value: value, Hint: hint} +} + +func failCheck(name, target, value, detail, hint string) DiagnosticCheck { + return DiagnosticCheck{Name: name, Status: "fail", Required: true, Target: target, Value: value, Detail: detail, Hint: hint} +} + +func skipCheck(name, target, detail string) DiagnosticCheck { + return DiagnosticCheck{Name: name, Status: "skip", Target: target, Detail: detail} +} + +func renderDiagnosticOutput(w io.Writer, output DiagnosticOutput, format string) error { + switch normalizeFormat(format) { + case "markdown": + return writeDiagnosticMarkdown(w, output) + case "json": + return writeJSON(w, output) + default: + return writeDiagnosticTable(w, output) + } +} + +func writeDiagnosticTable(w io.Writer, output DiagnosticOutput) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintf(tw, "LAYER\tREMOTE\tPASS\tWARN\tFAIL\tSKIP\n%s\t%t\t%d\t%d\t%d\t%d\n\n", output.Layer, output.Remote, output.Summary.Passed, output.Summary.Warned, output.Summary.Failed, output.Summary.Skipped); err != nil { + return err + } + if _, err := fmt.Fprintln(tw, "CHECK\tSTATUS\tTARGET\tVALUE\tDETAIL\tHINT"); err != nil { + return err + } + for _, check := range output.Checks { + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", check.Name, check.Status, check.Target, check.Value, oneLine(check.Detail), oneLine(check.Hint)); err != nil { + return err + } + } + return tw.Flush() +} + +func writeDiagnosticMarkdown(w io.Writer, output DiagnosticOutput) error { + if _, err := fmt.Fprintf(w, "# Feishu %s Check\n\n", titleWord(output.Layer)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "- Remote: `%t`\n- Passed: `%d`\n- Warned: `%d`\n- Failed: `%d`\n- Skipped: `%d`\n\n", output.Remote, output.Summary.Passed, output.Summary.Warned, output.Summary.Failed, output.Summary.Skipped); err != nil { + return err + } + if _, err := fmt.Fprintln(w, "| Check | Status | Target | Value | Detail | Hint |\n| --- | --- | --- | --- | --- | --- |"); err != nil { + return err + } + for _, check := range output.Checks { + if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s | %s | %s |\n", check.Name, check.Status, check.Target, check.Value, oneLine(check.Detail), oneLine(check.Hint)); err != nil { + return err + } + } + return nil +} + +func tableEnvName(table string) string { + switch table { + case "reports": + return "FEISHU_REPORT_TABLE_ID" + case "issues": + return "FEISHU_ISSUE_TABLE_ID" + case "prs": + return "FEISHU_PR_TABLE_ID" + case "contributors": + return "FEISHU_CONTRIBUTOR_TABLE_ID" + case "tasks": + return "FEISHU_TASK_TABLE_ID" + default: + return "FEISHU_TABLE_ID" + } +} + +func fieldNames(fields []BitableField) []string { + names := make([]string, 0, len(fields)) + for _, field := range fields { + names = append(names, field.Name) + } + return names +} + +func sanitizeDiagnostic(message string, values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + message = strings.ReplaceAll(message, value, redactToken(value)) + } + return message +} + +func oneLine(value string) string { + return strings.Join(strings.Fields(value), " ") +} diff --git a/shortcuts/feishu/feishu.go b/shortcuts/feishu/feishu.go index 6ca5ad7..d5d4512 100644 --- a/shortcuts/feishu/feishu.go +++ b/shortcuts/feishu/feishu.go @@ -26,6 +26,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { newWeeklyReportShortcut(), newOwnerDigestShortcut(), newContributorDigestShortcut(), + newAppCheckShortcut(), + newDocCheckShortcut(), + newBitableCheckShortcut(), + newTaskCheckShortcut(), newDocExportShortcut(), newBitableSchemaShortcut(), newBitableRecordsShortcut(), @@ -35,6 +39,73 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } } +func newAppCheckShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "app-check", + Description: "Check Feishu custom bot and self-built app configuration without writing resources", + Flags: []common.Flag{ + {Name: "webhook-url", Usage: "Feishu custom bot webhook URL. Defaults to FEISHU_WEBHOOK_URL"}, + {Name: "secret", Usage: "Feishu custom bot signing secret. Defaults to FEISHU_WEBHOOK_SECRET"}, + {Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"}, + {Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"}, + {Name: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"}, + }, + Run: runAppCheck, + } +} + +func newDocCheckShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "doc-check", + Description: "Check Feishu DocX/Wiki export configuration without writing documents", + Flags: []common.Flag{ + {Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"}, + {Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"}, + {Name: "folder-token", Usage: "Feishu folder token for creating a new DocX. Defaults to FEISHU_FOLDER_TOKEN"}, + {Name: "document-id", Usage: "Existing Feishu DocX document ID. Defaults to FEISHU_DOCUMENT_ID"}, + {Name: "wiki-url", Usage: "Existing Feishu Wiki URL. Defaults to FEISHU_WIKI_URL"}, + {Name: "wiki-node-token", Usage: "Existing Feishu Wiki node token. Defaults to FEISHU_WIKI_NODE_TOKEN"}, + {Name: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"}, + }, + Run: runDocCheck, + } +} + +func newBitableCheckShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "bitable-check", + Description: "Check Feishu Base/Bitable sync configuration and table readiness without writing records", + Flags: []common.Flag{ + {Name: "tables", Usage: "Comma-separated tables: reports,issues,prs,contributors,tasks", Default: defaultTables}, + {Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"}, + {Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"}, + {Name: "base-app-token", Usage: "Feishu Base app token. Defaults to FEISHU_BASE_APP_TOKEN"}, + {Name: "report-table-id", Usage: "Reports table ID. Defaults to FEISHU_REPORT_TABLE_ID"}, + {Name: "issue-table-id", Usage: "Issues table ID. Defaults to FEISHU_ISSUE_TABLE_ID"}, + {Name: "pr-table-id", Usage: "Pull requests table ID. Defaults to FEISHU_PR_TABLE_ID"}, + {Name: "contributor-table-id", Usage: "Contributors table ID. Defaults to FEISHU_CONTRIBUTOR_TABLE_ID"}, + {Name: "task-table-id", Usage: "Tasks table ID. Defaults to FEISHU_TASK_TABLE_ID"}, + {Name: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"}, + }, + Run: runBitableCheck, + } +} + +func newTaskCheckShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "task-check", + Description: "Check Feishu Task configuration without creating tasks", + Flags: []common.Flag{ + {Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"}, + {Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"}, + {Name: "task-project-id", Usage: "Feishu task project ID. Defaults to FEISHU_TASK_PROJECT_ID"}, + {Name: "task-section-id", Usage: "Feishu task section ID. Defaults to FEISHU_TASK_SECTION_ID"}, + {Name: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"}, + }, + Run: runTaskCheck, + } +} + func newBotTestShortcut() *common.Shortcut { return &common.Shortcut{ Name: "bot-test", @@ -299,6 +370,22 @@ func runContributorDigest(ctx *common.RuntimeContext) error { return renderDigest(os.Stdout, digest, formatOrDefault(ctx, "markdown"), normalizeLang(ctx.Arg("lang"))) } +func runAppCheck(ctx *common.RuntimeContext) error { + return runFeishuAppCheck(ctx) +} + +func runDocCheck(ctx *common.RuntimeContext) error { + return runFeishuDocCheck(ctx) +} + +func runBitableCheck(ctx *common.RuntimeContext) error { + return runFeishuBitableCheck(ctx) +} + +func runTaskCheck(ctx *common.RuntimeContext) error { + return runFeishuTaskCheck(ctx) +} + func runDocExport(ctx *common.RuntimeContext) error { opts, err := docExportOptionsFromContext(ctx) if err != nil { diff --git a/shortcuts/feishu/feishu_test.go b/shortcuts/feishu/feishu_test.go index 666216a..d191b14 100644 --- a/shortcuts/feishu/feishu_test.go +++ b/shortcuts/feishu/feishu_test.go @@ -20,7 +20,7 @@ func TestShortcutsExposeExpectedCommands(t *testing.T) { for _, shortcut := range Shortcuts() { got[shortcut.Name] = true } - for _, name := range []string{"bot-test", "notify", "weekly-report", "owner-digest", "contributor-digest", "doc-export", "bitable-schema", "bitable-records", "bitable-sync", "task-preview", "task-create"} { + for _, name := range []string{"bot-test", "notify", "weekly-report", "owner-digest", "contributor-digest", "app-check", "doc-check", "bitable-check", "task-check", "doc-export", "bitable-schema", "bitable-records", "bitable-sync", "task-preview", "task-create"} { if !got[name] { t.Fatalf("Shortcuts missing %s", name) } @@ -316,6 +316,103 @@ func TestBitableSyncMockHTTP(t *testing.T) { } } +func TestAppCheckRemoteMockHTTP(t *testing.T) { + var sawToken bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal" { + sawToken = true + _, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`)) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + oldBaseURL := openAPIBaseURL + openAPIBaseURL = server.URL + defer func() { openAPIBaseURL = oldBaseURL }() + + ctx := &common.RuntimeContext{Args: map[string]string{ + "app-id": "cli_xxx", + "app-secret": "secret", + "webhook-url": "https://open.feishu.cn/open-apis/bot/v2/hook/test", + "remote": "true", + }} + if err := runFeishuAppCheck(ctx); err != nil { + t.Fatalf("runFeishuAppCheck returned error: %v", err) + } + if !sawToken { + t.Fatal("expected tenant token request") + } +} + +func TestBitableCheckRemoteUsesSearchOnly(t *testing.T) { + var sawSearch bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal": + _, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`)) + case r.Method == http.MethodPost && r.URL.Path == "/bitable/v1/apps/base_token/tables/tbl_report/records/search": + sawSearch = true + _, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"items":[]}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + oldBaseURL := openAPIBaseURL + openAPIBaseURL = server.URL + defer func() { openAPIBaseURL = oldBaseURL }() + + ctx := &common.RuntimeContext{Args: map[string]string{ + "app-id": "cli_xxx", + "app-secret": "secret", + "base-app-token": "base_token", + "tables": "reports", + "report-table-id": "tbl_report", + "remote": "true", + }} + if err := runFeishuBitableCheck(ctx); err != nil { + t.Fatalf("runFeishuBitableCheck returned error: %v", err) + } + if !sawSearch { + t.Fatal("expected bitable search request") + } +} + +func TestTaskCheckRemoteDoesNotCreateTask(t *testing.T) { + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal" { + _, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`)) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + oldBaseURL := openAPIBaseURL + openAPIBaseURL = server.URL + defer func() { openAPIBaseURL = oldBaseURL }() + + ctx := &common.RuntimeContext{Args: map[string]string{ + "app-id": "cli_xxx", + "app-secret": "secret", + "remote": "true", + }} + if err := runFeishuTaskCheck(ctx); err != nil { + t.Fatalf("runFeishuTaskCheck returned error: %v", err) + } + if requestCount != 1 { + t.Fatalf("expected only tenant token request, got %d requests", requestCount) + } +} + func TestTaskCreateOptionsRejectSendDryRun(t *testing.T) { ctx := &common.RuntimeContext{Args: map[string]string{ "send": "true", From 138d886681a0f17e9d948338281aa60cde5f7798 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Sat, 27 Jun 2026 12:22:32 +0800 Subject: [PATCH 13/16] feat(feishu): add full PR inventory and review attribution --- .github/workflows/test.yml | 9 + README.md | 16 + README.zh-CN.md | 5 + docs/FEISHU_CAPABILITY_LAYERS.md | 30 ++ docs/FEISHU_OPENAPI_INVENTORY.md | 25 ++ docs/FEISHU_PR_ACTIVITY_STRATEGY.md | 246 +++++++++++++ docs/pr-draft.md | 229 ++++++++---- reports/FEISHU_SMOKE_20260627.md | 242 +++++++++++++ reports/FEISHU_SMOKE_EVIDENCE_20260627.md | 61 ++++ shortcuts/feishu/card.go | 27 +- shortcuts/feishu/digest.go | 175 +++++++-- shortcuts/feishu/doc_export.go | 20 ++ shortcuts/feishu/feishu_test.go | 29 ++ shortcuts/feishu/l10n.go | 42 ++- shortcuts/workflow/api_types.go | 2 +- shortcuts/workflow/api_types_test.go | 23 +- shortcuts/workflow/health_fetch.go | 90 ++++- shortcuts/workflow/pr_fetch.go | 32 +- shortcuts/workflow/pr_review_audit.go | 354 +++++++++++++++++++ shortcuts/workflow/pr_summary.go | 5 + shortcuts/workflow/pr_summary_test.go | 3 + shortcuts/workflow/render.go | 52 ++- shortcuts/workflow/repo_report.go | 123 +++++-- shortcuts/workflow/repo_report_fetch.go | 139 ++++++-- shortcuts/workflow/repo_report_fetch_test.go | 205 +++++++++++ shortcuts/workflow/repo_report_test.go | 17 + shortcuts/workflow/triage_fetch.go | 4 +- shortcuts/workflow/triage_fetch_test.go | 7 +- 28 files changed, 2024 insertions(+), 188 deletions(-) create mode 100644 docs/FEISHU_PR_ACTIVITY_STRATEGY.md create mode 100644 reports/FEISHU_SMOKE_20260627.md create mode 100644 reports/FEISHU_SMOKE_EVIDENCE_20260627.md create mode 100644 shortcuts/workflow/pr_review_audit.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d5673c..d4bd497 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,5 +26,14 @@ jobs: - name: Scan i18n key references run: go run ./internal/i18n/cmd/check --scan-code + - name: Test Feishu shortcuts + run: go test ./shortcuts/feishu + + - name: Test workflow shortcuts + run: go test ./shortcuts/workflow + - name: Run Go tests run: go test ./... + + - name: Vet + run: go vet ./... diff --git a/README.md b/README.md index c7e7f29..2ae1252 100644 --- a/README.md +++ b/README.md @@ -632,6 +632,14 @@ gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.j # Repository workflow report by read-only GitLink fetch gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown +# Optional full PR review attribution. This deep-fetches formal reviews and +# PR-associated Issue journals for analyzed PRs, so keep it explicit. +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --include-pr-review-audit --format json > report.review-audit.json + +# Limit analysis only when an intentional sample is needed. +# By default, repo-report paginates through all open issues and pull requests. +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --issue-limit 20 --pr-limit 50 --format markdown + # Repository workflow report from a local JSON file gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json ``` @@ -649,18 +657,26 @@ Safety: - They do not depend on LLM APIs. - `workflow +pr-summary` does not comment, approve, reject, or merge pull requests. - `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes. +- `workflow +repo-report --include-pr-review-audit` remains read-only. It treats formal review objects as authoritative review evidence and keeps submitter, reviewer, participant, and system journal activity separate. ### Feishu Collaboration Export `feishu` turns `workflow +repo-report` JSON into Feishu collaboration outputs. +`workflow +repo-report` paginates through all open issues and pull requests by +default. Feishu cards label these values as analyzed counts. Passing +`--issue-limit` or `--pr-limit` intentionally limits the analysis and the +resulting values must not be interpreted as repository totals. + Stable usage: ```bash gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --format json > report.json +gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --include-pr-review-audit --format json > report.review-audit.json gitlink-cli feishu +notify --from-workflow-json report.json --format json gitlink-cli feishu +notify --from-workflow-json report.json --send --format table +gitlink-cli feishu +owner-digest --from-workflow-json report.review-audit.json --format table gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown diff --git a/README.zh-CN.md b/README.zh-CN.md index cc48e55..f770289 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -555,6 +555,8 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --ye `feishu` 将 `workflow +repo-report` JSON 转成飞书协作内容。 +`workflow +repo-report` 默认分页读取并分析全部开放 Issue 和 PR。飞书卡片会把这些值明确标为“已分析数量”。只有显式传 `--issue-limit` 或 `--pr-limit` 时才会采样,此时结果不能解释为仓库总量。 + #### 稳定层:自定义机器人通知 稳定层只依赖飞书群自定义机器人。它适合把 GitLink 项目状态、周报、Owner 摘要和贡献者摘要推送到群里。默认只预览,真实发送必须显式传 `--send`。 @@ -562,6 +564,9 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --ye ```bash gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --format json > report.json +# 仅在明确需要采样时设置上限 +gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --issue-limit 20 --pr-limit 50 --format json > report.sample.json + gitlink-cli feishu +notify --from-workflow-json report.json --format json gitlink-cli feishu +notify --from-workflow-json report.json --send --format table diff --git a/docs/FEISHU_CAPABILITY_LAYERS.md b/docs/FEISHU_CAPABILITY_LAYERS.md index b43b89c..b15a56a 100644 --- a/docs/FEISHU_CAPABILITY_LAYERS.md +++ b/docs/FEISHU_CAPABILITY_LAYERS.md @@ -270,3 +270,33 @@ Authorization policy: GitLink write permissions must be defined by GitLink official maintainers, project owners, and deployers. This module must not hard-code a write-action authorization policy. ``` + +## Next-Stage Read-Only PR Activity Layer + +Before any GitLink action gateway, a read-only PR activity layer should support: + +```text +Complete open/merged/closed inventory. +Formal review status. +Review and journal attribution by submitter/reviewer/participant/system. +Previous-snapshot comparison. +Maintainer-role enrichment when authenticated member data is available. +Review-content fingerprints and change detection. +``` + +The generic actor, review, fallback, and snapshot rules are defined in +`docs/FEISHU_PR_ACTIVITY_STRATEGY.md`. Member lookup is optional enrichment: +when GitLink authentication or permission is unavailable, the CLI must not +guess that a participant is a maintainer. + +Current implementation status: + +```text +implemented: complete open/merged/closed PR inventory +implemented: optional read-only formal review and journal actor attribution +implemented: conservative reviewed/unreviewed classification +implemented: needs_re_review when submitter activity or PR updates happen after reviewer feedback +not implemented: previous-snapshot diff +not implemented: maintainer-role enrichment +not implemented: review-content fingerprint persistence +``` diff --git a/docs/FEISHU_OPENAPI_INVENTORY.md b/docs/FEISHU_OPENAPI_INVENTORY.md index d1c8c2e..9da2a26 100644 --- a/docs/FEISHU_OPENAPI_INVENTORY.md +++ b/docs/FEISHU_OPENAPI_INVENTORY.md @@ -16,6 +16,21 @@ Layer 3: Future callback-based GitLink action gateway No implemented command in this branch performs GitLink write operations. +## GitLink Read Sources Used by Feishu Exports + +The Feishu commands consume `workflow +repo-report` JSON. The workflow report +uses these GitLink read-only sources when the corresponding flags are enabled: + +| GitLink read source | Used by | Status | Notes | +| --- | --- | --- | --- | +| `GET /v1/{owner}/{repo}/issues?category=opened` | Issue analysis | Implemented | Paginates all open issues by default | +| `GET /v1/{owner}/{repo}/pulls?status=0` | Open PR analysis | Implemented | Paginates all open PRs by default | +| `GET /v1/{owner}/{repo}/pulls?status=1` | PR lifecycle totals | Implemented | Merged total only | +| `GET /v1/{owner}/{repo}/pulls?status=2` | PR lifecycle totals | Implemented | Closed/rejected total only | +| `GET /v1/{owner}/{repo}/pulls/{number}/reviews` | Optional review audit | Implemented read-only | Formal review objects are authoritative review evidence | +| `GET /v1/{owner}/{repo}/issues/{issue_id}/journals` | Optional review audit | Implemented read-only | Counts submitter/reviewer/participant/system activity without storing raw comment text | +| Repository member lookup | Optional role enrichment | Not implemented in this branch | Maintainer identity is not guessed when auth is unavailable | + ## Source Index Official Feishu / Lark references used for this inventory: @@ -503,6 +518,11 @@ Does not require the Feishu module to know a GitLink token. Provides summary-level issue, PR, contributor, recommendation, and health fields. ``` +`workflow +repo-report` paginates all open issues and pull requests by default. +`--issue-limit` and `--pr-limit` are explicit sampling controls. Counts in +Feishu output are labeled as analyzed counts and must not be interpreted as +repository totals after an explicit limit is applied. + Required future source expansion: ```text @@ -532,6 +552,11 @@ Audit source: future action gateway audit log ``` +Next-stage PR activity comparison uses the read-only PR review and associated +Issue journal endpoints. Actor attribution and snapshot-diff rules are defined +in `docs/FEISHU_PR_ACTIVITY_STRATEGY.md`. This remains planned; current Feishu +commands do not crawl or copy PR conversations. + Reason: ```text diff --git a/docs/FEISHU_PR_ACTIVITY_STRATEGY.md b/docs/FEISHU_PR_ACTIVITY_STRATEGY.md new file mode 100644 index 0000000..85f9728 --- /dev/null +++ b/docs/FEISHU_PR_ACTIVITY_STRATEGY.md @@ -0,0 +1,246 @@ +# PR Activity, Review Attribution, and Snapshot Strategy + +Status: next-stage design. No GitLink write operation is implemented here. + +## Goal + +Provide repository owners with a general, cross-repository view of: + +- all current open pull requests; +- merged and closed/rejected totals; +- state transitions since the previous assessment; +- whether a pull request received a formal review; +- whether conversation feedback came from the submitter, a reviewer, a + maintainer, another participant, a bot, or a system event; +- whether review/comment content changed since the previous snapshot. + +The model must not depend on a specific repository, user login, PR number, or +organization role name. + +## Verified Read Sources + +The GitLink API surfaces needed by this design are read-only: + +```text +GET /v1/{owner}/{repo}/pulls +GET /v1/{owner}/{repo}/pulls/{number} +GET /v1/{owner}/{repo}/pulls/{number}/reviews +GET /v1/{owner}/{repo}/issues/{issue_id}/journals +GET repository members when the current identity has permission +``` + +Local validation confirmed: + +- list responses expose PR state, author, associated issue ID, and pagination + totals; +- formal reviews expose reviewer identity, status, content, and time; +- journals expose actor identity, comment text, state events, created time, and + updated time; +- repository-member lookup may return 401 for an unauthenticated read. The + implementation must degrade to `participant`, not guess maintainer status. + +## Actor Classification + +Normalize identities by stable user ID first and login second. + +| Actor class | Evidence | +| --- | --- | +| `submitter` | Actor matches the PR author | +| `reviewer` | Actor owns a formal review or is in the assigned reviewer set | +| `maintainer` | Repository membership data proves a configured privileged role | +| `participant` | Authenticated human who is none of the above | +| `bot` | Explicit bot/application identity | +| `system` | State transition or generated event without human review content | +| `unknown` | Identity is incomplete | + +Role precedence: + +```text +system/bot -> submitter -> formal reviewer -> maintainer -> participant -> unknown +``` + +A user can be both maintainer and reviewer. Event attribution records the most +specific event relationship (`reviewer`) and may retain `is_maintainer=true` as +an additional property. + +## Review Standard + +Do not treat every comment as a review. + +### Authoritative review + +A formal review object with: + +```text +status: approved | rejected | common +reviewer identity +created_at +``` + +is authoritative review evidence. + +### Review-like journal feedback + +A journal comment is review feedback only when: + +1. it has non-empty human-authored content; +2. it is not a creation/status/system event; +3. the actor is a formal/assigned reviewer or a proven maintainer; +4. the actor is not the PR submitter, unless the UI explicitly marks a + self-review; +5. the normalized content is not only an acknowledgement such as `LGTM`, + `thanks`, or a generated status line, unless the product policy explicitly + enables acknowledgement reviews. + +When member data is unavailable, a comment from an unassigned actor remains +`participant_feedback`, not `maintainer_review`. + +## Risk Is Separate From Review + +Current `workflow +repo-report` risk is rule-based. A list-metadata keyword hit +is a risk hint, not proof that a reviewer found a problem. + +The next-stage output should keep separate fields: + +```text +metadata_risk_hint +code_change_risk +formal_review_status +review_feedback_status +merge_readiness +``` + +Detailed code risk requires files and commits. Bulk list metadata alone must +not be presented as a formal review conclusion. + +## Snapshot Model + +Recommended local snapshot: + +```json +{ + "schema_version": 1, + "repository": "owner/repo", + "generated_at": "RFC3339", + "totals": { + "open": 0, + "merged": 0, + "closed": 0 + }, + "pull_requests": [ + { + "number": 1, + "state": "open", + "author_id": "stable-id", + "updated_at": "RFC3339", + "head_revision": "optional", + "formal_review_status": "unreviewed", + "review_fingerprint": "sha256", + "conversation_fingerprint": "sha256", + "events": [] + } + ] +} +``` + +Raw access tokens and private profile fields must never enter snapshots. + +## Content Fingerprints + +Normalize review/comment content before hashing: + +1. normalize line endings; +2. trim leading/trailing whitespace; +3. collapse repeated whitespace outside code blocks; +4. remove generated status-only markup; +5. preserve code and semantic text; +6. hash actor ID, event type, normalized content, and event time. + +Store hashes and bounded summaries by default. Raw comment content should be +included only in an explicitly local evidence file. + +## Snapshot Diff + +Compare the current snapshot with `--previous-snapshot` and emit: + +```text +new_open_prs +newly_merged_prs +newly_closed_prs +reopened_prs +new_formal_reviews +review_status_changes +new_reviewer_feedback +edited_reviewer_feedback +submitter_responses +participant_feedback +``` + +State transitions are determined by PR number plus previous/current state, not +by subtracting aggregate totals. + +## Fetch Strategy + +Default inventory: + +1. paginate all PR list states; +2. record exact list totals and basic identity/state fields; +3. compare with the previous snapshot; +4. deep-fetch reviews and journals only for new or updated PRs. + +Optional full audit: + +```text +--full-review-audit +``` + +This explicitly deep-fetches every PR and may require hundreds of API calls. +Use bounded concurrency, retry/backoff, and a request summary. It must remain +read-only. + +## Planned Commands + +```text +gitlink-cli workflow +pr-activity-snapshot +gitlink-cli workflow +pr-activity-diff +gitlink-cli feishu +owner-activity-digest +``` + +The Feishu digest should show aggregate transitions and the most important +changed PRs. It must link to GitLink for full comments rather than copying an +unbounded conversation into a card. + +## Current Boundary + +Implemented in the current branch: + +- correct GitLink Issue and PR list filters; +- complete pagination for `workflow +repo-report` by default; +- PR lifecycle totals for open, merged, and closed/rejected states; +- explicit analyzed-count labels and scope notes; +- optional read-only formal review and journal actor attribution through + `workflow +repo-report --include-pr-review-audit`. + +The implemented audit follows the conservative review standard in this +document: + +- a formal `/pulls/{number}/reviews` object marks the PR as reviewed; +- journal comments from the PR submitter are counted as submitter responses, + not reviews; +- journal comments from an actor who also has a formal review on the PR are + counted as reviewer feedback; +- other human comments are counted as participant feedback; +- status changes and empty/generated events are counted as system events; +- a reviewed PR is marked `needs_re_review` when a later submitter comment, + later commit, or later PR update timestamp is newer than the latest reviewer + feedback timestamp; +- maintainer classification is not guessed when repository-member data is not + available. + +Not implemented in the current branch: + +- snapshot persistence; +- member-role enrichment; +- review-content diffing; +- Feishu activity-diff cards; +- any GitLink write operation. diff --git a/docs/pr-draft.md b/docs/pr-draft.md index 48bc8c6..29dc902 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -1,105 +1,184 @@ -# feat(workflow): add agent workflow commands for repository maintenance +# feat(feishu): add layered Feishu collaboration exports for workflow reports ## Summary -This PR adds four read-only workflow commands for repository maintenance: +- Add Feishu custom-bot cards for GitLink workflow reports. +- Add weekly, owner, and contributor digests. +- Add Bitable-ready schemas and records. +- Add experimental DocX, Bitable, and Task OpenAPI writes. +- Add read-only Open Platform readiness diagnostics. +- Add English and zh-CN Feishu output. +- Fix workflow Issue/PR list filters and paginate all open items by default. +- Add optional read-only PR review audit for formal reviews and comment actor attribution. +- Keep all GitLink write operations out of scope. -- `workflow +triage` -- `workflow +health` -- `workflow +pr-summary` -- `workflow +repo-report` +## Data Correctness -The commands provide rule-based, explainable analysis with stable `json`, concise `table`, -and copy-friendly `markdown` output. +`workflow +repo-report` now uses the GitLink API parameters used by the native +Issue and PR commands: -## Motivation +```text +Issue open filter: category=opened +PR open filter: status=0 +``` -Open-source maintainers often spend time on repetitive information organization before -making actual decisions: +It paginates all open issues and pull requests by default. An explicit +`--issue-limit` or `--pr-limit` enables sampling. -- Issue triage cost -- PR review cost -- repository health visibility -- Agent needs stable structured output +Feishu output labels these values as analyzed counts and includes a scope note. +This avoids presenting a limited sample as a repository total. -This PR adds workflow-level analysis on top of the existing GitLink CLI shortcut architecture -without introducing LLM dependencies or remote write behavior. +## PR Review Attribution -## Changes +`workflow +repo-report --include-pr-review-audit` performs a read-only audit of +the analyzed PRs: -### `workflow +triage` +```text +formal /pulls/{number}/reviews objects mark a PR as reviewed +submitter comments are counted separately and do not mark a PR as reviewed +participant comments are counted separately and do not mark a PR as reviewed +comments from actors with formal review identity are counted as reviewer feedback +maintainer role is not guessed when member lookup is unavailable +``` -- Classifies issues by type -- Scores priority and confidence -- Detects missing bug-report information -- Produces risk flags, recommended actions, suggested comments, and reasoning +This keeps metadata risk, formal review status, and conversation attribution as +separate signals. -### `workflow +health` +## Stable Surface -- Scores repository health -- Covers issue/PR backlog, activity, release, CI, docs, license, contributing, and Agent readiness signals -- Tolerates unknown metrics without failing the command +```text +feishu +bot-test +feishu +notify +feishu +weekly-report +feishu +owner-digest +feishu +contributor-digest +feishu +bitable-schema +feishu +bitable-records +feishu +task-preview +``` -### `workflow +pr-summary` +Stable commands preview locally by default. Custom-bot delivery requires +explicit `--send`. -- Summarizes PR metadata, changed files, and commits -- Produces change type, risk level, review focus, test suggestions, merge checklist, and reasoning -- Supports local JSON input and remote read-only PR fetch +## Readiness Diagnostics -### `workflow +repo-report` +```text +feishu +app-check +feishu +doc-check +feishu +bitable-check +feishu +task-check +``` -- Aggregates health, issue triage, and PR summary signals -- Produces a repository workflow report with score, risk level, recommendations, and reasoning -- Supports partial read-only remote aggregation when optional sections are unavailable +Local mode checks configuration only. `--remote` performs read/check OpenAPI +calls and does not create or modify Feishu or GitLink resources. + +## Experimental Surface + +```text +feishu +doc-export +feishu +bitable-sync +feishu +task-create +``` + +These commands require a Feishu self-built app and explicit `--send`. ## Safety -- Remote mode is read-only -- No LLM dependency -- No labels/comments/close operations -- No PR approve/reject/merge operations -- No `internal/output` change -- No new third-party dependency -- Test fixtures do not contain secrets or tokens +- Preview/check by default. +- Real Feishu side effects require explicit `--send`. +- Remote readiness calls require explicit `--remote`. +- GitLink write operations are not implemented. +- Card buttons are navigation-only. +- Secrets and resource IDs come from ignored local env files. +- CLI and smoke output redact sensitive values. +- Bitable sync never deletes records. -## Tests +## Real Validation -```bash -gofmt -w shortcuts/workflow/*.go shortcuts/register.go +The local test enterprise validated: + +```text +custom bot card delivery +English and zh-CN cards +DocX append +Bitable search/create/update +Feishu Task create +app/doc/bitable/task readiness diagnostics +``` + +The current real repository report validated complete default pagination: + +```text +open issues analyzed: 9 +open pull requests analyzed: 166 +open/merged/closed PR lifecycle totals: 166 / 65 / 74 +full review-audit result: 166 audited, 4 reviewed, 162 unreviewed +needs re-review after reviewer feedback: 0 +``` + +Task creation was not repeated during the final smoke because Feishu-side +deduplication is not implemented. + +## Validation Commands + +```powershell +go run . workflow +repo-report --owner "$env:GITLINK_OWNER" --repo "$env:GITLINK_REPO" --format json > .local\report.json +go run . workflow +repo-report --owner "$env:GITLINK_OWNER" --repo "$env:GITLINK_REPO" --include-pr-review-audit --format json > .local\report.review-audit.full.json + +go run . feishu +app-check --remote --format table +go run . feishu +doc-check --remote --format table +go run . feishu +bitable-check --tables reports,issues,prs,tasks --remote --format table +go run . feishu +task-check --remote --format table + +go run . feishu +notify --from-workflow-json .local\report.json --send --format table +go run . feishu +owner-digest --from-workflow-json .local\report.review-audit.full.json --send --format table +go run . feishu +doc-export --from-workflow-json .local\report.json --send --format table +go run . feishu +bitable-sync --from-workflow-json .local\report.json --send --format table + +go test ./shortcuts/feishu go test ./shortcuts/workflow go test ./... +go vet ./... ``` -Coverage includes: +## Review and Comment Attribution Boundary -- triage rules -- health scoring -- PR summary rules -- repo report aggregation -- fetch normalization -- partial failure handling -- `json` / `table` / `markdown` rendering -- local `--from` fixtures -- command wiring tests +Formal reviews and PR-associated Issue journals are consumed only by the +optional read-only audit path. Previous-snapshot comparison, member-role +enrichment, and review-content fingerprint persistence remain designed in: -## Documentation - -- `README.md` -- `docs/workflow-agent-design.md` -- `docs/workflow-agent-test-report.md` -- `skills/gitlink-workflow/SKILL.md` - -## Known Limitations - -- `workflow +release-notes` is not implemented. -- `workflow +stale` is not implemented. -- Real GitLink API shapes may require follow-up normalization. - -## Examples - -```bash -gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table -gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown -gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown -gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown +```text +docs/FEISHU_PR_ACTIVITY_STRATEGY.md ``` + +## Evidence + +```text +reports/FEISHU_SMOKE_20260626.md +reports/FEISHU_SMOKE_20260627.md +reports/FEISHU_SMOKE_EVIDENCE_20260627.md +reports/FEISHU_PERMISSION_MATRIX.md +docs/FEISHU_OPENAPI_INVENTORY.md +``` + +## Out of Scope + +- GitLink issue comment or close. +- GitLink PR review, approve, reject, or merge. +- GitLink member management. +- Feishu callback server. +- Feishu-to-GitLink identity mapping. +- Automatic Base/table/field/view creation. +- Task project/section/assignee placement. +- Feishu-side Task deduplication. +- PR activity snapshot persistence. +- Review-content fingerprint diffing. +- Maintainer-role enrichment without authenticated member data. + +## Reviewer Questions + +- Should webhook export remain the stable main path? +- Should DocX, Bitable, and Task writes remain experimental? +- Should full PR review activity be a separate workflow command? +- Should member-role enrichment require authenticated GitLink access? +- Should future Feishu callbacks live in gitlink-cli or a separate service? diff --git a/reports/FEISHU_SMOKE_20260627.md b/reports/FEISHU_SMOKE_20260627.md new file mode 100644 index 0000000..62de5a6 --- /dev/null +++ b/reports/FEISHU_SMOKE_20260627.md @@ -0,0 +1,242 @@ +# Feishu Smoke Report + +Date: 2026-06-27 + +## Branch and Base Commit + +```text +branch: feat/feishu-export-clean +base commit: d7812df1af49519f9eb84def218bd3d5a9fdf02f +``` + +This smoke run included uncommitted data-correctness fixes that are documented +below and will receive a new commit after final validation. + +## Environment + +```text +Real Feishu test enterprise: used +Real GitLink repository: used +Custom bot: used +Self-built Feishu app: used +DocX target: used +Five split Bitable tables: used +GitLink write operations: not used +``` + +Real credentials and resource IDs remained in the ignored file: + +```text +.local/feishu-gitlink.env.ps1 +``` + +## Readiness Diagnostics + +| Command | Result | Side effect | +| --- | --- | --- | +| `feishu +app-check` | pass | none | +| `feishu +doc-check` | pass | none | +| `feishu +bitable-check --tables reports,issues,prs,tasks` | pass | none | +| `feishu +task-check` | pass with expected project/section/dedupe warnings | none | +| `feishu +app-check --remote` | pass | tenant token check only | +| `feishu +doc-check --remote` | pass with write-permission checks skipped | Wiki/DocX read/check only | +| `feishu +bitable-check --remote` | pass for four tables | sentinel search only | +| `feishu +task-check --remote` | pass with expected warnings | tenant token check only | + +## Data-Correctness Finding + +The first report generated: + +```text +issues analyzed: 19 +pull requests analyzed: 10 +``` + +The GitLink UI showed: + +```text +open issues: 9 +open pull requests: 166 +``` + +The values were real API-derived values, but the workflow request semantics +were wrong: + +1. Issue workflow fetch sent `state=open`. GitLink Issue list requires + `category=opened`, so the API ignored the filter and returned 9 open plus 10 + closed issues. +2. PR workflow fetch sent `state=open`. GitLink PR list requires `status=0`, so + the API ignored the filter and returned all states. +3. The old repo-report defaults analyzed only 20 issues and 10 PRs. +4. The API can cap a requested page at 50 records, so stopping only because a + page is shorter than the requested limit can truncate a report. + +## Data-Correctness Fix + +The branch now: + +```text +uses category=opened for open Issue queries +uses status=0 for open PR queries +paginates until the API total_count is reached +deduplicates list items by stable identifiers +recognizes GitLink PR index as the user-facing PR number +analyzes all open issues and PRs by default +uses --issue-limit/--pr-limit only as explicit sampling controls +labels Feishu counts as analyzed counts +adds a scope note that sampled values are not repository totals +``` + +Real post-fix result: + +```text +open issues analyzed: 9 +open pull requests analyzed: 166 +open PR lifecycle total: 166 +merged PR lifecycle total: 65 +closed/rejected PR lifecycle total: 74 +``` + +These values match the GitLink web UI badges used during the smoke run. + +## PR Risk Source + +The bulk repo report uses PR list metadata, not changed files, commits, reviews, +or journal comments. The 13 critical metadata classifications came from the +existing `security-sensitive keyword` rule: + +| PR | Metadata hit | +| --- | --- | +| 76 | token | +| 77 | token | +| 115 | token | +| 146 | token | +| 167 | secret | +| 171 | token, secret, credential | +| 173 | secret | +| 183 | secret | +| 189 | token | +| 225 | token | +| 254 | token | +| 280 | token | +| 293 | token | + +This is a metadata risk hint, not a formal reviewer conclusion. Detailed code +risk requires files and commits. Formal review status must be reported +separately. + +## Review and Journal API Validation + +Three historical PRs were used only as local read-only samples: + +| Sample | Formal review | Journal result | +| --- | --- | --- | +| PR 95 | one approved review with reviewer identity and content | review comments and merged event readable | +| PR 29 | one approved review with reviewer identity and content | review comment and merged event readable | +| PR 75 | no formal review object | review-like comments and rejected/closed event readable | + +The repository member list returned 401 without GitLink authentication. +Therefore a generic implementation must not guess maintainer identity. It can +still reliably distinguish the submitter, formal reviewer, participant, and +system event. The cross-repository strategy is documented in: + +```text +docs/FEISHU_PR_ACTIVITY_STRATEGY.md +``` + +## Full PR Review Audit + +After the strategy was implemented as an explicit read-only audit path, the +full open-PR inventory was audited with: + +```text +workflow +repo-report --include-pr-review-audit +``` + +Result: + +```text +PRs analyzed: 166 +PRs review-audited: 166 +PRs with formal review evidence: 4 +PRs without formal review evidence: 162 +PRs needing re-review after reviewer feedback: 0 +formal reviews: 4 +reviewer comments: 6 +submitter comments: 0 +participant comments: 436 +system events: 0 +audit errors: 0 +``` + +Review judgment is conservative: + +```text +formal /pulls/{number}/reviews objects mark a PR as reviewed +submitter comments do not mark a PR as reviewed +participant comments do not mark a PR as reviewed +comments by actors with formal review identity are counted as reviewer feedback +reviewed PRs are marked needs_re_review when later submitter comments, later commits, or later PR updates appear after the last reviewer feedback +maintainer identity is not guessed without member-role data +``` + +## Real Feishu Writes + +| Command group | Result | +| --- | --- | +| Eight webhook test/report/digest sends | HTTP 200, Feishu code 0 | +| Corrected full-analysis notify card | HTTP 200, Feishu code 0 | +| Corrected full-analysis owner digest | HTTP 200, Feishu code 0 | +| Full review-audit notify card | HTTP 200, Feishu code 0 | +| Full review-audit owner digest | HTTP 200, Feishu code 0 | +| Final English smoke notify card | HTTP 200, Feishu code 0 | +| Final English smoke owner digest | HTTP 200, Feishu code 0 | +| English and Chinese DocX append | 9 blocks each | +| Corrected Chinese DocX append | 11 blocks | +| Bitable full-analysis upsert | reports 1 updated; issues 5 updated; PRs 5 created/3 updated; contributors 1 updated; tasks 2 created/7 updated | +| Task create | intentionally skipped in this run to avoid duplicates | + +## Current Boundaries + +```text +No GitLink write operation. +No callback server. +No automatic Base/table/view creation. +No Feishu-side Task dedupe. +No PR activity snapshot persistence. +No previous-snapshot review diff. +No review-content fingerprint persistence. +No maintainer-role guess when member lookup is unavailable. +``` + +## Test Results + +| Check | Result | +| --- | --- | +| `go test ./shortcuts/feishu` | pass | +| `go test ./shortcuts/workflow` | pass | +| `go test ./shortcuts` | pass | +| `go test ./...` | pass | +| `go build .` | pass | +| `go vet ./...` | pass | +| `go run ./internal/i18n/cmd/check` | blocked by existing Windows locale line-ending issue | +| `go run ./internal/i18n/cmd/check --scan-code` | blocked by the same formatting check | + +The i18n line-ending and missing-key fix remains in its independent branch/PR +and is intentionally not duplicated into this Feishu change. + +## Screenshot Status + +The requested Windows computer-use connection failed twice during plugin +initialization: + +```text +failed to write kernel assets: path not found +``` + +No screenshot was fabricated or committed. Text evidence and API-derived +results remain the evidence for this run. See: + +```text +reports/FEISHU_SMOKE_EVIDENCE_20260627.md +``` diff --git a/reports/FEISHU_SMOKE_EVIDENCE_20260627.md b/reports/FEISHU_SMOKE_EVIDENCE_20260627.md new file mode 100644 index 0000000..3cfbf3c --- /dev/null +++ b/reports/FEISHU_SMOKE_EVIDENCE_20260627.md @@ -0,0 +1,61 @@ +# Feishu Smoke Evidence + +Date: 2026-06-27 + +Branch: + +```text +feat/feishu-export-clean +``` + +Base commit: + +```text +d7812df1af49519f9eb84def218bd3d5a9fdf02f +``` + +## Evidence Files + +| Evidence | Expected file | Status | Notes | +| --- | --- | --- | --- | +| Custom bot notify card | `reports/images/feishu-card-notify-redacted.png` | not captured | computer-use initialization failed | +| Owner digest card | `reports/images/feishu-owner-digest-redacted.png` | not captured | corrected card was sent successfully | +| DocX append result | `reports/images/feishu-docx-append-redacted.png` | not captured | corrected 11-block append passed | +| Bitable sync result | `reports/images/feishu-bitable-sync-redacted.png` | not captured | real upsert passed | +| Task create result | `reports/images/feishu-task-create-redacted.png` | not captured | historical result retained; creation was not repeated | +| Diagnostics output | `reports/images/feishu-diagnostics-terminal-redacted.png` | not captured | local and remote checks passed | + +No placeholder or fabricated image file is committed. + +## Text Evidence + +```text +reports/FEISHU_SMOKE_20260626.md +reports/FEISHU_SMOKE_20260627.md +reports/FEISHU_PERMISSION_MATRIX.md +reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md +docs/FEISHU_OPENAPI_INVENTORY.md +docs/FEISHU_PR_ACTIVITY_STRATEGY.md +``` + +## Redaction Checklist + +```text +[x] No webhook URL committed +[x] No app secret committed +[x] No tenant_access_token committed +[x] No document token committed +[x] No Base app token committed +[x] No table ID committed +[x] No task ID committed +[x] No open_id / union_id committed +[x] No personal account credential committed +[x] No unredacted screenshot committed +``` + +## Capture Rule + +Screenshots may be added only after the Windows automation connection works and +each image is reviewed for resource IDs, personal identities, and unrelated +conversation content. Until then, this document records the missing visual +evidence explicitly rather than presenting a fake pass. diff --git a/shortcuts/feishu/card.go b/shortcuts/feishu/card.go index f3ecb4a..0cc60b0 100644 --- a/shortcuts/feishu/card.go +++ b/shortcuts/feishu/card.go @@ -58,19 +58,41 @@ func BuildWorkflowCard(report workflow.RepoReportResult, include []string, title } if hasItem(include, "issues") { elements = append(elements, fields([]fieldValue{ - {Label: feishuLabel(lang, "issues"), Value: fmt.Sprintf("%d", report.IssueSummary.Total)}, + {Label: feishuLabel(lang, "issues_analyzed"), Value: fmt.Sprintf("%d", report.IssueSummary.Total)}, {Label: feishuLabel(lang, "high_risk_issues"), Value: fmt.Sprintf("%d", report.IssueSummary.HighRisk)}, {Label: feishuLabel(lang, "missing_info"), Value: fmt.Sprintf("%d", report.IssueSummary.MissingInfo)}, })) } if hasItem(include, "prs") { elements = append(elements, fields([]fieldValue{ - {Label: feishuLabel(lang, "pull_requests"), Value: fmt.Sprintf("%d", report.PRSummary.Total)}, + {Label: feishuLabel(lang, "prs_analyzed"), Value: fmt.Sprintf("%d", report.PRSummary.Total)}, {Label: feishuLabel(lang, "high_risk_prs"), Value: fmt.Sprintf("%d", report.PRSummary.HighRisk)}, })) + if report.PRLifecycle != nil { + elements = append(elements, fields([]fieldValue{ + {Label: feishuLabel(lang, "open_prs"), Value: fmt.Sprintf("%d", report.PRLifecycle.Open)}, + {Label: feishuLabel(lang, "merged_prs"), Value: fmt.Sprintf("%d", report.PRLifecycle.Merged)}, + {Label: feishuLabel(lang, "closed_prs"), Value: fmt.Sprintf("%d", report.PRLifecycle.ClosedOrRejected)}, + })) + } + if report.PRReviewAudit != nil { + elements = append(elements, fields([]fieldValue{ + {Label: feishuLabel(lang, "review_audited"), Value: fmt.Sprintf("%d", report.PRReviewAudit.Audited)}, + {Label: feishuLabel(lang, "reviewed_prs"), Value: fmt.Sprintf("%d", report.PRReviewAudit.Reviewed)}, + {Label: feishuLabel(lang, "unreviewed_prs"), Value: fmt.Sprintf("%d", report.PRReviewAudit.Unreviewed)}, + {Label: feishuLabel(lang, "needs_re_review"), Value: fmt.Sprintf("%d", report.PRReviewAudit.NeedsReReview)}, + {Label: feishuLabel(lang, "formal_reviews"), Value: fmt.Sprintf("%d", report.PRReviewAudit.FormalReviews)}, + })) + elements = append(elements, div(fmt.Sprintf("**%s**\n%s", + feishuLabel(lang, "review_actor_attribution"), + bulletList(reviewAuditActorLines(report.PRReviewAudit, lang), 6)))) + } if len(report.PRSummary.ReviewFocus) > 0 { elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "review_focus"), bulletList(localizeFeishuLines(report.PRSummary.ReviewFocus, lang), 4)))) } + if lines := riskSourceLines(report.PRSummary.RiskSources); len(lines) > 0 { + elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "risk_sources"), bulletList(lines, 8)))) + } } if len(report.Recommendations) > 0 { elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "recommendations"), bulletList(localizeFeishuLines(report.Recommendations, lang), 5)))) @@ -78,6 +100,7 @@ func BuildWorkflowCard(report workflow.RepoReportResult, include []string, title if strings.TrimSpace(docURL) != "" { elements = append(elements, actionButton(feishuLabel(lang, "open_feishu_report"), docURL)) } + elements = append(elements, note(feishuLabel(lang, "analysis_scope"))) elements = append(elements, note(feishuLabel(lang, "preview_note"))) return baseCard(title, templateForRisk(report.RiskLevel), elements) } diff --git a/shortcuts/feishu/digest.go b/shortcuts/feishu/digest.go index 967f990..e11d2ea 100644 --- a/shortcuts/feishu/digest.go +++ b/shortcuts/feishu/digest.go @@ -3,6 +3,7 @@ package feishu import ( "fmt" "io" + "sort" "strings" "text/tabwriter" @@ -10,24 +11,27 @@ import ( ) 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"` + Role string `json:"role"` + Repository string `json:"repository"` + RepositoryURL string `json:"repository_url,omitempty"` + DocURL string `json:"doc_url,omitempty"` + HealthScore *int `json:"health_score,omitempty"` + HealthRisk string `json:"health_risk,omitempty"` + RiskLevel string `json:"risk_level"` + ReportScore int `json:"report_score"` + IssueTotal int `json:"issue_total"` + IssueHighRisk int `json:"issue_high_risk"` + IssueMissingInfo int `json:"issue_missing_info"` + PRTotal int `json:"pr_total"` + PRHighRisk int `json:"pr_high_risk"` + PRRiskSources map[string]int `json:"pr_risk_sources,omitempty"` + PRLifecycle *workflow.RepoPRLifecycle `json:"pr_lifecycle,omitempty"` + PRReviewAudit *workflow.RepoPRReviewAudit `json:"pr_review_audit,omitempty"` + ReviewFocus []string `json:"review_focus,omitempty"` + Recommendations []string `json:"recommendations,omitempty"` + AttentionItems []string `json:"attention_items,omitempty"` + NextSteps []string `json:"next_steps,omitempty"` + BoundaryDescription string `json:"boundary_description"` } func BuildOwnerDigest(report workflow.RepoReportResult, docURL string) RoleDigest { @@ -70,6 +74,9 @@ func BuildOwnerDigest(report workflow.RepoReportResult, docURL string) RoleDiges IssueMissingInfo: report.IssueSummary.MissingInfo, PRTotal: report.PRSummary.Total, PRHighRisk: report.PRSummary.HighRisk, + PRRiskSources: report.PRSummary.RiskSources, + PRLifecycle: report.PRLifecycle, + PRReviewAudit: report.PRReviewAudit, ReviewFocus: report.PRSummary.ReviewFocus, Recommendations: report.Recommendations, AttentionItems: uniqueDigestStrings(attention), @@ -118,6 +125,9 @@ func BuildContributorDigest(report workflow.RepoReportResult, docURL string) Rol IssueMissingInfo: report.IssueSummary.MissingInfo, PRTotal: report.PRSummary.Total, PRHighRisk: report.PRSummary.HighRisk, + PRRiskSources: report.PRSummary.RiskSources, + PRLifecycle: report.PRLifecycle, + PRReviewAudit: report.PRReviewAudit, ReviewFocus: report.PRSummary.ReviewFocus, Recommendations: report.Recommendations, AttentionItems: limitStrings(uniqueDigestStrings(attention), 8), @@ -140,8 +150,8 @@ func buildDigestCard(digest RoleDigest, title string, role string, lang string) fields([]fieldValue{ {Label: feishuLabel(lang, "report_score"), Value: fmt.Sprintf("%d", digest.ReportScore)}, {Label: feishuLabel(lang, "risk_level"), Value: digest.RiskLevel}, - {Label: feishuLabel(lang, "issues"), Value: fmt.Sprintf("%d", digest.IssueTotal)}, - {Label: feishuLabel(lang, "pull_requests"), Value: fmt.Sprintf("%d", digest.PRTotal)}, + {Label: feishuLabel(lang, "issues_analyzed"), Value: fmt.Sprintf("%d", digest.IssueTotal)}, + {Label: feishuLabel(lang, "prs_analyzed"), Value: fmt.Sprintf("%d", digest.PRTotal)}, }), fields([]fieldValue{ {Label: feishuLabel(lang, "high_risk_issues"), Value: fmt.Sprintf("%d", digest.IssueHighRisk)}, @@ -156,9 +166,31 @@ func buildDigestCard(digest RoleDigest, title string, role string, lang string) {Label: feishuLabel(lang, "health_risk"), Value: digest.HealthRisk}, })) } + if digest.PRLifecycle != nil { + elements = append(elements, fields([]fieldValue{ + {Label: feishuLabel(lang, "open_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.Open)}, + {Label: feishuLabel(lang, "merged_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.Merged)}, + {Label: feishuLabel(lang, "closed_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.ClosedOrRejected)}, + })) + } + if digest.PRReviewAudit != nil { + elements = append(elements, fields([]fieldValue{ + {Label: feishuLabel(lang, "review_audited"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Audited)}, + {Label: feishuLabel(lang, "reviewed_prs"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Reviewed)}, + {Label: feishuLabel(lang, "unreviewed_prs"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Unreviewed)}, + {Label: feishuLabel(lang, "needs_re_review"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.NeedsReReview)}, + {Label: feishuLabel(lang, "formal_reviews"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.FormalReviews)}, + })) + elements = append(elements, div(fmt.Sprintf("**%s**\n%s", + feishuLabel(lang, "review_actor_attribution"), + bulletList(reviewAuditActorLines(digest.PRReviewAudit, lang), 6)))) + } if len(digest.AttentionItems) > 0 { elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "attention"), bulletList(localizeFeishuLines(digest.AttentionItems, lang), 5)))) } + if lines := riskSourceLines(digest.PRRiskSources); len(lines) > 0 { + elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "risk_sources"), bulletList(lines, 8)))) + } if len(digest.NextSteps) > 0 { elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "suggested_next_steps"), bulletList(localizeFeishuLines(digest.NextSteps, lang), 5)))) } @@ -168,6 +200,7 @@ func buildDigestCard(digest RoleDigest, title string, role string, lang string) if digest.DocURL != "" { elements = append(elements, actionButton(feishuLabel(lang, "open_feishu_report"), digest.DocURL)) } + elements = append(elements, note(feishuLabel(lang, "analysis_scope"))) elements = append(elements, note(localizedBoundary(digest, lang))) template := templateForRisk(digest.RiskLevel) if role == "contributor" && digest.PRSummaryNeedsAttention() { @@ -228,6 +261,40 @@ func writeDigestMarkdown(w io.Writer, digest RoleDigest, lang string) error { lines = append(lines, fmt.Sprintf("- Health score: `%d`; health risk: `%s`", *digest.HealthScore, firstNonEmpty(digest.HealthRisk, "unknown"))) } } + if digest.PRLifecycle != nil { + if isChineseLang(lang) { + lines = append(lines, fmt.Sprintf("- PR 生命周期:开放 `%d`,已合并 `%d`,已关闭/拒绝 `%d`", + digest.PRLifecycle.Open, + digest.PRLifecycle.Merged, + digest.PRLifecycle.ClosedOrRejected, + )) + } else { + lines = append(lines, fmt.Sprintf("- PR lifecycle: open `%d`, merged `%d`, closed/rejected `%d`", + digest.PRLifecycle.Open, + digest.PRLifecycle.Merged, + digest.PRLifecycle.ClosedOrRejected, + )) + } + } + if digest.PRReviewAudit != nil { + if isChineseLang(lang) { + lines = append(lines, fmt.Sprintf("- Review 判别:已归因 `%d`,已被 review `%d`,未被 review `%d`,待重新 review `%d`,正式 Review `%d`", + digest.PRReviewAudit.Audited, + digest.PRReviewAudit.Reviewed, + digest.PRReviewAudit.Unreviewed, + digest.PRReviewAudit.NeedsReReview, + digest.PRReviewAudit.FormalReviews, + )) + } else { + lines = append(lines, fmt.Sprintf("- Review audit: audited `%d`, reviewed `%d`, unreviewed `%d`, needs re-review `%d`, formal reviews `%d`", + digest.PRReviewAudit.Audited, + digest.PRReviewAudit.Reviewed, + digest.PRReviewAudit.Unreviewed, + digest.PRReviewAudit.NeedsReReview, + digest.PRReviewAudit.FormalReviews, + )) + } + } if digest.RepositoryURL != "" { if isChineseLang(lang) { lines = append(lines, "- GitLink 仓库:"+digest.RepositoryURL) @@ -254,6 +321,12 @@ func writeDigestMarkdown(w io.Writer, digest RoleDigest, lang string) error { return err } } + if lines := riskSourceLines(digest.PRRiskSources); len(lines) > 0 { + heading := feishuLabel(lang, "risk_sources") + if _, err := fmt.Fprintf(w, "\n## %s\n\n%s\n", heading, bulletList(lines, 8)); err != nil { + return err + } + } if len(digest.NextSteps) > 0 { heading := "Suggested next steps" if isChineseLang(lang) { @@ -272,28 +345,42 @@ func digestMarkdownLines(digest RoleDigest, lang string) []string { return []string{ fmt.Sprintf("- 报告分数:`%d`", digest.ReportScore), fmt.Sprintf("- 风险等级:`%s`", firstNonEmpty(digest.RiskLevel, "unknown")), - fmt.Sprintf("- Issue:总数 `%d`,高风险 `%d`,信息缺失 `%d`", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo), - fmt.Sprintf("- PR:总数 `%d`,高风险 `%d`", digest.PRTotal, digest.PRHighRisk), + fmt.Sprintf("- 已分析 Issue:`%d`,其中高风险 `%d`,信息缺失 `%d`", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo), + fmt.Sprintf("- 已分析 PR:`%d`,其中高风险 `%d`", digest.PRTotal, digest.PRHighRisk), + "- " + feishuLabel(lang, "analysis_scope"), } } return []string{ fmt.Sprintf("- Report score: `%d`", digest.ReportScore), fmt.Sprintf("- Risk level: `%s`", firstNonEmpty(digest.RiskLevel, "unknown")), - fmt.Sprintf("- Issues: `%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), + fmt.Sprintf("- Issues analyzed: `%d`, including `%d` high risk and `%d` missing info", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo), + fmt.Sprintf("- Pull requests analyzed: `%d`, including `%d` high risk", digest.PRTotal, digest.PRHighRisk), + "- " + feishuLabel(lang, "analysis_scope"), } } func writeDigestTable(w io.Writer, digest RoleDigest, lang string) error { tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - header := "ROLE\tREPOSITORY\tRISK\tSCORE\tISSUES\tHIGH_RISK_ISSUES\tPRS\tHIGH_RISK_PRS\tATTENTION" + header := "ROLE\tREPOSITORY\tRISK\tSCORE\tISSUES_ANALYZED\tHIGH_RISK_ISSUES\tPRS_ANALYZED\tHIGH_RISK_PRS\tOPEN_PRS\tMERGED_PRS\tCLOSED_PRS\tREVIEWED_PRS\tUNREVIEWED_PRS\tNEEDS_RE_REVIEW\tATTENTION" if isChineseLang(lang) { - header = "角色\t仓库\t风险\t分数\tIssue\t高风险Issue\tPR\t高风险PR\t关注项" + header = "角色\t仓库\t风险\t分数\t已分析Issue\t高风险Issue\t已分析PR\t高风险PR\t开放PR\t已合并PR\t已关闭PR\t已Review PR\t未Review PR\t待重新Review\t关注项" } if _, err := fmt.Fprintln(tw, header); err != nil { return err } - if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\n", + openPRs, mergedPRs, closedPRs := 0, 0, 0 + if digest.PRLifecycle != nil { + openPRs = digest.PRLifecycle.Open + mergedPRs = digest.PRLifecycle.Merged + closedPRs = digest.PRLifecycle.ClosedOrRejected + } + reviewedPRs, unreviewedPRs, needsReReviewPRs := 0, 0, 0 + if digest.PRReviewAudit != nil { + reviewedPRs = digest.PRReviewAudit.Reviewed + unreviewedPRs = digest.PRReviewAudit.Unreviewed + needsReReviewPRs = digest.PRReviewAudit.NeedsReReview + } + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n", digest.Role, digest.Repository, digest.RiskLevel, @@ -302,6 +389,12 @@ func writeDigestTable(w io.Writer, digest RoleDigest, lang string) error { digest.IssueHighRisk, digest.PRTotal, digest.PRHighRisk, + openPRs, + mergedPRs, + closedPRs, + reviewedPRs, + unreviewedPRs, + needsReReviewPRs, len(digest.AttentionItems), ); err != nil { return err @@ -309,6 +402,34 @@ func writeDigestTable(w io.Writer, digest RoleDigest, lang string) error { return tw.Flush() } +func riskSourceLines(sources map[string]int) []string { + if len(sources) == 0 { + return nil + } + keys := make([]string, 0, len(sources)) + for key := range sources { + keys = append(keys, key) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, key := range keys { + lines = append(lines, fmt.Sprintf("%s: %d", key, sources[key])) + } + return lines +} + +func reviewAuditActorLines(audit *workflow.RepoPRReviewAudit, lang string) []string { + if audit == nil { + return nil + } + return []string{ + fmt.Sprintf("%s: %d", feishuLabel(lang, "reviewer_comments"), audit.ReviewerComments), + fmt.Sprintf("%s: %d", feishuLabel(lang, "submitter_comments"), audit.SubmitterComments), + fmt.Sprintf("%s: %d", feishuLabel(lang, "participant_comments"), audit.ParticipantComments), + fmt.Sprintf("%s: %d", feishuLabel(lang, "system_events"), audit.SystemEvents), + } +} + func digestHealth(report workflow.RepoReportResult) (*int, string) { if report.Health == nil { return nil, "" diff --git a/shortcuts/feishu/doc_export.go b/shortcuts/feishu/doc_export.go index 0eb6452..a3dc826 100644 --- a/shortcuts/feishu/doc_export.go +++ b/shortcuts/feishu/doc_export.go @@ -189,6 +189,26 @@ func BuildDocBlocks(report workflow.RepoReportResult, lang string) []DocBlock { textBlock(fmt.Sprintf(feishuLabel(lang, "doc_health"), healthScore, healthRisk)), textBlock(fmt.Sprintf(feishuLabel(lang, "doc_issues"), report.IssueSummary.Total, report.IssueSummary.HighRisk, report.IssueSummary.MissingInfo)), textBlock(fmt.Sprintf(feishuLabel(lang, "doc_prs"), report.PRSummary.Total, report.PRSummary.HighRisk)), + textBlock(feishuLabel(lang, "analysis_scope")), + } + if report.PRLifecycle != nil { + blocks = append(blocks, textBlock(fmt.Sprintf( + "%s=%d; %s=%d; %s=%d", + feishuLabel(lang, "open_prs"), report.PRLifecycle.Open, + feishuLabel(lang, "merged_prs"), report.PRLifecycle.Merged, + feishuLabel(lang, "closed_prs"), report.PRLifecycle.ClosedOrRejected, + ))) + } + if report.PRReviewAudit != nil { + blocks = append(blocks, textBlock(fmt.Sprintf( + "%s=%d; %s=%d; %s=%d; %s=%d; %s=%d", + feishuLabel(lang, "review_audited"), report.PRReviewAudit.Audited, + feishuLabel(lang, "reviewed_prs"), report.PRReviewAudit.Reviewed, + feishuLabel(lang, "unreviewed_prs"), report.PRReviewAudit.Unreviewed, + feishuLabel(lang, "needs_re_review"), report.PRReviewAudit.NeedsReReview, + feishuLabel(lang, "formal_reviews"), report.PRReviewAudit.FormalReviews, + ))) + blocks = append(blocks, textBlock(feishuLabel(lang, "review_actor_attribution")+":\n"+joinLines(reviewAuditActorLines(report.PRReviewAudit, lang), 6))) } if len(report.PRSummary.ReviewFocus) > 0 { blocks = append(blocks, textBlock(feishuLabel(lang, "doc_review_focus")+":\n"+joinLines(localizeFeishuLines(report.PRSummary.ReviewFocus, lang), 6))) diff --git a/shortcuts/feishu/feishu_test.go b/shortcuts/feishu/feishu_test.go index d191b14..3554fbc 100644 --- a/shortcuts/feishu/feishu_test.go +++ b/shortcuts/feishu/feishu_test.go @@ -77,6 +77,18 @@ func TestBuildWorkflowCardIncludesDocButton(t *testing.T) { if err != nil { t.Fatalf("readWorkflowReport returned error: %v", err) } + report.PRSummary.RiskSources = map[string]int{"security-sensitive keyword": 2} + report.PRReviewAudit = &workflow.RepoPRReviewAudit{ + Audited: 3, + Reviewed: 2, + Unreviewed: 1, + NeedsReReview: 1, + FormalReviews: 2, + ReviewerComments: 4, + SubmitterComments: 3, + ParticipantComments: 1, + SystemEvents: 2, + } card := BuildWorkflowCard(report, parseList(defaultInclude), "", "en", "https://example.feishu.cn/wiki/node") encoded, err := json.Marshal(card) if err != nil { @@ -85,6 +97,15 @@ func TestBuildWorkflowCardIncludesDocButton(t *testing.T) { if !strings.Contains(string(encoded), "Open Feishu report") { t.Fatalf("card missing doc button: %s", string(encoded)) } + if !strings.Contains(string(encoded), "Issues analyzed") || !strings.Contains(string(encoded), "not repository totals") { + t.Fatalf("card missing analyzed-count boundary: %s", string(encoded)) + } + if !strings.Contains(string(encoded), "PR risk rule sources") || !strings.Contains(string(encoded), "security-sensitive keyword: 2") { + t.Fatalf("card missing PR risk sources: %s", string(encoded)) + } + if !strings.Contains(string(encoded), "Reviewed PRs") || !strings.Contains(string(encoded), "Needs re-review") || !strings.Contains(string(encoded), "Reviewer comments: 4") { + t.Fatalf("card missing PR review audit: %s", string(encoded)) + } } func TestWebhookClientSendsPayload(t *testing.T) { @@ -186,6 +207,11 @@ func TestOwnerAndContributorDigestMapping(t *testing.T) { if owner.IssueTotal != report.IssueSummary.Total || owner.PRTotal != report.PRSummary.Total { t.Fatalf("owner digest counts = %+v", owner) } + report.PRReviewAudit = &workflow.RepoPRReviewAudit{Audited: 2, Reviewed: 1, Unreviewed: 1, NeedsReReview: 1, FormalReviews: 1} + owner = BuildOwnerDigest(report, "https://tenant.feishu.cn/wiki/node") + if owner.PRReviewAudit == nil || owner.PRReviewAudit.Reviewed != 1 { + t.Fatalf("owner digest missing review audit: %+v", owner) + } contributor := BuildContributorDigest(report, "") if contributor.Role != "contributor" { t.Fatalf("contributor digest role = %q", contributor.Role) @@ -201,6 +227,9 @@ func TestOwnerAndContributorDigestMapping(t *testing.T) { if !strings.Contains(string(encoded), "Open GitLink repository") { t.Fatalf("owner card missing repository button: %s", string(encoded)) } + if !strings.Contains(string(encoded), "Issues analyzed") || !strings.Contains(string(encoded), "not repository totals") { + t.Fatalf("owner card missing analyzed-count boundary: %s", string(encoded)) + } } func TestTaskCandidatesAreStable(t *testing.T) { diff --git a/shortcuts/feishu/l10n.go b/shortcuts/feishu/l10n.go index f7d0022..6c6af69 100644 --- a/shortcuts/feishu/l10n.go +++ b/shortcuts/feishu/l10n.go @@ -49,6 +49,7 @@ func localizeFeishuLines(values []string, lang string) []string { var feishuLabelsEN = map[string]string{ "attention": "Attention", + "analysis_scope": "Counts are items analyzed from workflow JSON and may be limited by --issue-limit/--pr-limit; they are not repository totals.", "boundary_contributor": "Contributor digest is role-oriented, not personalized. It does not use Feishu open_id or union_id routing.", "boundary_owner": "Owner digest is a read-only summary. It does not modify GitLink or Feishu resources.", "bot_generated": "Generated by gitlink-cli feishu +bot-test.", @@ -56,8 +57,8 @@ var feishuLabelsEN = map[string]string{ "bot_status": "Status", "bot_title": "GitLink Feishu integration test", "doc_health": "Health score: %s; health risk: %s", - "doc_issues": "Issues: total=%d, high_risk=%d, missing_info=%d", - "doc_prs": "Pull Requests: total=%d, high_risk=%d", + "doc_issues": "Issues analyzed: %d; high_risk=%d, missing_info=%d", + "doc_prs": "Pull Requests analyzed: %d; high_risk=%d", "doc_reasoning": "Reasoning", "doc_recommendations": "Recommendations", "doc_report_score": "Report score: %d", @@ -70,20 +71,36 @@ var feishuLabelsEN = map[string]string{ "high_risk_issues": "High-risk issues", "high_risk_prs": "High-risk PRs", "issues": "Issues", + "issues_analyzed": "Issues analyzed", "missing_info": "Missing info", "missing_info_issues": "Missing-info issues", + "merged_prs": "Merged PRs", + "closed_prs": "Closed/rejected PRs", + "open_prs": "Open PRs", "open_feishu_report": "Open Feishu report", "open_gitlink_repository": "Open GitLink repository", "owner_digest_title": "GitLink owner digest: %s", "contributor_digest_title": "GitLink contributor digest: %s", "preview_note": "Preview is read-only. Bitable records are generated locally by +bitable-records.", "pull_requests": "Pull requests", + "prs_analyzed": "PRs analyzed", "ready": "Ready", "recommendations": "Recommendations", "report_score": "Report score", "repository": "Repository", "review_focus": "Review focus", + "review_audited": "PRs review-audited", + "reviewed_prs": "Reviewed PRs", + "unreviewed_prs": "Unreviewed PRs", + "needs_re_review": "Needs re-review", + "formal_reviews": "Formal reviews", + "review_actor_attribution": "Review actor attribution", + "reviewer_comments": "Reviewer comments", + "submitter_comments": "Submitter comments", + "participant_comments": "Participant comments", + "system_events": "System events", "risk_level": "Risk level", + "risk_sources": "PR risk rule sources", "source": "Source", "suggested_next_steps": "Suggested next steps", "task_description_default": "Workflow recommendation from gitlink-cli repo report.", @@ -96,6 +113,7 @@ var feishuLabelsEN = map[string]string{ var feishuLabelsZH = map[string]string{ "attention": "需要关注", + "analysis_scope": "数量表示 workflow JSON 中实际分析的条目,可能受 --issue-limit/--pr-limit 限制,不代表仓库总量。", "boundary_contributor": "贡献者摘要是按角色生成的汇总,不是基于飞书 open_id 或 union_id 的个人定向推送。", "boundary_owner": "Owner 摘要是只读汇总,不会修改 GitLink 或飞书资源。", "bot_generated": "由 gitlink-cli feishu +bot-test 生成。", @@ -103,8 +121,8 @@ var feishuLabelsZH = map[string]string{ "bot_status": "状态", "bot_title": "GitLink 飞书集成测试", "doc_health": "健康分:%s;健康风险:%s", - "doc_issues": "Issue:总数=%d,高风险=%d,信息缺失=%d", - "doc_prs": "PR:总数=%d,高风险=%d", + "doc_issues": "已分析 Issue:%d;高风险=%d,信息缺失=%d", + "doc_prs": "已分析 PR:%d;高风险=%d", "doc_reasoning": "判断依据", "doc_recommendations": "建议操作", "doc_report_score": "报告分数:%d", @@ -117,20 +135,36 @@ var feishuLabelsZH = map[string]string{ "high_risk_issues": "高风险 Issue", "high_risk_prs": "高风险 PR", "issues": "Issue", + "issues_analyzed": "已分析 Issue", "missing_info": "信息缺失", "missing_info_issues": "信息缺失 Issue", + "merged_prs": "已合并 PR", + "closed_prs": "已关闭/拒绝 PR", + "open_prs": "开放 PR", "open_feishu_report": "打开飞书报告", "open_gitlink_repository": "打开 GitLink 仓库", "owner_digest_title": "GitLink Owner 摘要:%s", "contributor_digest_title": "GitLink 贡献者摘要:%s", "preview_note": "当前为只读预览。多维表格记录由 +bitable-records 在本地生成。", "pull_requests": "PR", + "prs_analyzed": "已分析 PR", "ready": "就绪", "recommendations": "建议操作", "report_score": "报告分数", "repository": "仓库", "review_focus": "审查重点", + "review_audited": "已审查归因 PR", + "reviewed_prs": "已被 review 的 PR", + "unreviewed_prs": "未被 review 的 PR", + "needs_re_review": "待重新 review", + "formal_reviews": "正式 Review", + "review_actor_attribution": "Review 评论来源归因", + "reviewer_comments": "Reviewer 评论", + "submitter_comments": "提交者评论", + "participant_comments": "参与者评论", + "system_events": "系统事件", "risk_level": "风险等级", + "risk_sources": "PR 风险规则来源", "source": "来源", "suggested_next_steps": "建议下一步", "task_description_default": "来自 gitlink-cli 仓库报告的工作流建议。", diff --git a/shortcuts/workflow/api_types.go b/shortcuts/workflow/api_types.go index 4a4fe53..8eb51fa 100644 --- a/shortcuts/workflow/api_types.go +++ b/shortcuts/workflow/api_types.go @@ -104,7 +104,7 @@ func apiList(data interface{}) []interface{} { case []interface{}: return v case map[string]interface{}: - for _, key := range []string{"issues", "pulls", "pull_requests", "files", "commits", "releases", "builds", "items", "records", "data"} { + for _, key := range []string{"issues", "pulls", "pull_requests", "reviews", "journals", "comments", "notes", "files", "commits", "releases", "builds", "items", "records", "data"} { if raw, ok := v[key]; ok { if items := apiList(raw); len(items) > 0 { return items diff --git a/shortcuts/workflow/api_types_test.go b/shortcuts/workflow/api_types_test.go index ec549f3..7cf5b0f 100644 --- a/shortcuts/workflow/api_types_test.go +++ b/shortcuts/workflow/api_types_test.go @@ -495,8 +495,27 @@ func TestQueryWithPageLimit(t *testing.T) { func TestIssueListQuery(t *testing.T) { q := issueListQuery("open") - if q.Get("state") != "open" { - t.Fatalf("issueListQuery state = %q", q.Get("state")) + if q.Get("category") != "opened" || q.Get("state") != "" { + t.Fatalf("issueListQuery = %v, want category=opened", q) + } + q = issueListQuery("closed") + if q.Get("category") != "closed" { + t.Fatalf("issueListQuery closed = %v", q) + } +} + +func TestPullListQuery(t *testing.T) { + q := pullListQuery("open") + if q.Get("status") != "0" || q.Get("state") != "" { + t.Fatalf("pullListQuery = %v, want status=0", q) + } + q = pullListQuery("merged") + if q.Get("status") != "1" { + t.Fatalf("pullListQuery merged = %v", q) + } + q = pullListQuery("closed") + if q.Get("status") != "2" { + t.Fatalf("pullListQuery closed = %v", q) } } diff --git a/shortcuts/workflow/health_fetch.go b/shortcuts/workflow/health_fetch.go index 62066c5..a9aa6a2 100644 --- a/shortcuts/workflow/health_fetch.go +++ b/shortcuts/workflow/health_fetch.go @@ -39,7 +39,7 @@ func FetchHealthInput(ctx *common.RuntimeContext, opts HealthFetchOptions) (Heal input.RecentActivityKnown, input.RecentActivityDays, input = updateRecentActivity(input, latestTimeFromItems(issues)) } - if prs, err := fetchAllListItems(ctx, workflowRepoPath(owner, repo)+"/pulls", issueListQuery("open"), 100); err != nil { + if prs, err := fetchAllListItems(ctx, workflowRepoPath(owner, repo)+"/pulls", pullListQuery("open"), 100); err != nil { notes = append(notes, ScoringNote{Metric: "open_prs", Note: fmt.Sprintf("pull request probe failed: %v", err)}) } else { input.OpenPRs = len(prs) @@ -222,16 +222,50 @@ func queryWithPageLimit(base url.Values, page, limit int) url.Values { func issueListQuery(state string) url.Values { q := url.Values{} - q.Set("state", state) + switch strings.ToLower(strings.TrimSpace(state)) { + case "open", "opened", "": + q.Set("category", "opened") + case "closed": + q.Set("category", "closed") + case "all": + q.Set("category", "all") + default: + q.Set("category", state) + } + return q +} + +func pullListQuery(state string) url.Values { + q := url.Values{} + switch strings.ToLower(strings.TrimSpace(state)) { + case "open", "opened": + q.Set("status", "0") + case "merged": + q.Set("status", "1") + case "closed": + q.Set("status", "2") + case "all", "": + default: + q.Set("status", state) + } return q } func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Values, pageSize int) ([]map[string]interface{}, error) { + return fetchListItems(ctx, path, baseQuery, pageSize, 0) +} + +func fetchListItems(ctx *common.RuntimeContext, path string, baseQuery url.Values, pageSize, maxItems int) ([]map[string]interface{}, error) { if pageSize <= 0 { - pageSize = 100 + pageSize = 50 + } + if maxItems > 0 && pageSize > maxItems { + pageSize = maxItems } all := []map[string]interface{}{} - for page := 1; ; page++ { + seen := map[string]struct{}{} + totalCount := 0 + for page := 1; page <= 1000; page++ { query := cloneValues(baseQuery) query.Set("page", fmt.Sprintf("%d", page)) query.Set("limit", fmt.Sprintf("%d", pageSize)) @@ -240,6 +274,12 @@ func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Va if err != nil { return nil, err } + if env.Meta != nil && env.Meta.TotalCount > totalCount { + totalCount = env.Meta.TotalCount + } + if dataTotal := apiListTotal(env.Data); dataTotal > totalCount { + totalCount = dataTotal + } items := apiList(env.Data) pageItems := make([]map[string]interface{}, 0, len(items)) for _, raw := range items { @@ -250,14 +290,52 @@ func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Va if len(pageItems) == 0 { break } - all = append(all, pageItems...) - if len(pageItems) < pageSize { + before := len(all) + for _, item := range pageItems { + key := listItemIdentity(item) + if key != "" { + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + } + all = append(all, item) + if maxItems > 0 && len(all) >= maxItems { + return all[:maxItems], nil + } + } + if totalCount > 0 && len(all) >= totalCount { + break + } + if len(all) == before { + break + } + if totalCount == 0 && len(pageItems) < pageSize { break } } return all, nil } +func apiListTotal(data interface{}) int { + object := apiObject(data) + for _, key := range []string{"total_count", "opened_count", "total"} { + if total := apiInt(object[key]); total > 0 { + return total + } + } + return 0 +} + +func listItemIdentity(item map[string]interface{}) string { + for _, key := range []string{"id", "database_id", "index", "number", "iid", "project_issues_index"} { + if value := apiString(item[key]); value != "" { + return key + ":" + value + } + } + return "" +} + func cloneValues(values url.Values) url.Values { if values == nil { return url.Values{} diff --git a/shortcuts/workflow/pr_fetch.go b/shortcuts/workflow/pr_fetch.go index e8a854f..b70e3c9 100644 --- a/shortcuts/workflow/pr_fetch.go +++ b/shortcuts/workflow/pr_fetch.go @@ -156,7 +156,7 @@ func prAPIObject(data interface{}) map[string]interface{} { } func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool) { - number := firstPRInt(item, "number", "iid", "pull_request_number") + number := firstPRInt(item, "number", "index", "iid", "pull_request_number") title := firstPRString(item, "title", "subject") if number == 0 && strings.TrimSpace(title) == "" { return PRSummaryInput{}, false @@ -164,18 +164,28 @@ func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool) body := firstPRString(item, "body", "description", "content") state := firstPRString(item, "state", "status") author := firstPRAuthor(item) + issueID := firstPRIssueID(item) base := firstPRBranch(item, "base_branch", "target_branch", "base") head := firstPRBranch(item, "head_branch", "source_branch", "head") + createdAt := firstPRTime(item, "created_at", "createdAt") + updatedAt := apiLatestTime( + firstPRTime(item, "updated_at", "updatedAt"), + firstPRTime(item, "last_updated_at", "lastUpdatedAt"), + firstPRTime(item, "last_activity_at", "lastActivityAt"), + ) additions := firstPRInt(item, "additions", "additions_count") deletions := firstPRInt(item, "deletions", "deletions_count") return PRSummaryInput{ Number: number, + IssueID: issueID, Title: title, Author: author, State: state, BaseBranch: base, HeadBranch: head, + CreatedAt: createdAt, + UpdatedAt: updatedAt, Body: body, Additions: additions, Deletions: deletions, @@ -298,6 +308,26 @@ func firstPRCommitAuthor(item map[string]interface{}) string { return "" } +func firstPRIssueID(item map[string]interface{}) int { + for _, key := range []string{"issue_id", "issueId"} { + if value, ok := item[key]; ok { + if id := apiInt(value); id != 0 { + return id + } + } + } + for _, key := range []string{"issue", "issue_info"} { + if raw, ok := item[key].(map[string]interface{}); ok { + for _, field := range []string{"id", "issue_id"} { + if id := apiInt(raw[field]); id != 0 { + return id + } + } + } + } + return 0 +} + func firstPRBranch(item map[string]interface{}, keys ...string) string { for _, key := range keys { if value, ok := item[key]; ok { diff --git a/shortcuts/workflow/pr_review_audit.go b/shortcuts/workflow/pr_review_audit.go new file mode 100644 index 0000000..6cbb7b0 --- /dev/null +++ b/shortcuts/workflow/pr_review_audit.go @@ -0,0 +1,354 @@ +package workflow + +import ( + "fmt" + "net/url" + "sort" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +const ( + reviewStandardFormal = "formal_review" + reviewStandardReviewerJournal = "reviewer_journal_feedback" + reviewStandardUnreviewed = "unreviewed" + + actorSubmitter = "submitter" + actorReviewer = "reviewer" + actorParticipant = "participant" + actorBot = "bot" + actorSystem = "system" + actorUnknown = "unknown" +) + +type normalizedPRReview struct { + Actor string + Status string + At time.Time +} + +func fetchPRReviewAudit(ctx *common.RuntimeContext, owner, repo string, prs []PRSummaryInput, maxItems int) (*RepoPRReviewAudit, []ScoringNote) { + if maxItems > 0 && len(prs) > maxItems { + prs = prs[:maxItems] + } + audit := &RepoPRReviewAudit{ + Source: "remote-read-only-fetch:reviews-and-journals", + PullRequests: make([]PRReviewAudit, 0, len(prs)), + } + notes := []ScoringNote{} + for _, pr := range prs { + item, itemNotes := fetchOnePRReviewAudit(ctx, owner, repo, pr) + notes = append(notes, itemNotes...) + audit.PullRequests = append(audit.PullRequests, item) + audit.Audited++ + if item.Reviewed { + audit.Reviewed++ + } else { + audit.Unreviewed++ + } + if item.NeedsReReview { + audit.NeedsReReview++ + } + audit.FormalReviews += item.FormalReviewCount + audit.ReviewerComments += item.ReviewerComments + audit.SubmitterComments += item.SubmitterComments + audit.ParticipantComments += item.ParticipantComments + audit.BotEvents += item.BotEvents + audit.SystemEvents += item.SystemEvents + audit.UnknownActorEvents += item.UnknownActorEvents + if len(item.Notes) > 0 { + audit.Errors += len(item.Notes) + } + } + return audit, uniqueScoringNotes(notes) +} + +func fetchOnePRReviewAudit(ctx *common.RuntimeContext, owner, repo string, pr PRSummaryInput) (PRReviewAudit, []ScoringNote) { + result := PRReviewAudit{ + Number: pr.Number, + Author: pr.Author, + IssueID: pr.IssueID, + ReviewStandard: reviewStandardUnreviewed, + FormalReviewStatus: "unreviewed", + } + notes := []ScoringNote{} + if result.Number <= 0 { + result.Notes = append(result.Notes, "missing PR number") + return result, []ScoringNote{{Metric: "repo_report_pr_review_audit", Note: "skipped PR review audit: missing PR number"}} + } + + if result.IssueID == 0 || strings.TrimSpace(result.Author) == "" { + base, err := fetchPRBase(ctx, owner, repo, result.Number) + if err != nil { + note := fmt.Sprintf("PR #%d base detail unavailable for review audit: %v", result.Number, err) + result.Notes = append(result.Notes, note) + notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note}) + } else { + if result.IssueID == 0 { + result.IssueID = base.IssueID + } + if strings.TrimSpace(result.Author) == "" { + result.Author = base.Author + } + if pr.CreatedAt.IsZero() { + pr.CreatedAt = base.CreatedAt + } + if pr.UpdatedAt.IsZero() { + pr.UpdatedAt = base.UpdatedAt + } + } + } + + reviews, err := fetchFormalPRReviews(ctx, owner, repo, result.Number) + if err != nil { + note := fmt.Sprintf("PR #%d formal reviews unavailable: %v", result.Number, err) + result.Notes = append(result.Notes, note) + notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note}) + } else { + result.FormalReviewCount = len(reviews) + result.FormalReviewStatus = summarizeFormalReviewStatus(reviews) + result.Reviewers = uniqueReviewerNames(reviews) + if latest := latestReviewTime(reviews); !latest.IsZero() { + result.LatestReviewerAt = latest.Format(time.RFC3339) + } + if result.FormalReviewCount > 0 { + result.Reviewed = true + result.ReviewStandard = reviewStandardFormal + } + } + + reviewerSet := reviewerSet(reviews) + var latestReviewerAt time.Time + if result.LatestReviewerAt != "" { + latestReviewerAt = apiTime(result.LatestReviewerAt) + } + var latestSubmitterAt time.Time + if result.IssueID == 0 { + note := fmt.Sprintf("PR #%d conversation journal skipped: missing associated issue id", result.Number) + result.Notes = append(result.Notes, note) + notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note}) + } else { + journals, err := fetchPRJournals(ctx, owner, repo, result.IssueID) + if err != nil { + note := fmt.Sprintf("PR #%d conversation journal unavailable: %v", result.Number, err) + result.Notes = append(result.Notes, note) + notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note}) + } else { + for _, journal := range journals { + switch classifyJournalActor(journal, result.Author, reviewerSet) { + case actorReviewer: + result.ReviewerComments++ + latestReviewerAt = apiLatestTime(latestReviewerAt, journalTime(journal)) + case actorSubmitter: + result.SubmitterComments++ + latestSubmitterAt = apiLatestTime(latestSubmitterAt, journalTime(journal)) + case actorParticipant: + result.ParticipantComments++ + case actorBot: + result.BotEvents++ + case actorSystem: + result.SystemEvents++ + default: + result.UnknownActorEvents++ + } + } + if !result.Reviewed && result.ReviewerComments > 0 { + result.Reviewed = true + result.ReviewStandard = reviewStandardReviewerJournal + } + } + } + if !latestReviewerAt.IsZero() { + result.LatestReviewerAt = latestReviewerAt.Format(time.RFC3339) + } + if !latestSubmitterAt.IsZero() { + result.LatestSubmitterAt = latestSubmitterAt.Format(time.RFC3339) + } + latestCommitAt := latestCommitTime(pr.Commits) + if !latestCommitAt.IsZero() { + result.LatestCommitAt = latestCommitAt.Format(time.RFC3339) + } + if !pr.UpdatedAt.IsZero() { + result.LatestPRUpdateAt = pr.UpdatedAt.Format(time.RFC3339) + } + result.NeedsReReview = needsReReview(latestReviewerAt, latestSubmitterAt, latestCommitAt, pr.UpdatedAt) + + return result, notes +} + +func fetchFormalPRReviews(ctx *common.RuntimeContext, owner, repo string, number int) ([]normalizedPRReview, error) { + items, err := fetchListItems(ctx, prPath(owner, repo, number)+"/reviews", url.Values{}, 50, 0) + if err != nil { + return nil, err + } + reviews := make([]normalizedPRReview, 0, len(items)) + for _, item := range items { + review := normalizedPRReview{ + Actor: firstReviewActor(item), + Status: strings.ToLower(strings.TrimSpace(firstPRString(item, "status", "state", "review_status"))), + At: journalTime(item), + } + if review.Actor == "" && review.Status == "" { + continue + } + if review.Status == "" { + review.Status = "common" + } + reviews = append(reviews, review) + } + return reviews, nil +} + +func latestReviewTime(reviews []normalizedPRReview) time.Time { + var latest time.Time + for _, review := range reviews { + latest = apiLatestTime(latest, review.At) + } + return latest +} + +func journalTime(item map[string]interface{}) time.Time { + return apiLatestTime( + firstPRTime(item, "updated_at", "updatedAt"), + firstPRTime(item, "created_at", "createdAt"), + ) +} + +func latestCommitTime(commits []PRCommit) time.Time { + var latest time.Time + for _, commit := range commits { + latest = apiLatestTime(latest, commit.Date) + } + return latest +} + +func needsReReview(latestReviewerAt, latestSubmitterAt, latestCommitAt, latestPRUpdateAt time.Time) bool { + if latestReviewerAt.IsZero() { + return false + } + return isAfter(latestSubmitterAt, latestReviewerAt) || + isAfter(latestCommitAt, latestReviewerAt) || + isAfter(latestPRUpdateAt, latestReviewerAt) +} + +func isAfter(value, baseline time.Time) bool { + return !value.IsZero() && !baseline.IsZero() && value.After(baseline) +} + +func fetchPRJournals(ctx *common.RuntimeContext, owner, repo string, issueID int) ([]map[string]interface{}, error) { + return fetchListItems(ctx, fmt.Sprintf("/v1/%s/%s/issues/%d/journals", owner, repo, issueID), url.Values{}, 50, 0) +} + +func firstReviewActor(item map[string]interface{}) string { + for _, key := range []string{"reviewer", "user", "author", "creator"} { + if value, ok := item[key]; ok { + if actor := apiAuthor(value); actor != "" { + return actor + } + } + } + return "" +} + +func summarizeFormalReviewStatus(reviews []normalizedPRReview) string { + if len(reviews) == 0 { + return "unreviewed" + } + hasApproved := false + hasCommon := false + for _, review := range reviews { + switch strings.ToLower(strings.TrimSpace(review.Status)) { + case "rejected", "reject", "changes_requested", "request_changes": + return "rejected" + case "approved", "approve": + hasApproved = true + default: + hasCommon = true + } + } + if hasApproved { + return "approved" + } + if hasCommon { + return "common" + } + return "reviewed" +} + +func uniqueReviewerNames(reviews []normalizedPRReview) []string { + set := map[string]string{} + for _, review := range reviews { + key := normalizeActorID(review.Actor) + if key != "" { + set[key] = review.Actor + } + } + keys := make([]string, 0, len(set)) + for key := range set { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]string, 0, len(keys)) + for _, key := range keys { + out = append(out, set[key]) + } + return out +} + +func reviewerSet(reviews []normalizedPRReview) map[string]bool { + set := map[string]bool{} + for _, review := range reviews { + if key := normalizeActorID(review.Actor); key != "" { + set[key] = true + } + } + return set +} + +func classifyJournalActor(item map[string]interface{}, author string, reviewers map[string]bool) string { + category := strings.ToLower(strings.TrimSpace(firstPRString(item, "operate_category", "category", "type", "event"))) + content := strings.TrimSpace(firstPRString(item, "notes", "note", "body", "content", "operate_content")) + actor := firstReviewActor(item) + if isSystemJournalEvent(category, content, actor) { + return actorSystem + } + if actor == "" { + return actorUnknown + } + if isBotActor(actor) { + return actorBot + } + actorID := normalizeActorID(actor) + if actorID != "" && actorID == normalizeActorID(author) { + return actorSubmitter + } + if actorID != "" && reviewers[actorID] { + return actorReviewer + } + return actorParticipant +} + +func isSystemJournalEvent(category, content, actor string) bool { + if strings.TrimSpace(content) == "" { + return true + } + switch category { + case "status", "state", "system", "relation", "assignee", "label", "milestone": + return true + } + if strings.TrimSpace(actor) == "" && category != "" { + return true + } + return false +} + +func isBotActor(actor string) bool { + actor = strings.ToLower(strings.TrimSpace(actor)) + return strings.Contains(actor, "bot") || strings.Contains(actor, "机器人") || strings.Contains(actor, "automation") +} + +func normalizeActorID(actor string) string { + return strings.ToLower(strings.TrimSpace(actor)) +} diff --git a/shortcuts/workflow/pr_summary.go b/shortcuts/workflow/pr_summary.go index 398bf1e..dc925fe 100644 --- a/shortcuts/workflow/pr_summary.go +++ b/shortcuts/workflow/pr_summary.go @@ -32,11 +32,14 @@ const ( type PRSummaryInput struct { Repository string `json:"repository"` Number int `json:"number"` + IssueID int `json:"issue_id,omitempty"` Title string `json:"title"` Author string `json:"author"` State string `json:"state"` BaseBranch string `json:"base_branch"` HeadBranch string `json:"head_branch"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` Body string `json:"body,omitempty"` ChangedFiles []PRChangedFile `json:"changed_files"` Commits []PRCommit `json:"commits"` @@ -75,6 +78,7 @@ type PRSummaryResult struct { CommitCount int `json:"commit_count"` ChangeType string `json:"change_type"` RiskLevel string `json:"risk_level"` + RiskReasons []string `json:"risk_reasons,omitempty"` ReviewFocus []string `json:"review_focus"` TestSuggestions []string `json:"test_suggestions"` MergeChecklist []string `json:"merge_checklist"` @@ -208,6 +212,7 @@ func AnalyzePRSummary(input PRSummaryInput, lang string) PRSummaryResult { CommitCount: len(input.Commits), ChangeType: changeType, RiskLevel: riskLevel, + RiskReasons: riskReasons, ReviewFocus: reviewFocus, TestSuggestions: testSuggestions, MergeChecklist: mergeChecklist, diff --git a/shortcuts/workflow/pr_summary_test.go b/shortcuts/workflow/pr_summary_test.go index 29983a9..4a8e6f7 100644 --- a/shortcuts/workflow/pr_summary_test.go +++ b/shortcuts/workflow/pr_summary_test.go @@ -82,6 +82,9 @@ func TestAnalyzePRSummaryAuthTokenCriticalRisk(t *testing.T) { if result.RiskLevel != PRRiskCritical { t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskCritical) } + if len(result.RiskReasons) != 1 || result.RiskReasons[0] != "security-sensitive keyword" { + t.Fatalf("RiskReasons = %v, want security-sensitive keyword", result.RiskReasons) + } } func TestAnalyzePRSummaryMixedFiles(t *testing.T) { diff --git a/shortcuts/workflow/render.go b/shortcuts/workflow/render.go index 366c2e2..32bd9ea 100644 --- a/shortcuts/workflow/render.go +++ b/shortcuts/workflow/render.go @@ -164,10 +164,22 @@ func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) err if len(result.Recommendations) > 0 { topRecommendation = truncateTableText(result.Recommendations[0], 96) } - if _, err := fmt.Fprintln(tw, "REPOSITORY\tREPORT_SCORE\tRISK\tHEALTH_SCORE\tISSUES\tHIGH_RISK_ISSUES\tPRS\tHIGH_RISK_PRS\tTOP_RECOMMENDATION"); err != nil { + openPRs, mergedPRs, closedPRs := "N/A", "N/A", "N/A" + if result.PRLifecycle != nil { + openPRs = fmt.Sprintf("%d", result.PRLifecycle.Open) + mergedPRs = fmt.Sprintf("%d", result.PRLifecycle.Merged) + closedPRs = fmt.Sprintf("%d", result.PRLifecycle.ClosedOrRejected) + } + reviewedPRs, unreviewedPRs, needsReReviewPRs := "N/A", "N/A", "N/A" + if result.PRReviewAudit != nil { + reviewedPRs = fmt.Sprintf("%d", result.PRReviewAudit.Reviewed) + unreviewedPRs = fmt.Sprintf("%d", result.PRReviewAudit.Unreviewed) + needsReReviewPRs = fmt.Sprintf("%d", result.PRReviewAudit.NeedsReReview) + } + if _, err := fmt.Fprintln(tw, "REPOSITORY\tREPORT_SCORE\tRISK\tHEALTH_SCORE\tISSUES_ANALYZED\tHIGH_RISK_ISSUES\tPRS_ANALYZED\tHIGH_RISK_PRS\tOPEN_PRS\tMERGED_PRS\tCLOSED_PRS\tREVIEWED_PRS\tUNREVIEWED_PRS\tNEEDS_RE_REVIEW\tTOP_RECOMMENDATION"); err != nil { return err } - if _, err := fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%d\t%d\t%d\t%d\t%s\n", + if _, err := fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%d\t%d\t%d\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", result.Repository, result.ReportScore, result.RiskLevel, @@ -176,6 +188,12 @@ func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) err result.IssueSummary.HighRisk, result.PRSummary.Total, result.PRSummary.HighRisk, + openPRs, + mergedPRs, + closedPRs, + reviewedPRs, + unreviewedPRs, + needsReReviewPRs, topRecommendation, ); err != nil { return err @@ -269,6 +287,36 @@ func writeRepoReportMarkdown(w io.Writer, result RepoReportResult, lang string) } writeCountMapMarkdown(w, "By type", result.PRSummary.ByType) writeCountMapMarkdown(w, "By risk", result.PRSummary.ByRisk) + writeCountMapMarkdown(w, "Risk rule sources", result.PRSummary.RiskSources) + if result.PRLifecycle != nil { + if _, err := fmt.Fprintf(w, "- Lifecycle totals: open `%d`, merged `%d`, closed/rejected `%d`, all states `%d`\n", + result.PRLifecycle.Open, + result.PRLifecycle.Merged, + result.PRLifecycle.ClosedOrRejected, + result.PRLifecycle.Total, + ); err != nil { + return err + } + } + if result.PRReviewAudit != nil { + if _, err := fmt.Fprintf(w, "- Review audit: audited `%d`, reviewed `%d`, unreviewed `%d`, needs re-review `%d`, formal reviews `%d`\n", + result.PRReviewAudit.Audited, + result.PRReviewAudit.Reviewed, + result.PRReviewAudit.Unreviewed, + result.PRReviewAudit.NeedsReReview, + result.PRReviewAudit.FormalReviews, + ); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "- Review actor attribution: reviewer comments `%d`, submitter comments `%d`, participant comments `%d`, system events `%d`\n", + result.PRReviewAudit.ReviewerComments, + result.PRReviewAudit.SubmitterComments, + result.PRReviewAudit.ParticipantComments, + result.PRReviewAudit.SystemEvents, + ); err != nil { + return err + } + } if len(result.PRSummary.ReviewFocus) > 0 { if _, err := fmt.Fprintln(w, "- Review focus:"); err != nil { return err diff --git a/shortcuts/workflow/repo_report.go b/shortcuts/workflow/repo_report.go index 4043be6..f7a7fca 100644 --- a/shortcuts/workflow/repo_report.go +++ b/shortcuts/workflow/repo_report.go @@ -12,24 +12,76 @@ import ( ) type RepoReportInput struct { - Repository string `json:"repository"` - Health *HealthInput `json:"health,omitempty"` - Issues []IssueInput `json:"issues,omitempty"` - PullRequests []PRSummaryInput `json:"pull_requests,omitempty"` - Source string `json:"source"` + Repository string `json:"repository"` + Health *HealthInput `json:"health,omitempty"` + Issues []IssueInput `json:"issues,omitempty"` + PullRequests []PRSummaryInput `json:"pull_requests,omitempty"` + PRLifecycle *RepoPRLifecycle `json:"pr_lifecycle,omitempty"` + PRReviewAudit *RepoPRReviewAudit `json:"pr_review_audit,omitempty"` + Source string `json:"source"` } type RepoReportResult struct { - Repository string `json:"repository"` - Health *HealthResult `json:"health,omitempty"` - IssueSummary RepoIssueSummary `json:"issue_summary"` - PRSummary RepoPRSummary `json:"pr_summary"` - Recommendations []string `json:"recommendations"` - RiskLevel string `json:"risk_level"` - ReportScore int `json:"report_score"` - Sections []string `json:"sections"` - Reasoning []string `json:"reasoning"` - Source string `json:"source"` + Repository string `json:"repository"` + Health *HealthResult `json:"health,omitempty"` + IssueSummary RepoIssueSummary `json:"issue_summary"` + PRSummary RepoPRSummary `json:"pr_summary"` + PRLifecycle *RepoPRLifecycle `json:"pr_lifecycle,omitempty"` + PRReviewAudit *RepoPRReviewAudit `json:"pr_review_audit,omitempty"` + Recommendations []string `json:"recommendations"` + RiskLevel string `json:"risk_level"` + ReportScore int `json:"report_score"` + Sections []string `json:"sections"` + Reasoning []string `json:"reasoning"` + Source string `json:"source"` +} + +type RepoPRLifecycle struct { + Open int `json:"open"` + Merged int `json:"merged"` + ClosedOrRejected int `json:"closed_or_rejected"` + Total int `json:"total"` + Source string `json:"source"` +} + +type RepoPRReviewAudit struct { + Audited int `json:"audited"` + Reviewed int `json:"reviewed"` + Unreviewed int `json:"unreviewed"` + NeedsReReview int `json:"needs_re_review"` + FormalReviews int `json:"formal_reviews"` + ReviewerComments int `json:"reviewer_comments"` + SubmitterComments int `json:"submitter_comments"` + ParticipantComments int `json:"participant_comments"` + BotEvents int `json:"bot_events"` + SystemEvents int `json:"system_events"` + UnknownActorEvents int `json:"unknown_actor_events"` + Errors int `json:"errors"` + Source string `json:"source"` + PullRequests []PRReviewAudit `json:"pull_requests,omitempty"` +} + +type PRReviewAudit struct { + Number int `json:"number"` + Author string `json:"author,omitempty"` + IssueID int `json:"issue_id,omitempty"` + Reviewed bool `json:"reviewed"` + NeedsReReview bool `json:"needs_re_review"` + ReviewStandard string `json:"review_standard"` + FormalReviewStatus string `json:"formal_review_status"` + FormalReviewCount int `json:"formal_review_count"` + ReviewerComments int `json:"reviewer_comments"` + SubmitterComments int `json:"submitter_comments"` + ParticipantComments int `json:"participant_comments"` + BotEvents int `json:"bot_events"` + SystemEvents int `json:"system_events"` + UnknownActorEvents int `json:"unknown_actor_events"` + Reviewers []string `json:"reviewers,omitempty"` + LatestReviewerAt string `json:"latest_reviewer_at,omitempty"` + LatestSubmitterAt string `json:"latest_submitter_at,omitempty"` + LatestCommitAt string `json:"latest_commit_at,omitempty"` + LatestPRUpdateAt string `json:"latest_pr_update_at,omitempty"` + Notes []string `json:"notes,omitempty"` } type RepoIssueSummary struct { @@ -44,6 +96,7 @@ type RepoPRSummary struct { Total int `json:"total"` ByType map[string]int `json:"by_type"` ByRisk map[string]int `json:"by_risk"` + RiskSources map[string]int `json:"risk_sources,omitempty"` HighRisk int `json:"high_risk"` ReviewFocus []string `json:"review_focus"` } @@ -54,11 +107,14 @@ func newRepoReportShortcut() *common.Shortcut { Description: "Generate a read-only repository workflow report", Flags: []common.Flag{ {Name: "from", Usage: "Read repository report input from a JSON file"}, - {Name: "issue-limit", Usage: "Maximum issues to fetch and analyze", Default: "20"}, - {Name: "pr-limit", Usage: "Maximum pull requests to fetch and summarize", Default: "10"}, + {Name: "issue-limit", Usage: "Maximum issues to fetch and analyze; 0 analyzes all open issues", Default: "0"}, + {Name: "pr-limit", Usage: "Maximum pull requests to fetch and summarize; 0 analyzes all open pull requests", Default: "0"}, {Name: "stale-days", Usage: "Days before an issue or PR is considered stale", Default: "30"}, {Name: "include-issues", Usage: "Include issue triage summary", Bool: true, Default: "true"}, {Name: "include-prs", Usage: "Include pull request summary", Bool: true, Default: "true"}, + {Name: "include-pr-lifecycle", Usage: "Include open/merged/closed PR totals", Bool: true, Default: "true"}, + {Name: "include-pr-review-audit", Usage: "Read formal reviews and PR conversation journals for actor attribution", Bool: true, Default: "false"}, + {Name: "pr-review-audit-limit", Usage: "Maximum analyzed PRs to review-audit; 0 audits every analyzed PR", Default: "0"}, {Name: "include-health", Usage: "Include repository health summary", Bool: true, Default: "true"}, {Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN}, }, @@ -104,11 +160,11 @@ func collectRepoReportInput(ctx *common.RuntimeContext) (RepoReportInput, []Scor return input, nil, nil } - issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-limit") + issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 0, "issue-limit") if err != nil { return RepoReportInput{}, nil, err } - prLimit, err := parseIntArg(ctx.Arg("pr-limit"), 10, "pr-limit") + prLimit, err := parseIntArg(ctx.Arg("pr-limit"), 0, "pr-limit") if err != nil { return RepoReportInput{}, nil, err } @@ -116,14 +172,21 @@ func collectRepoReportInput(ctx *common.RuntimeContext) (RepoReportInput, []Scor if err != nil { return RepoReportInput{}, nil, err } + prReviewAuditLimit, err := parseIntArg(ctx.Arg("pr-review-audit-limit"), 0, "pr-review-audit-limit") + if err != nil { + return RepoReportInput{}, nil, err + } return FetchRepoReportInput(ctx, RepoReportFetchOptions{ - IssueLimit: issueLimit, - PRLimit: prLimit, - StaleDays: staleDays, - IncludeIssues: parseBoolArg(ctx.Arg("include-issues")), - IncludePRs: parseBoolArg(ctx.Arg("include-prs")), - IncludeHealth: parseBoolArg(ctx.Arg("include-health")), + IssueLimit: issueLimit, + PRLimit: prLimit, + StaleDays: staleDays, + PRReviewAuditLimit: prReviewAuditLimit, + IncludeIssues: parseBoolArg(ctx.Arg("include-issues")), + IncludePRs: parseBoolArg(ctx.Arg("include-prs")), + IncludePRLifecycle: parseBoolArg(ctx.Arg("include-pr-lifecycle")), + IncludePRReviewAudit: parseBoolArg(ctx.Arg("include-pr-review-audit")), + IncludeHealth: parseBoolArg(ctx.Arg("include-health")), }) } @@ -198,6 +261,8 @@ func AnalyzeRepoReport(input RepoReportInput, lang string) RepoReportResult { Health: healthResult, IssueSummary: issueSummary, PRSummary: prSummary, + PRLifecycle: input.PRLifecycle, + PRReviewAudit: input.PRReviewAudit, Recommendations: recommendations, RiskLevel: risk, ReportScore: reportScore, @@ -231,8 +296,9 @@ func summarizeRepoIssues(issues []IssueInput, lang string) (RepoIssueSummary, [] func summarizeRepoPRs(inputs []PRSummaryInput, lang string) (RepoPRSummary, []PRSummaryResult) { summary := RepoPRSummary{ - ByType: map[string]int{}, - ByRisk: map[string]int{}, + ByType: map[string]int{}, + ByRisk: map[string]int{}, + RiskSources: map[string]int{}, } results := make([]PRSummaryResult, 0, len(inputs)) focus := []string{} @@ -244,6 +310,9 @@ func summarizeRepoPRs(inputs []PRSummaryInput, lang string) (RepoPRSummary, []PR summary.ByRisk[result.RiskLevel]++ if result.RiskLevel == PRRiskHigh || result.RiskLevel == PRRiskCritical { summary.HighRisk++ + for _, reason := range result.RiskReasons { + summary.RiskSources[reason]++ + } } focus = append(focus, result.ReviewFocus...) } diff --git a/shortcuts/workflow/repo_report_fetch.go b/shortcuts/workflow/repo_report_fetch.go index 9610a71..c7d1a3b 100644 --- a/shortcuts/workflow/repo_report_fetch.go +++ b/shortcuts/workflow/repo_report_fetch.go @@ -2,21 +2,23 @@ package workflow import ( "fmt" - "net/url" "strings" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) type RepoReportFetchOptions struct { - Owner string - Repo string - IssueLimit int - PRLimit int - StaleDays int - IncludeIssues bool - IncludePRs bool - IncludeHealth bool + Owner string + Repo string + IssueLimit int + PRLimit int + StaleDays int + PRReviewAuditLimit int + IncludeIssues bool + IncludePRs bool + IncludePRLifecycle bool + IncludePRReviewAudit bool + IncludeHealth bool } func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOptions) (RepoReportInput, []ScoringNote, error) { @@ -24,12 +26,6 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption if err != nil { return RepoReportInput{}, nil, fmt.Errorf("workflow +repo-report remote mode requires --owner and --repo or a Git remote: %w", err) } - if opts.IssueLimit <= 0 { - opts.IssueLimit = 20 - } - if opts.PRLimit <= 0 { - opts.PRLimit = 10 - } if opts.StaleDays <= 0 { opts.StaleDays = 30 } @@ -60,13 +56,7 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption } if opts.IncludeIssues { - issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{ - Owner: owner, - Repo: repo, - State: "open", - Limit: opts.IssueLimit, - Page: 1, - }) + issues, err := fetchIssueListForReport(ctx, owner, repo, opts.IssueLimit) if err != nil { notes = append(notes, ScoringNote{Metric: "repo_report_issues", Note: fmt.Sprintf("issue fetch failed: %v", err)}) } else { @@ -82,6 +72,16 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption } else { input.PullRequests = prs successes++ + if opts.IncludePRLifecycle { + lifecycle, lifecycleNotes := fetchPRLifecycle(ctx, owner, repo) + input.PRLifecycle = lifecycle + notes = append(notes, lifecycleNotes...) + } + if opts.IncludePRReviewAudit { + audit, auditNotes := fetchPRReviewAudit(ctx, owner, repo, prs, opts.PRReviewAuditLimit) + input.PRReviewAudit = audit + notes = append(notes, auditNotes...) + } if len(prs) > 0 { notes = append(notes, ScoringNote{ Metric: "repo_report_prs", @@ -100,26 +100,65 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption return input, uniqueScoringNotes(notes), nil } -func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]PRSummaryInput, error) { - if limit <= 0 { - limit = 10 +func fetchPRLifecycle(ctx *common.RuntimeContext, owner, repo string) (*RepoPRLifecycle, []ScoringNote) { + states := []struct { + name string + value *int + }{ + {name: "open"}, + {name: "merged"}, + {name: "closed"}, } - query := url.Values{} - query.Set("state", "open") - query.Set("page", "1") - query.Set("limit", fmt.Sprintf("%d", limit)) + lifecycle := &RepoPRLifecycle{Source: "remote-read-only-fetch:list-totals"} + states[0].value = &lifecycle.Open + states[1].value = &lifecycle.Merged + states[2].value = &lifecycle.ClosedOrRejected + notes := []ScoringNote{} + successes := 0 + for _, state := range states { + total, err := fetchPRStateTotal(ctx, owner, repo, state.name) + if err != nil { + notes = append(notes, ScoringNote{ + Metric: "repo_report_pr_lifecycle", + Note: fmt.Sprintf("%s PR total unavailable: %v", state.name, err), + }) + continue + } + *state.value = total + successes++ + } + if successes == 0 { + return nil, notes + } + lifecycle.Total = lifecycle.Open + lifecycle.Merged + lifecycle.ClosedOrRejected + return lifecycle, notes +} +func fetchPRStateTotal(ctx *common.RuntimeContext, owner, repo, state string) (int, error) { + query := pullListQuery(state) + query.Set("page", "1") + query.Set("limit", "1") env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/pulls", query) + if err != nil { + return 0, err + } + if env.Meta != nil && env.Meta.TotalCount > 0 { + return env.Meta.TotalCount, nil + } + if total := apiListTotal(env.Data); total > 0 { + return total, nil + } + return len(apiList(env.Data)), nil +} + +func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]PRSummaryInput, error) { + pageSize := reportPageSize(limit) + items, err := fetchListItems(ctx, workflowRepoPath(owner, repo)+"/pulls", pullListQuery("open"), pageSize, limit) if err != nil { return nil, err } - items := apiList(env.Data) inputs := make([]PRSummaryInput, 0, len(items)) - for _, raw := range items { - item, ok := raw.(map[string]interface{}) - if !ok { - continue - } + for _, item := range items { input, ok := normalizePRSummaryItem(item) if !ok { continue @@ -130,9 +169,37 @@ func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit input.State = "open" } inputs = append(inputs, input) - if len(inputs) >= limit { + if limit > 0 && len(inputs) >= limit { break } } return inputs, nil } + +func fetchIssueListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]IssueInput, error) { + pageSize := reportPageSize(limit) + items, err := fetchListItems(ctx, workflowRepoPath(owner, repo)+"/issues", issueListQuery("open"), pageSize, limit) + if err != nil { + return nil, err + } + issues := make([]IssueInput, 0, len(items)) + for _, item := range items { + issue, ok := normalizeIssueItem(item) + if !ok { + continue + } + issues = append(issues, issue) + if limit > 0 && len(issues) >= limit { + break + } + } + return issues, nil +} + +func reportPageSize(limit int) int { + const maxPageSize = 50 + if limit > 0 && limit < maxPageSize { + return limit + } + return maxPageSize +} diff --git a/shortcuts/workflow/repo_report_fetch_test.go b/shortcuts/workflow/repo_report_fetch_test.go index 37e9838..c8d1969 100644 --- a/shortcuts/workflow/repo_report_fetch_test.go +++ b/shortcuts/workflow/repo_report_fetch_test.go @@ -1,6 +1,7 @@ package workflow import ( + "fmt" "net/http" "net/http/httptest" "strings" @@ -173,6 +174,210 @@ func TestFetchRepoReportInputPRListMetadata(t *testing.T) { } } +func TestFetchRepoReportInputPaginatesAllOpenItemsByDefault(t *testing.T) { + prPages := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/owner/repo/issues.json": + if got := r.URL.Query().Get("category"); got != "opened" { + t.Fatalf("issue category = %q, want opened", got) + } + writeWorkflowJSON(t, w, map[string]interface{}{ + "total_count": 2, + "issues": []map[string]interface{}{ + {"id": 1, "number": 1, "title": "First open issue"}, + {"id": 2, "number": 2, "title": "Second open issue"}, + }, + }) + case "/v1/owner/repo/pulls.json": + if got := r.URL.Query().Get("status"); got != "0" { + t.Fatalf("PR status = %q, want 0", got) + } + page := mustParseInt(r.URL.Query().Get("page"), 1) + prPages++ + start := (page - 1) * 50 + end := start + 50 + if end > 120 { + end = 120 + } + pulls := make([]map[string]interface{}, 0, end-start) + for index := start + 1; index <= end; index++ { + pulls = append(pulls, map[string]interface{}{ + "id": 1000 + index, + "index": index, + "title": fmt.Sprintf("PR %d", index), + }) + } + writeWorkflowJSON(t, w, map[string]interface{}{ + "total_count": 120, + "pulls": pulls, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, _, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{ + IncludeHealth: false, + IncludeIssues: true, + IncludePRs: true, + }) + if err != nil { + t.Fatalf("FetchRepoReportInput returned error: %v", err) + } + if len(input.Issues) != 2 { + t.Fatalf("len(Issues) = %d, want 2", len(input.Issues)) + } + if len(input.PullRequests) != 120 { + t.Fatalf("len(PullRequests) = %d, want 120", len(input.PullRequests)) + } + if prPages != 3 { + t.Fatalf("PR pages = %d, want 3", prPages) + } + if input.PullRequests[119].Number != 120 { + t.Fatalf("last PR number = %d, want 120", input.PullRequests[119].Number) + } +} + +func TestFetchRepoReportInputIncludesPRLifecycleTotals(t *testing.T) { + openCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("limit"); got != "1" { + t.Fatalf("PR limit = %q, want 1", got) + } + switch r.URL.Query().Get("status") { + case "0": + openCalls++ + writeWorkflowJSON(t, w, map[string]interface{}{ + "total_count": 12, + "pulls": []map[string]interface{}{ + {"id": 1001, "index": 1, "title": "Open PR"}, + }, + }) + case "1": + writeWorkflowJSON(t, w, map[string]interface{}{ + "total_count": 5, + "pulls": []map[string]interface{}{}, + }) + case "2": + writeWorkflowJSON(t, w, map[string]interface{}{ + "total_count": 7, + "pulls": []map[string]interface{}{}, + }) + default: + t.Fatalf("unexpected PR status query: %q", r.URL.Query().Get("status")) + } + })) + defer server.Close() + + input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{ + PRLimit: 1, + IncludeHealth: false, + IncludeIssues: false, + IncludePRs: true, + IncludePRLifecycle: true, + }) + if err != nil { + t.Fatalf("FetchRepoReportInput returned error: %v", err) + } + if len(input.PullRequests) != 1 { + t.Fatalf("len(PullRequests) = %d, want 1", len(input.PullRequests)) + } + if input.PRLifecycle == nil { + t.Fatal("PRLifecycle is nil, want totals") + } + if input.PRLifecycle.Open != 12 || input.PRLifecycle.Merged != 5 || input.PRLifecycle.ClosedOrRejected != 7 || input.PRLifecycle.Total != 24 { + t.Fatalf("PRLifecycle = %+v, want open=12 merged=5 closed=7 total=24", input.PRLifecycle) + } + if openCalls != 2 { + t.Fatalf("open status calls = %d, want 2 for list fetch plus lifecycle total", openCalls) + } + if !hasNote(notes, "repo_report_prs") { + t.Fatalf("notes = %+v, want repo_report_prs note", notes) + } +} + +func TestFetchRepoReportInputIncludesPRReviewAudit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "total_count": 2, + "pulls": []map[string]interface{}{ + { + "id": 1001, + "index": 1, + "title": "feat: reviewed change", + "author": map[string]interface{}{"login": "alice"}, + "issue": map[string]interface{}{"id": 501}, + "updated_at": "2026-06-02T12:00:00Z", + }, + {"id": 1002, "index": 2, "title": "docs: unreviewed change", "author": map[string]interface{}{"login": "dana"}, "issue": map[string]interface{}{"id": 502}}, + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/1/reviews.json": + writeWorkflowJSON(t, w, map[string]interface{}{"reviews": []map[string]interface{}{ + {"reviewer": map[string]interface{}{"login": "bob"}, "status": "approved", "content": "looks good", "created_at": "2026-06-01T10:00:00Z"}, + }}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/2/reviews.json": + writeWorkflowJSON(t, w, map[string]interface{}{"reviews": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/501/journals.json": + writeWorkflowJSON(t, w, map[string]interface{}{"journals": []map[string]interface{}{ + {"user": map[string]interface{}{"login": "alice"}, "notes": "I updated the branch", "operate_category": "comment", "created_at": "2026-06-02T11:00:00Z"}, + {"user": map[string]interface{}{"login": "bob"}, "notes": "Please keep this test", "operate_category": "comment", "created_at": "2026-06-01T09:30:00Z"}, + {"user": map[string]interface{}{"login": "carol"}, "notes": "I can reproduce this", "operate_category": "comment", "created_at": "2026-06-01T12:00:00Z"}, + {"user": map[string]interface{}{"login": "system"}, "operate_content": "status changed", "operate_category": "status"}, + }}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/502/journals.json": + writeWorkflowJSON(t, w, map[string]interface{}{"journals": []map[string]interface{}{ + {"user": map[string]interface{}{"login": "dana"}, "notes": "Initial description", "operate_category": "comment"}, + }}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{ + IncludeHealth: false, + IncludeIssues: false, + IncludePRs: true, + IncludePRReviewAudit: true, + }) + if err != nil { + t.Fatalf("FetchRepoReportInput returned error: %v", err) + } + if len(notes) != 1 || notes[0].Metric != "repo_report_prs" { + t.Fatalf("notes = %+v, want only list metadata note", notes) + } + audit := input.PRReviewAudit + if audit == nil { + t.Fatal("PRReviewAudit is nil") + } + if audit.Audited != 2 || audit.Reviewed != 1 || audit.Unreviewed != 1 || audit.NeedsReReview != 1 || audit.FormalReviews != 1 { + t.Fatalf("audit summary = %+v, want audited=2 reviewed=1 unreviewed=1 formal=1", audit) + } + if audit.SubmitterComments != 2 || audit.ReviewerComments != 1 || audit.ParticipantComments != 1 || audit.SystemEvents != 1 { + t.Fatalf("actor counts = submitter:%d reviewer:%d participant:%d system:%d", + audit.SubmitterComments, audit.ReviewerComments, audit.ParticipantComments, audit.SystemEvents) + } + first := audit.PullRequests[0] + if !first.Reviewed || first.ReviewStandard != reviewStandardFormal || first.FormalReviewStatus != "approved" { + t.Fatalf("first audit = %+v, want formal approved review", first) + } + if !first.NeedsReReview { + t.Fatalf("first audit = %+v, want needs_re_review after submitter update", first) + } + second := audit.PullRequests[1] + if second.Reviewed || second.ReviewStandard != reviewStandardUnreviewed { + t.Fatalf("second audit = %+v, want unreviewed despite submitter comment", second) + } +} + func hasNote(notes []ScoringNote, metric string) bool { for _, note := range notes { if note.Metric == metric { diff --git a/shortcuts/workflow/repo_report_test.go b/shortcuts/workflow/repo_report_test.go index 5b508cd..20400e9 100644 --- a/shortcuts/workflow/repo_report_test.go +++ b/shortcuts/workflow/repo_report_test.go @@ -76,6 +76,23 @@ func TestAnalyzeRepoReportPartialInput(t *testing.T) { } } +func TestAnalyzeRepoReportAggregatesPRRiskSources(t *testing.T) { + result := AnalyzeRepoReport(RepoReportInput{ + Repository: "owner/repo", + PullRequests: []PRSummaryInput{{ + Number: 1, + Title: "fix: prevent secret token leak", + Source: "remote-read-only-fetch:list-metadata", + }}, + }, "en") + if result.PRSummary.HighRisk != 1 { + t.Fatalf("HighRisk = %d, want 1", result.PRSummary.HighRisk) + } + if result.PRSummary.RiskSources["security-sensitive keyword"] != 1 { + t.Fatalf("RiskSources = %v, want security-sensitive keyword=1", result.PRSummary.RiskSources) + } +} + func TestAnalyzeRepoReportChinese(t *testing.T) { result := AnalyzeRepoReport(sampleRepoReportInput(), "zh-CN") if len(result.Recommendations) == 0 { diff --git a/shortcuts/workflow/triage_fetch.go b/shortcuts/workflow/triage_fetch.go index a509cf8..af2b47a 100644 --- a/shortcuts/workflow/triage_fetch.go +++ b/shortcuts/workflow/triage_fetch.go @@ -2,7 +2,6 @@ package workflow import ( "fmt" - "net/url" "strings" "time" @@ -28,8 +27,7 @@ func FetchIssuesForTriage(ctx *common.RuntimeContext, opts TriageFetchOptions) ( state = "open" } - query := url.Values{} - query.Set("state", state) + query := issueListQuery(state) query.Set("limit", fmt.Sprintf("%d", limit)) query.Set("page", fmt.Sprintf("%d", page)) if len(opts.Labels) > 0 { diff --git a/shortcuts/workflow/triage_fetch_test.go b/shortcuts/workflow/triage_fetch_test.go index f7af88a..782ab3c 100644 --- a/shortcuts/workflow/triage_fetch_test.go +++ b/shortcuts/workflow/triage_fetch_test.go @@ -16,8 +16,11 @@ func TestFetchIssuesForTriageNormalizesAPIResponse(t *testing.T) { if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - if got := r.URL.Query().Get("state"); got != "open" { - t.Fatalf("state query = %q, want open", got) + if got := r.URL.Query().Get("category"); got != "opened" { + t.Fatalf("category query = %q, want opened", got) + } + if got := r.URL.Query().Get("state"); got != "" { + t.Fatalf("state query = %q, want empty", got) } if got := r.URL.Query().Get("limit"); got != "30" { t.Fatalf("limit query = %q, want 30", got) From 5a2e0549c0a318e8af2b18da2132fa184f9ab987 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Sat, 27 Jun 2026 12:41:56 +0800 Subject: [PATCH 14/16] docs(feishu): keep validation evidence text-only --- docs/pr-draft.md | 33 ++++++++++ reports/FEISHU_SMOKE_20260627.md | 15 +++-- reports/FEISHU_SMOKE_EVIDENCE_20260627.md | 74 +++++++++++++++++------ 3 files changed, 97 insertions(+), 25 deletions(-) diff --git a/docs/pr-draft.md b/docs/pr-draft.md index 29dc902..6ff3c57 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -153,6 +153,10 @@ docs/FEISHU_PR_ACTIVITY_STRATEGY.md ## Evidence +Validation evidence is text-only in the repository. No screenshots or other +binary evidence are committed. If visual proof is requested, use redacted +screenshots pasted directly into the PR description, not repository files. + ```text reports/FEISHU_SMOKE_20260626.md reports/FEISHU_SMOKE_20260627.md @@ -161,6 +165,35 @@ reports/FEISHU_PERMISSION_MATRIX.md docs/FEISHU_OPENAPI_INVENTORY.md ``` +Text-only validation summary: + +```text +Real Feishu custom bot delivery passed: +- final English notify card: HTTP 200 / Feishu code 0 +- final English owner digest card: HTTP 200 / Feishu code 0 + +Real GitLink repository data: +- repository: Gitlink/gitlink-cli +- open issues analyzed: 9 +- open PRs analyzed: 166 +- PR lifecycle totals: open 166, merged 65, closed/rejected 74 + +Full PR review audit: +- PRs audited: 166 +- reviewed PRs: 4 +- unreviewed PRs: 162 +- needs re-review: 0 +- formal reviews: 4 +- reviewer comments: 6 +- submitter comments: 0 +- participant comments: 436 +- system events: 0 +- audit errors: 0 + +Risk source: +- high-risk PRs from metadata rule `security-sensitive keyword`: 13 +``` + ## Out of Scope - GitLink issue comment or close. diff --git a/reports/FEISHU_SMOKE_20260627.md b/reports/FEISHU_SMOKE_20260627.md index 62de5a6..60aad12 100644 --- a/reports/FEISHU_SMOKE_20260627.md +++ b/reports/FEISHU_SMOKE_20260627.md @@ -227,15 +227,14 @@ and is intentionally not duplicated into this Feishu change. ## Screenshot Status -The requested Windows computer-use connection failed twice during plugin -initialization: +Screenshot files are intentionally excluded from the repository. The project +owner asked that validation images not be committed because binary screenshots +remain in Git history and duplicate the text report evidence. -```text -failed to write kernel assets: path not found -``` - -No screenshot was fabricated or committed. Text evidence and API-derived -results remain the evidence for this run. See: +No screenshot was fabricated or committed. Visual proof, if needed, should be +redacted and pasted directly into the PR description instead of being stored in +the worktree. Text evidence and API-derived results remain the committed +evidence for this run. See: ```text reports/FEISHU_SMOKE_EVIDENCE_20260627.md diff --git a/reports/FEISHU_SMOKE_EVIDENCE_20260627.md b/reports/FEISHU_SMOKE_EVIDENCE_20260627.md index 3cfbf3c..2a4070c 100644 --- a/reports/FEISHU_SMOKE_EVIDENCE_20260627.md +++ b/reports/FEISHU_SMOKE_EVIDENCE_20260627.md @@ -8,24 +8,19 @@ Branch: feat/feishu-export-clean ``` -Base commit: +Head commit: ```text -d7812df1af49519f9eb84def218bd3d5a9fdf02f +138d886 feat(feishu): add full PR inventory and review attribution ``` -## Evidence Files +## Evidence Policy -| Evidence | Expected file | Status | Notes | -| --- | --- | --- | --- | -| Custom bot notify card | `reports/images/feishu-card-notify-redacted.png` | not captured | computer-use initialization failed | -| Owner digest card | `reports/images/feishu-owner-digest-redacted.png` | not captured | corrected card was sent successfully | -| DocX append result | `reports/images/feishu-docx-append-redacted.png` | not captured | corrected 11-block append passed | -| Bitable sync result | `reports/images/feishu-bitable-sync-redacted.png` | not captured | real upsert passed | -| Task create result | `reports/images/feishu-task-create-redacted.png` | not captured | historical result retained; creation was not repeated | -| Diagnostics output | `reports/images/feishu-diagnostics-terminal-redacted.png` | not captured | local and remote checks passed | +No screenshot or other binary evidence is committed in this branch. -No placeholder or fabricated image file is committed. +Visual validation can be attached directly to the PR description after manual +redaction when needed. It must not be stored under repository paths such as +`assets/validation-screenshots/`, `reports/images/`, or `docs/images/`. ## Text Evidence @@ -38,6 +33,52 @@ docs/FEISHU_OPENAPI_INVENTORY.md docs/FEISHU_PR_ACTIVITY_STRATEGY.md ``` +## PR Description Evidence + +The following text is safe to paste into the PR description instead of adding +image files: + +```text +Validation evidence is text-only in the repository. No screenshots are committed. + +Real Feishu custom bot delivery passed: +- final English notify card: HTTP 200 / Feishu code 0 +- final English owner digest card: HTTP 200 / Feishu code 0 + +Real GitLink repository data: +- repository: Gitlink/gitlink-cli +- open issues analyzed: 9 +- open PRs analyzed: 166 +- PR lifecycle totals: open 166, merged 65, closed/rejected 74 + +Full PR review audit: +- PRs audited: 166 +- reviewed PRs: 4 +- unreviewed PRs: 162 +- needs re-review: 0 +- formal reviews: 4 +- reviewer comments: 6 +- submitter comments: 0 +- participant comments: 436 +- system events: 0 +- audit errors: 0 + +Risk source: +- high-risk PRs from metadata rule `security-sensitive keyword`: 13 + +Tests passed: +- go test ./shortcuts/feishu +- go test ./shortcuts/workflow +- go test ./shortcuts +- go test ./... +- go build . +- go vet ./... + +Known separate issue: +- go run ./internal/i18n/cmd/check is blocked by an existing locale formatting + issue handled in a separate branch/PR. +``` + ## Redaction Checklist ```text @@ -50,12 +91,11 @@ docs/FEISHU_PR_ACTIVITY_STRATEGY.md [x] No task ID committed [x] No open_id / union_id committed [x] No personal account credential committed -[x] No unredacted screenshot committed +[x] No screenshot committed ``` ## Capture Rule -Screenshots may be added only after the Windows automation connection works and -each image is reviewed for resource IDs, personal identities, and unrelated -conversation content. Until then, this document records the missing visual -evidence explicitly rather than presenting a fake pass. +Screenshots may be used only outside the repository, for example pasted into the +PR description after redaction. If a local screenshot is temporarily captured, +keep it outside the worktree and delete it after the PR evidence is prepared. From 9b82841082f8d683fe92b37e938edd14d89f4b5c Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Sat, 27 Jun 2026 20:55:42 +0800 Subject: [PATCH 15/16] docs(feishu): finalize non-image PR validation notes --- docs/pr-draft.md | 29 +++++++++++++++++++++++++++++ reports/FEISHU_SMOKE_20260627.md | 29 ++++++++++++++++++++--------- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/docs/pr-draft.md b/docs/pr-draft.md index 6ff3c57..9510edd 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -137,10 +137,39 @@ go run . feishu +bitable-sync --from-workflow-json .local\report.json --send --f go test ./shortcuts/feishu go test ./shortcuts/workflow +go test ./shortcuts go test ./... +go build . go vet ./... ``` +Local validation on 2026-06-27: + +```text +go test ./shortcuts/feishu: pass +go test ./shortcuts/workflow: pass +go test ./shortcuts: pass +go test ./...: pass +go build .: pass +go vet ./...: pass +``` + +CI note: + +```text +.github/workflows/test.yml already includes the upstream i18n validation steps. +This branch adds Feishu/workflow package tests and go vet to the workflow. +Remote GitHub Actions status should be checked on the PR page. +``` + +Known separate issue: + +```text +On this Windows workstation, go run ./internal/i18n/cmd/check still reports +internal\i18n\locales\en-US.json formatting. That fix is intentionally kept in +the separate i18n branch/PR and is not mixed into this Feishu change. +``` + ## Review and Comment Attribution Boundary Formal reviews and PR-associated Issue journals are consumed only by the diff --git a/reports/FEISHU_SMOKE_20260627.md b/reports/FEISHU_SMOKE_20260627.md index 60aad12..0427e02 100644 --- a/reports/FEISHU_SMOKE_20260627.md +++ b/reports/FEISHU_SMOKE_20260627.md @@ -6,11 +6,12 @@ Date: 2026-06-27 ```text branch: feat/feishu-export-clean -base commit: d7812df1af49519f9eb84def218bd3d5a9fdf02f +base: origin/master +head: 5a2e054 docs(feishu): keep validation evidence text-only ``` -This smoke run included uncommitted data-correctness fixes that are documented -below and will receive a new commit after final validation. +The data-correctness, PR review-audit, diagnostics, and text-only evidence +changes are committed in this branch. ## Environment @@ -213,18 +214,28 @@ No maintainer-role guess when member lookup is unavailable. | Check | Result | | --- | --- | -| `go test ./shortcuts/feishu` | pass | -| `go test ./shortcuts/workflow` | pass | -| `go test ./shortcuts` | pass | -| `go test ./...` | pass | -| `go build .` | pass | -| `go vet ./...` | pass | +| `go test ./shortcuts/feishu` | pass on 2026-06-27 | +| `go test ./shortcuts/workflow` | pass on 2026-06-27 | +| `go test ./shortcuts` | pass on 2026-06-27 | +| `go test ./...` | pass on 2026-06-27 | +| `go build .` | pass on 2026-06-27 | +| `go vet ./...` | pass on 2026-06-27 | | `go run ./internal/i18n/cmd/check` | blocked by existing Windows locale line-ending issue | | `go run ./internal/i18n/cmd/check --scan-code` | blocked by the same formatting check | The i18n line-ending and missing-key fix remains in its independent branch/PR and is intentionally not duplicated into this Feishu change. +## CI Status + +`.github/workflows/test.yml` already runs i18n validation on `origin/master`. +This branch adds Feishu/workflow package tests and `go vet` to that workflow. + +The local machine does not have GitHub CLI installed, and an unauthenticated +GitHub API query was rate-limited, so the remote GitHub Actions result was not +confirmed from this workstation. The PR page should be used as the source of +truth for remote CI status. + ## Screenshot Status Screenshot files are intentionally excluded from the repository. The project From bb467ae9fa7ca2a962b55e290c2b55e3e67bc466 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Sat, 27 Jun 2026 21:06:14 +0800 Subject: [PATCH 16/16] docs(feishu): clarify final CI and i18n closure --- docs/pr-draft.md | 11 ++++++++++- reports/FEISHU_SMOKE_20260627.md | 26 +++++++++++++++++++++----- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/pr-draft.md b/docs/pr-draft.md index 9510edd..e9fcb54 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -159,7 +159,9 @@ CI note: ```text .github/workflows/test.yml already includes the upstream i18n validation steps. This branch adds Feishu/workflow package tests and go vet to the workflow. -Remote GitHub Actions status should be checked on the PR page. +The workflow triggers on pull_request and pushes to main/master. A feature-branch +push alone does not create a branch workflow run, so remote CI should be checked +after the PR is opened. ``` Known separate issue: @@ -168,6 +170,13 @@ Known separate issue: On this Windows workstation, go run ./internal/i18n/cmd/check still reports internal\i18n\locales\en-US.json formatting. That fix is intentionally kept in the separate i18n branch/PR and is not mixed into this Feishu change. + +The separate i18n fix is: +fix/i18n-locale-eol +8e24a89 fix(i18n): restore locale validation on Windows + +It adds .gitattributes LF handling for locale JSON and the missing +cmd.ignore.short locale key. ``` ## Review and Comment Attribution Boundary diff --git a/reports/FEISHU_SMOKE_20260627.md b/reports/FEISHU_SMOKE_20260627.md index 0427e02..cd5d2bc 100644 --- a/reports/FEISHU_SMOKE_20260627.md +++ b/reports/FEISHU_SMOKE_20260627.md @@ -7,12 +7,16 @@ Date: 2026-06-27 ```text branch: feat/feishu-export-clean base: origin/master -head: 5a2e054 docs(feishu): keep validation evidence text-only +runtime validation head: 9b82841 docs(feishu): finalize non-image PR validation notes ``` The data-correctness, PR review-audit, diagnostics, and text-only evidence changes are committed in this branch. +Commit `9b82841` only updates validation notes and does not change runtime +behavior. Later documentation-only closing commits do not change the runtime +smoke target. + ## Environment ```text @@ -231,10 +235,22 @@ and is intentionally not duplicated into this Feishu change. `.github/workflows/test.yml` already runs i18n validation on `origin/master`. This branch adds Feishu/workflow package tests and `go vet` to that workflow. -The local machine does not have GitHub CLI installed, and an unauthenticated -GitHub API query was rate-limited, so the remote GitHub Actions result was not -confirmed from this workstation. The PR page should be used as the source of -truth for remote CI status. +The workflow triggers on `pull_request` and pushes to `main`/`master`, so a +push to `feat/feishu-export-clean` alone does not create a branch workflow run. +The GitHub Actions branch filter showed zero runs for this feature branch before +opening a PR. The PR page should be used as the source of truth after the PR is +created. + +If CI reaches the i18n steps before the independent i18n fix is merged, it may +fail because this branch intentionally does not include: + +```text +fix/i18n-locale-eol +8e24a89 fix(i18n): restore locale validation on Windows +``` + +That separate fix adds `.gitattributes` locale LF handling and the missing +`cmd.ignore.short` locale key. ## Screenshot Status