feat: add feishu export workflow

This commit is contained in:
whzy 2026-06-15 15:05:48 +08:00
parent a4f3954b2c
commit c2a581eee9
15 changed files with 1762 additions and 3 deletions

View File

@ -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
```

View File

@ -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 <folder_token> --send
4. gitlink-cli feishu +notify --from-workflow-json report.json --doc-url <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 <folder_token>
--document-id <document_id> optional later
--wiki-url <wiki_url> optional later
--wiki-node-token <node_token> optional later
--title <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.
```

View File

@ -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.

View File

@ -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
```

348
shortcuts/feishu/bitable.go Normal file
View File

@ -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, ", ")
}

199
shortcuts/feishu/card.go Normal file
View File

@ -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", " ")
}

View File

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

184
shortcuts/feishu/feishu.go Normal file
View File

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

View File

@ -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"]))
}
}

113
shortcuts/feishu/options.go Normal file
View File

@ -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 ""
}

118
shortcuts/feishu/render.go Normal file
View File

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

21
shortcuts/feishu/sign.go Normal file
View File

@ -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()
}

View File

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

View File

@ -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",

View File

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