feat: add feishu export workflow
This commit is contained in:
parent
a4f3954b2c
commit
c2a581eee9
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
@ -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, ", ")
|
||||
}
|
||||
|
|
@ -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", " ")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"]))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
Loading…
Reference in New Issue