gitlink-cli/shortcuts/feishu/doc_export.go

367 lines
13 KiB
Go

package feishu
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
type DocExportOptions struct {
AppID string `json:"-"`
AppSecret string `json:"-"`
FolderToken string `json:"folder_token,omitempty"`
DocumentID string `json:"document_id,omitempty"`
WikiURL string `json:"wiki_url,omitempty"`
WikiNodeToken string `json:"wiki_node_token,omitempty"`
Title string `json:"title"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
}
type DocExportOutput struct {
Mode string `json:"mode"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
TargetType string `json:"target_type"`
Operation string `json:"operation"`
Title string `json:"title"`
DocumentID string `json:"document_id,omitempty"`
DocumentURL string `json:"document_url,omitempty"`
WikiNodeToken string `json:"wiki_node_token,omitempty"`
WikiNode *WikiNodeSummary `json:"wiki_node,omitempty"`
BlockCount int `json:"block_count"`
TokenExpire int `json:"token_expire,omitempty"`
RevisionID int `json:"revision_id,omitempty"`
Preview string `json:"preview,omitempty"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
type WikiNodeSummary struct {
NodeType string `json:"node_type,omitempty"`
ObjType string `json:"obj_type,omitempty"`
Title string `json:"title,omitempty"`
}
type DocBlock map[string]interface{}
func docExportOptionsFromContext(ctx *common.RuntimeContext) (DocExportOptions, error) {
opts := DocExportOptions{
AppID: firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")),
AppSecret: firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET")),
FolderToken: firstNonEmpty(ctx.Arg("folder-token"), os.Getenv("FEISHU_FOLDER_TOKEN"), os.Getenv("FEISHU_DOC_FOLDER_TOKEN")),
DocumentID: firstNonEmpty(ctx.Arg("document-id"), os.Getenv("FEISHU_DOCUMENT_ID")),
WikiURL: firstNonEmpty(ctx.Arg("wiki-url"), os.Getenv("FEISHU_WIKI_URL")),
WikiNodeToken: firstNonEmpty(ctx.Arg("wiki-node-token"), os.Getenv("FEISHU_WIKI_NODE_TOKEN")),
Title: strings.TrimSpace(ctx.Arg("title")),
Send: parseBool(ctx.Arg("send")),
DryRun: parseBool(ctx.Arg("dry-run")),
}
if opts.WikiNodeToken == "" && opts.WikiURL != "" {
opts.WikiNodeToken = wikiNodeTokenFromURL(opts.WikiURL)
}
if opts.DocumentID == "" && opts.WikiURL != "" {
opts.DocumentID = docxTokenFromURL(opts.WikiURL)
}
if opts.Send && opts.DryRun {
return DocExportOptions{}, fmt.Errorf("--send and --dry-run cannot be used together")
}
if opts.Send {
if strings.TrimSpace(opts.AppID) == "" {
return DocExportOptions{}, fmt.Errorf("--send requires --app-id or FEISHU_APP_ID")
}
if strings.TrimSpace(opts.AppSecret) == "" {
return DocExportOptions{}, fmt.Errorf("--send requires --app-secret or FEISHU_APP_SECRET")
}
if opts.FolderToken == "" && opts.DocumentID == "" && opts.WikiNodeToken == "" {
return DocExportOptions{}, fmt.Errorf("--send requires --folder-token, --document-id, --wiki-url, or --wiki-node-token")
}
}
return opts, nil
}
func exportDocOrPreview(ctx *common.RuntimeContext, opts DocExportOptions, report workflow.RepoReportResult, lang string) error {
title := firstNonEmpty(opts.Title, "GitLink workflow report: "+report.Repository)
markdown, err := workflow.RenderRepoReport(report, "markdown", lang)
if err != nil {
return err
}
blocks := BuildDocBlocks(report, lang)
output := DocExportOutput{
Mode: "preview",
Send: opts.Send,
DryRun: !opts.Send,
TargetType: docTargetType(opts),
Operation: docOperation(opts),
Title: title,
DocumentID: redactToken(opts.DocumentID),
DocumentURL: redactResourceURL(firstNonEmpty(opts.WikiURL)),
BlockCount: len(blocks),
Preview: markdown,
}
if opts.WikiNodeToken != "" {
output.WikiNodeToken = redactToken(opts.WikiNodeToken)
}
if !opts.Send {
return renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "markdown"))
}
client := NewOpenAPIClient(http.DefaultClient)
token, err := client.TenantAccessToken(context.Background(), opts.AppID, opts.AppSecret)
if err != nil {
output.Diagnostics = append(output.Diagnostics, diagnoseOpenAPIError(err, "docx", "tenant_access_token"))
_ = renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return err
}
output.TokenExpire = token.Expire
documentID := opts.DocumentID
if opts.WikiNodeToken != "" {
node, err := client.GetWikiNode(context.Background(), token.Value, opts.WikiNodeToken)
if err != nil {
output.Diagnostics = append(output.Diagnostics, diagnoseOpenAPIError(err, "docx", "wiki node"))
_ = renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return err
}
output.TargetType = "wiki"
if node.ObjType != "" && node.ObjType != "docx" {
output.Diagnostics = append(output.Diagnostics, "wiki node object type is not supported; expected docx")
_ = renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return fmt.Errorf("Feishu wiki node object type %q is not supported; expected docx", node.ObjType)
}
documentID = node.ObjToken
output.DocumentID = redactToken(documentID)
output.WikiNode = &WikiNodeSummary{
NodeType: node.NodeType,
ObjType: node.ObjType,
Title: node.Title,
}
if output.DocumentURL == "" {
output.DocumentURL = redactResourceURL(node.URL)
}
}
if documentID == "" {
created, err := client.CreateDocument(context.Background(), token.Value, opts.FolderToken, title)
if err != nil {
output.Diagnostics = append(output.Diagnostics, diagnoseOpenAPIError(err, "docx", "folder"))
_ = renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return err
}
documentID = created.DocumentID
output.DocumentID = redactToken(created.DocumentID)
output.DocumentURL = redactResourceURL(created.URL)
output.RevisionID = created.RevisionID
output.Operation = "create"
}
createdBlocks, err := client.CreateBlocks(context.Background(), token.Value, documentID, documentID, blocks)
if err != nil {
output.Diagnostics = append(output.Diagnostics, diagnoseOpenAPIError(err, "docx", output.TargetType))
output.Diagnostics = append(output.Diagnostics, "required permission: app can edit the target DocX/Wiki page, or create documents in the target folder")
_ = renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
return fmt.Errorf("%w\nhint: grant the Feishu self-built app edit access to the target DocX/Wiki page, or export to a folder where the app has document creation permission", err)
}
if createdBlocks.RevisionID != 0 {
output.RevisionID = createdBlocks.RevisionID
}
output.Mode = "sent"
output.DryRun = false
output.Preview = ""
return renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
}
func BuildDocBlocks(report workflow.RepoReportResult, lang string) []DocBlock {
healthScore := "N/A"
healthRisk := "N/A"
if report.Health != nil {
healthScore = fmt.Sprintf("%d", report.Health.HealthScore)
healthRisk = report.Health.RiskLevel
}
blocks := []DocBlock{
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_title"), report.Repository)),
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_report_score"), report.ReportScore)),
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_risk"), firstNonEmpty(report.RiskLevel, "unknown"))),
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_health"), healthScore, healthRisk)),
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_issues"), report.IssueSummary.Total, report.IssueSummary.HighRisk, report.IssueSummary.MissingInfo)),
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_prs"), report.PRSummary.Total, report.PRSummary.HighRisk)),
textBlock(feishuLabel(lang, "analysis_scope")),
}
if report.PRLifecycle != nil {
blocks = append(blocks, textBlock(fmt.Sprintf(
"%s=%d; %s=%d; %s=%d",
feishuLabel(lang, "open_prs"), report.PRLifecycle.Open,
feishuLabel(lang, "merged_prs"), report.PRLifecycle.Merged,
feishuLabel(lang, "closed_prs"), report.PRLifecycle.ClosedOrRejected,
)))
}
if report.PRReviewAudit != nil {
blocks = append(blocks, textBlock(fmt.Sprintf(
"%s=%d; %s=%d; %s=%d; %s=%d; %s=%d",
feishuLabel(lang, "review_audited"), report.PRReviewAudit.Audited,
feishuLabel(lang, "reviewed_prs"), report.PRReviewAudit.Reviewed,
feishuLabel(lang, "unreviewed_prs"), report.PRReviewAudit.Unreviewed,
feishuLabel(lang, "needs_re_review"), report.PRReviewAudit.NeedsReReview,
feishuLabel(lang, "formal_reviews"), report.PRReviewAudit.FormalReviews,
)))
blocks = append(blocks, textBlock(feishuLabel(lang, "review_actor_attribution")+":\n"+joinLines(reviewAuditActorLines(report.PRReviewAudit, lang), 6)))
}
if len(report.PRSummary.ReviewFocus) > 0 {
blocks = append(blocks, textBlock(feishuLabel(lang, "doc_review_focus")+":\n"+joinLines(localizeFeishuLines(report.PRSummary.ReviewFocus, lang), 6)))
}
if len(report.Recommendations) > 0 {
blocks = append(blocks, textBlock(feishuLabel(lang, "doc_recommendations")+":\n"+joinLines(localizeFeishuLines(report.Recommendations, lang), 8)))
}
if len(report.Reasoning) > 0 {
blocks = append(blocks, textBlock(feishuLabel(lang, "doc_reasoning")+":\n"+joinLines(localizeFeishuLines(report.Reasoning, lang), 8)))
}
blocks = append(blocks, textBlock(fmt.Sprintf(feishuLabel(lang, "doc_source"), firstNonEmpty(report.Source, "workflow-json"))))
return blocks
}
func textBlock(content string) DocBlock {
return DocBlock{
"block_type": 2,
"text": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"text_run": map[string]interface{}{
"content": content,
},
},
},
},
}
}
func renderDocExportOutput(w io.Writer, output DocExportOutput, format string) error {
switch normalizeFormat(format) {
case "json":
return writeJSON(w, output)
case "table":
return writeDocExportTable(w, output)
default:
return writeDocExportMarkdown(w, output)
}
}
func writeDocExportMarkdown(w io.Writer, output DocExportOutput) error {
if _, err := fmt.Fprintf(w, "# Feishu Doc Export\n\n"); err != nil {
return err
}
lines := []string{
fmt.Sprintf("- Mode: `%s`", output.Mode),
fmt.Sprintf("- Send: `%t`", output.Send),
fmt.Sprintf("- Dry run: `%t`", output.DryRun),
fmt.Sprintf("- Target: `%s`", output.TargetType),
fmt.Sprintf("- Operation: `%s`", output.Operation),
fmt.Sprintf("- Title: `%s`", output.Title),
fmt.Sprintf("- Blocks: `%d`", output.BlockCount),
}
if output.DocumentID != "" {
lines = append(lines, fmt.Sprintf("- Document ID: `%s`", output.DocumentID))
}
if output.DocumentURL != "" {
lines = append(lines, fmt.Sprintf("- Document URL: %s", output.DocumentURL))
}
if _, err := fmt.Fprintln(w, strings.Join(lines, "\n")); err != nil {
return err
}
for _, diagnostic := range output.Diagnostics {
if _, err := fmt.Fprintf(w, "- Diagnostic: %s\n", diagnostic); err != nil {
return err
}
}
if output.Preview != "" {
if _, err := fmt.Fprint(w, "\n## Preview\n\n"); err != nil {
return err
}
_, err := fmt.Fprint(w, output.Preview)
return err
}
return nil
}
func writeDocExportTable(w io.Writer, output DocExportOutput) error {
_, err := fmt.Fprintf(w, "MODE\tSEND\tDRY_RUN\tTARGET\tOPERATION\tBLOCKS\tDOCUMENT\n%s\t%t\t%t\t%s\t%s\t%d\t%s\n",
output.Mode,
output.Send,
output.DryRun,
output.TargetType,
output.Operation,
output.BlockCount,
firstNonEmpty(output.DocumentURL, output.DocumentID),
)
return err
}
func docTargetType(opts DocExportOptions) string {
switch {
case opts.WikiNodeToken != "":
return "wiki"
case opts.DocumentID != "":
return "docx"
case opts.FolderToken != "":
return "folder"
default:
return "preview"
}
}
func docOperation(opts DocExportOptions) string {
if opts.FolderToken != "" && opts.DocumentID == "" && opts.WikiNodeToken == "" {
return "create"
}
if opts.DocumentID != "" || opts.WikiNodeToken != "" {
return "append"
}
return "preview"
}
func wikiNodeTokenFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for i, part := range parts {
if part == "wiki" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
func docxTokenFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for i, part := range parts {
if part == "docx" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
func joinLines(values []string, limit int) string {
if limit <= 0 || limit > len(values) {
limit = len(values)
}
lines := make([]string, 0, limit)
for _, value := range values[:limit] {
value = strings.TrimSpace(value)
if value == "" {
continue
}
lines = append(lines, "- "+value)
}
return strings.Join(lines, "\n")
}