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{}