gitlink-cli/shortcuts/feishu/digest.go

475 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package feishu
import (
"fmt"
"io"
"sort"
"strings"
"text/tabwriter"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
type RoleDigest struct {
Role string `json:"role"`
Repository string `json:"repository"`
RepositoryURL string `json:"repository_url,omitempty"`
DocURL string `json:"doc_url,omitempty"`
HealthScore *int `json:"health_score,omitempty"`
HealthRisk string `json:"health_risk,omitempty"`
RiskLevel string `json:"risk_level"`
ReportScore int `json:"report_score"`
IssueTotal int `json:"issue_total"`
IssueHighRisk int `json:"issue_high_risk"`
IssueMissingInfo int `json:"issue_missing_info"`
PRTotal int `json:"pr_total"`
PRHighRisk int `json:"pr_high_risk"`
PRRiskSources map[string]int `json:"pr_risk_sources,omitempty"`
PRLifecycle *workflow.RepoPRLifecycle `json:"pr_lifecycle,omitempty"`
PRReviewAudit *workflow.RepoPRReviewAudit `json:"pr_review_audit,omitempty"`
ReviewFocus []string `json:"review_focus,omitempty"`
Recommendations []string `json:"recommendations,omitempty"`
AttentionItems []string `json:"attention_items,omitempty"`
NextSteps []string `json:"next_steps,omitempty"`
BoundaryDescription string `json:"boundary_description"`
}
func BuildOwnerDigest(report workflow.RepoReportResult, docURL string) RoleDigest {
healthScore, healthRisk := digestHealth(report)
attention := []string{}
if report.IssueSummary.HighRisk > 0 {
attention = append(attention, fmt.Sprintf("%d high-risk issues need maintainer triage", report.IssueSummary.HighRisk))
}
if report.IssueSummary.MissingInfo > 0 {
attention = append(attention, fmt.Sprintf("%d issues are missing required information", report.IssueSummary.MissingInfo))
}
if report.PRSummary.HighRisk > 0 {
attention = append(attention, fmt.Sprintf("%d high-risk pull requests need owner review", report.PRSummary.HighRisk))
}
if healthScore != nil && *healthScore < 65 {
attention = append(attention, fmt.Sprintf("repository health score is %d", *healthScore))
}
if len(attention) == 0 {
attention = append(attention, "No critical owner action was detected in the workflow report.")
}
nextSteps := []string{
"Review high-risk issues and PRs first.",
"Use the Feishu report document for full context when available.",
"Keep GitLink write actions outside this digest; card buttons are navigation-only.",
}
if len(report.Recommendations) > 0 {
nextSteps = append(report.Recommendations, nextSteps...)
}
return RoleDigest{
Role: "owner",
Repository: report.Repository,
RepositoryURL: gitlinkRepoURL(report.Repository),
DocURL: strings.TrimSpace(docURL),
HealthScore: healthScore,
HealthRisk: healthRisk,
RiskLevel: report.RiskLevel,
ReportScore: report.ReportScore,
IssueTotal: report.IssueSummary.Total,
IssueHighRisk: report.IssueSummary.HighRisk,
IssueMissingInfo: report.IssueSummary.MissingInfo,
PRTotal: report.PRSummary.Total,
PRHighRisk: report.PRSummary.HighRisk,
PRRiskSources: report.PRSummary.RiskSources,
PRLifecycle: report.PRLifecycle,
PRReviewAudit: report.PRReviewAudit,
ReviewFocus: report.PRSummary.ReviewFocus,
Recommendations: report.Recommendations,
AttentionItems: uniqueDigestStrings(attention),
NextSteps: limitStrings(uniqueDigestStrings(nextSteps), 8),
BoundaryDescription: "Owner digest is a read-only summary. It does not modify GitLink or Feishu resources.",
}
}
func BuildContributorDigest(report workflow.RepoReportResult, docURL string) RoleDigest {
healthScore, healthRisk := digestHealth(report)
attention := []string{}
if len(report.PRSummary.ReviewFocus) > 0 {
attention = append(attention, report.PRSummary.ReviewFocus...)
}
if report.PRSummary.HighRisk > 0 {
attention = append(attention, fmt.Sprintf("%d high-risk pull requests may need contributor updates", report.PRSummary.HighRisk))
}
if report.IssueSummary.MissingInfo > 0 {
attention = append(attention, fmt.Sprintf("%d issues need clearer reproduction details or missing information", report.IssueSummary.MissingInfo))
}
if report.IssueSummary.HighRisk > 0 {
attention = append(attention, fmt.Sprintf("%d high-risk issues may need focused follow-up", report.IssueSummary.HighRisk))
}
if len(attention) == 0 {
attention = append(attention, "No contributor-specific blocker was detected in the workflow report.")
}
nextSteps := []string{
"Check PR review focus and update the related branch or description.",
"Add missing reproduction steps, logs, or screenshots when requested.",
"Open the GitLink repository or Feishu report link for details.",
}
if report.PRSummary.HighRisk > 0 {
nextSteps = append([]string{"Prioritize high-risk pull request feedback before new work."}, nextSteps...)
}
return RoleDigest{
Role: "contributor",
Repository: report.Repository,
RepositoryURL: gitlinkRepoURL(report.Repository),
DocURL: strings.TrimSpace(docURL),
HealthScore: healthScore,
HealthRisk: healthRisk,
RiskLevel: report.RiskLevel,
ReportScore: report.ReportScore,
IssueTotal: report.IssueSummary.Total,
IssueHighRisk: report.IssueSummary.HighRisk,
IssueMissingInfo: report.IssueSummary.MissingInfo,
PRTotal: report.PRSummary.Total,
PRHighRisk: report.PRSummary.HighRisk,
PRRiskSources: report.PRSummary.RiskSources,
PRLifecycle: report.PRLifecycle,
PRReviewAudit: report.PRReviewAudit,
ReviewFocus: report.PRSummary.ReviewFocus,
Recommendations: report.Recommendations,
AttentionItems: limitStrings(uniqueDigestStrings(attention), 8),
NextSteps: limitStrings(uniqueDigestStrings(nextSteps), 8),
BoundaryDescription: "Contributor digest is role-oriented, not personalized. It does not use Feishu open_id or union_id routing.",
}
}
func BuildOwnerDigestCard(digest RoleDigest, title string, lang string) Card {
return buildDigestCard(digest, firstNonEmpty(title, fmt.Sprintf(feishuLabel(lang, "owner_digest_title"), digest.Repository)), "owner", lang)
}
func BuildContributorDigestCard(digest RoleDigest, title string, lang string) Card {
return buildDigestCard(digest, firstNonEmpty(title, fmt.Sprintf(feishuLabel(lang, "contributor_digest_title"), digest.Repository)), "contributor", lang)
}
func buildDigestCard(digest RoleDigest, title string, role string, lang string) Card {
elements := []interface{}{
div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "repository"), escapeMD(digest.Repository))),
fields([]fieldValue{
{Label: feishuLabel(lang, "report_score"), Value: fmt.Sprintf("%d", digest.ReportScore)},
{Label: feishuLabel(lang, "risk_level"), Value: digest.RiskLevel},
{Label: feishuLabel(lang, "issues_analyzed"), Value: fmt.Sprintf("%d", digest.IssueTotal)},
{Label: feishuLabel(lang, "prs_analyzed"), Value: fmt.Sprintf("%d", digest.PRTotal)},
}),
fields([]fieldValue{
{Label: feishuLabel(lang, "high_risk_issues"), Value: fmt.Sprintf("%d", digest.IssueHighRisk)},
{Label: feishuLabel(lang, "missing_info_issues"), Value: fmt.Sprintf("%d", digest.IssueMissingInfo)},
{Label: feishuLabel(lang, "high_risk_prs"), Value: fmt.Sprintf("%d", digest.PRHighRisk)},
{Label: feishuLabel(lang, "review_focus"), Value: fmt.Sprintf("%d", len(digest.ReviewFocus))},
}),
}
if digest.HealthScore != nil {
elements = append(elements, fields([]fieldValue{
{Label: feishuLabel(lang, "health_score"), Value: fmt.Sprintf("%d", *digest.HealthScore)},
{Label: feishuLabel(lang, "health_risk"), Value: digest.HealthRisk},
}))
}
if digest.PRLifecycle != nil {
elements = append(elements, fields([]fieldValue{
{Label: feishuLabel(lang, "open_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.Open)},
{Label: feishuLabel(lang, "merged_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.Merged)},
{Label: feishuLabel(lang, "closed_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.ClosedOrRejected)},
}))
}
if digest.PRReviewAudit != nil {
elements = append(elements, fields([]fieldValue{
{Label: feishuLabel(lang, "review_audited"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Audited)},
{Label: feishuLabel(lang, "reviewed_prs"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Reviewed)},
{Label: feishuLabel(lang, "unreviewed_prs"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Unreviewed)},
{Label: feishuLabel(lang, "needs_re_review"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.NeedsReReview)},
{Label: feishuLabel(lang, "formal_reviews"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.FormalReviews)},
}))
elements = append(elements, div(fmt.Sprintf("**%s**\n%s",
feishuLabel(lang, "review_actor_attribution"),
bulletList(reviewAuditActorLines(digest.PRReviewAudit, lang), 6))))
}
if len(digest.AttentionItems) > 0 {
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "attention"), bulletList(localizeFeishuLines(digest.AttentionItems, lang), 5))))
}
if lines := riskSourceLines(digest.PRRiskSources); len(lines) > 0 {
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "risk_sources"), bulletList(lines, 8))))
}
if len(digest.NextSteps) > 0 {
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "suggested_next_steps"), bulletList(localizeFeishuLines(digest.NextSteps, lang), 5))))
}
if digest.RepositoryURL != "" {
elements = append(elements, actionButton(feishuLabel(lang, "open_gitlink_repository"), digest.RepositoryURL))
}
if digest.DocURL != "" {
elements = append(elements, actionButton(feishuLabel(lang, "open_feishu_report"), digest.DocURL))
}
elements = append(elements, note(feishuLabel(lang, "analysis_scope")))
elements = append(elements, note(localizedBoundary(digest, lang)))
template := templateForRisk(digest.RiskLevel)
if role == "contributor" && digest.PRSummaryNeedsAttention() {
template = "yellow"
}
return baseCard(title, template, elements)
}
func localizedBoundary(digest RoleDigest, lang string) string {
if !isChineseLang(lang) {
return digest.BoundaryDescription
}
switch digest.Role {
case "owner":
return feishuLabel(lang, "boundary_owner")
case "contributor":
return feishuLabel(lang, "boundary_contributor")
default:
return localizeFeishuText(digest.BoundaryDescription, lang)
}
}
func (d RoleDigest) PRSummaryNeedsAttention() bool {
return d.PRHighRisk > 0 || len(d.ReviewFocus) > 0
}
func renderDigest(w io.Writer, digest RoleDigest, format string, lang string) error {
switch normalizeFormat(format) {
case "markdown":
return writeDigestMarkdown(w, digest, lang)
case "table":
return writeDigestTable(w, digest, lang)
default:
return writeJSON(w, digest)
}
}
func writeDigestMarkdown(w io.Writer, digest RoleDigest, lang string) error {
title := fmt.Sprintf("# GitLink %s digest: %s\n\n", digest.Role, digest.Repository)
if isChineseLang(lang) {
role := "角色"
if digest.Role == "owner" {
role = "Owner"
}
if digest.Role == "contributor" {
role = "贡献者"
}
title = fmt.Sprintf("# GitLink %s摘要%s\n\n", role, digest.Repository)
}
if _, err := fmt.Fprint(w, title); err != nil {
return err
}
lines := digestMarkdownLines(digest, lang)
if digest.HealthScore != nil {
if isChineseLang(lang) {
lines = append(lines, fmt.Sprintf("- 健康分:`%d`;健康风险:`%s`", *digest.HealthScore, firstNonEmpty(digest.HealthRisk, "unknown")))
} else {
lines = append(lines, fmt.Sprintf("- Health score: `%d`; health risk: `%s`", *digest.HealthScore, firstNonEmpty(digest.HealthRisk, "unknown")))
}
}
if digest.PRLifecycle != nil {
if isChineseLang(lang) {
lines = append(lines, fmt.Sprintf("- PR 生命周期:开放 `%d`,已合并 `%d`,已关闭/拒绝 `%d`",
digest.PRLifecycle.Open,
digest.PRLifecycle.Merged,
digest.PRLifecycle.ClosedOrRejected,
))
} else {
lines = append(lines, fmt.Sprintf("- PR lifecycle: open `%d`, merged `%d`, closed/rejected `%d`",
digest.PRLifecycle.Open,
digest.PRLifecycle.Merged,
digest.PRLifecycle.ClosedOrRejected,
))
}
}
if digest.PRReviewAudit != nil {
if isChineseLang(lang) {
lines = append(lines, fmt.Sprintf("- Review 判别:已归因 `%d`,已被 review `%d`,未被 review `%d`,待重新 review `%d`,正式 Review `%d`",
digest.PRReviewAudit.Audited,
digest.PRReviewAudit.Reviewed,
digest.PRReviewAudit.Unreviewed,
digest.PRReviewAudit.NeedsReReview,
digest.PRReviewAudit.FormalReviews,
))
} else {
lines = append(lines, fmt.Sprintf("- Review audit: audited `%d`, reviewed `%d`, unreviewed `%d`, needs re-review `%d`, formal reviews `%d`",
digest.PRReviewAudit.Audited,
digest.PRReviewAudit.Reviewed,
digest.PRReviewAudit.Unreviewed,
digest.PRReviewAudit.NeedsReReview,
digest.PRReviewAudit.FormalReviews,
))
}
}
if digest.RepositoryURL != "" {
if isChineseLang(lang) {
lines = append(lines, "- GitLink 仓库:"+digest.RepositoryURL)
} else {
lines = append(lines, "- GitLink repository: "+digest.RepositoryURL)
}
}
if digest.DocURL != "" {
if isChineseLang(lang) {
lines = append(lines, "- 飞书报告:"+digest.DocURL)
} else {
lines = append(lines, "- Feishu report: "+digest.DocURL)
}
}
if _, err := fmt.Fprintln(w, strings.Join(lines, "\n")); err != nil {
return err
}
if len(digest.AttentionItems) > 0 {
heading := "Attention"
if isChineseLang(lang) {
heading = "需要关注"
}
if _, err := fmt.Fprintf(w, "\n## %s\n\n%s\n", heading, bulletList(localizeFeishuLines(digest.AttentionItems, lang), 8)); err != nil {
return err
}
}
if lines := riskSourceLines(digest.PRRiskSources); len(lines) > 0 {
heading := feishuLabel(lang, "risk_sources")
if _, err := fmt.Fprintf(w, "\n## %s\n\n%s\n", heading, bulletList(lines, 8)); err != nil {
return err
}
}
if len(digest.NextSteps) > 0 {
heading := "Suggested next steps"
if isChineseLang(lang) {
heading = "建议下一步"
}
if _, err := fmt.Fprintf(w, "\n## %s\n\n%s\n", heading, bulletList(localizeFeishuLines(digest.NextSteps, lang), 8)); err != nil {
return err
}
}
_, err := fmt.Fprintf(w, "\n> %s\n", localizedBoundary(digest, lang))
return err
}
func digestMarkdownLines(digest RoleDigest, lang string) []string {
if isChineseLang(lang) {
return []string{
fmt.Sprintf("- 报告分数:`%d`", digest.ReportScore),
fmt.Sprintf("- 风险等级:`%s`", firstNonEmpty(digest.RiskLevel, "unknown")),
fmt.Sprintf("- 已分析 Issue`%d`,其中高风险 `%d`,信息缺失 `%d`", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo),
fmt.Sprintf("- 已分析 PR`%d`,其中高风险 `%d`", digest.PRTotal, digest.PRHighRisk),
"- " + feishuLabel(lang, "analysis_scope"),
}
}
return []string{
fmt.Sprintf("- Report score: `%d`", digest.ReportScore),
fmt.Sprintf("- Risk level: `%s`", firstNonEmpty(digest.RiskLevel, "unknown")),
fmt.Sprintf("- Issues analyzed: `%d`, including `%d` high risk and `%d` missing info", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo),
fmt.Sprintf("- Pull requests analyzed: `%d`, including `%d` high risk", digest.PRTotal, digest.PRHighRisk),
"- " + feishuLabel(lang, "analysis_scope"),
}
}
func writeDigestTable(w io.Writer, digest RoleDigest, lang string) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
header := "ROLE\tREPOSITORY\tRISK\tSCORE\tISSUES_ANALYZED\tHIGH_RISK_ISSUES\tPRS_ANALYZED\tHIGH_RISK_PRS\tOPEN_PRS\tMERGED_PRS\tCLOSED_PRS\tREVIEWED_PRS\tUNREVIEWED_PRS\tNEEDS_RE_REVIEW\tATTENTION"
if isChineseLang(lang) {
header = "角色\t仓库\t风险\t分数\t已分析Issue\t高风险Issue\t已分析PR\t高风险PR\t开放PR\t已合并PR\t已关闭PR\t已Review PR\t未Review PR\t待重新Review\t关注项"
}
if _, err := fmt.Fprintln(tw, header); err != nil {
return err
}
openPRs, mergedPRs, closedPRs := 0, 0, 0
if digest.PRLifecycle != nil {
openPRs = digest.PRLifecycle.Open
mergedPRs = digest.PRLifecycle.Merged
closedPRs = digest.PRLifecycle.ClosedOrRejected
}
reviewedPRs, unreviewedPRs, needsReReviewPRs := 0, 0, 0
if digest.PRReviewAudit != nil {
reviewedPRs = digest.PRReviewAudit.Reviewed
unreviewedPRs = digest.PRReviewAudit.Unreviewed
needsReReviewPRs = digest.PRReviewAudit.NeedsReReview
}
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n",
digest.Role,
digest.Repository,
digest.RiskLevel,
digest.ReportScore,
digest.IssueTotal,
digest.IssueHighRisk,
digest.PRTotal,
digest.PRHighRisk,
openPRs,
mergedPRs,
closedPRs,
reviewedPRs,
unreviewedPRs,
needsReReviewPRs,
len(digest.AttentionItems),
); err != nil {
return err
}
return tw.Flush()
}
func riskSourceLines(sources map[string]int) []string {
if len(sources) == 0 {
return nil
}
keys := make([]string, 0, len(sources))
for key := range sources {
keys = append(keys, key)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
lines = append(lines, fmt.Sprintf("%s: %d", key, sources[key]))
}
return lines
}
func reviewAuditActorLines(audit *workflow.RepoPRReviewAudit, lang string) []string {
if audit == nil {
return nil
}
return []string{
fmt.Sprintf("%s: %d", feishuLabel(lang, "reviewer_comments"), audit.ReviewerComments),
fmt.Sprintf("%s: %d", feishuLabel(lang, "submitter_comments"), audit.SubmitterComments),
fmt.Sprintf("%s: %d", feishuLabel(lang, "participant_comments"), audit.ParticipantComments),
fmt.Sprintf("%s: %d", feishuLabel(lang, "system_events"), audit.SystemEvents),
}
}
func digestHealth(report workflow.RepoReportResult) (*int, string) {
if report.Health == nil {
return nil, ""
}
score := report.Health.HealthScore
return &score, report.Health.RiskLevel
}
func gitlinkRepoURL(repository string) string {
repository = strings.Trim(strings.TrimSpace(repository), "/")
if repository == "" || repository == "local" {
return ""
}
if strings.Contains(repository, "://") {
return repository
}
if !strings.Contains(repository, "/") {
return ""
}
return "https://www.gitlink.org.cn/" + repository
}
func limitStrings(values []string, limit int) []string {
if limit <= 0 || len(values) <= limit {
return values
}
return values[:limit]
}
func uniqueDigestStrings(values []string) []string {
seen := map[string]bool{}
result := []string{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}