From 74379dbbe408012fa90d0e3c2907241cec01649f Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Wed, 10 Jun 2026 15:02:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20=E6=96=B0=E5=A2=9E=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=20Issue=20=E6=A3=80=E6=B5=8B=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 + README.zh-CN.md | 20 ++ doc/changes/workflow-issue-dedupe.md | 7 + shortcuts/workflow/issue_dedupe.go | 444 ++++++++++++++++++++++++ shortcuts/workflow/issue_dedupe_test.go | 148 ++++++++ shortcuts/workflow/workflow.go | 1 + 6 files changed, 629 insertions(+) create mode 100644 doc/changes/workflow-issue-dedupe.md create mode 100644 shortcuts/workflow/issue_dedupe.go create mode 100644 shortcuts/workflow/issue_dedupe_test.go diff --git a/README.md b/README.md index b43dcd1..18752c7 100644 --- a/README.md +++ b/README.md @@ -485,9 +485,11 @@ gitlink-cli search +users -k "zhangsan" - `workflow +health` - `workflow +pr-summary` - `workflow +repo-report` +- `workflow +issue-dedupe` `workflow +pr-summary` defaults to `table` when `--format` is omitted. `workflow +repo-report` defaults to `markdown` when `--format` is omitted. +`workflow +issue-dedupe` defaults to `table` when `--format` is omitted. Examples: @@ -560,6 +562,12 @@ gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format ma # Repository workflow report from a local JSON file gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json + +# Find likely duplicate issues by read-only GitLink fetch +gitlink-cli workflow +issue-dedupe --owner Gitlink --repo gitlink-cli --limit 50 --threshold 55 --format table + +# Find duplicate candidates from a local issue JSON file +gitlink-cli workflow +issue-dedupe --from issues.json --threshold 60 --format markdown ``` Output formats: @@ -575,6 +583,7 @@ Safety: - They do not depend on LLM APIs. - `workflow +pr-summary` does not comment, approve, reject, or merge pull requests. - `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes. +- `workflow +issue-dedupe` only reports duplicate candidates; it does not close, comment on, or edit issues. ### Raw API diff --git a/README.zh-CN.md b/README.zh-CN.md index 265ac98..3a5a37e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -455,6 +455,26 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### 工作流命令 + +`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和重复 Issue 候选检测。 + +```bash +# 生成单个 PR 的审查摘要 +gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown + +# 生成仓库工作流报告 +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown + +# 从远端只读拉取 Issue 并检测重复候选 +gitlink-cli workflow +issue-dedupe --owner Gitlink --repo gitlink-cli --limit 50 --threshold 55 --format table + +# 从本地 JSON 文件检测重复候选 +gitlink-cli workflow +issue-dedupe --from issues.json --threshold 60 --format markdown +``` + +`workflow +issue-dedupe` 默认使用 `table` 输出,会根据标题、正文和标签的共享关键词计算相似度,帮助维护者优先确认高置信重复候选;命令只输出候选对,不会关闭、评论或修改 Issue。 + ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: diff --git a/doc/changes/workflow-issue-dedupe.md b/doc/changes/workflow-issue-dedupe.md new file mode 100644 index 0000000..d2d1aaa --- /dev/null +++ b/doc/changes/workflow-issue-dedupe.md @@ -0,0 +1,7 @@ +# 新增重复 Issue 候选检测工作流 + +`gitlink-cli workflow +issue-dedupe` 新增了只读的重复 Issue 候选检测能力。命令可以读取本地 Issue JSON,也可以通过 GitLink API 只读拉取指定仓库的 Issue 列表,再基于标题、正文和标签分词计算相似度,输出候选重复对、置信度、共享关键词和处理建议。 + +该功能面向 Issue 较多的仓库维护场景:维护者可以先处理高置信重复候选,把后创建的 Issue 链接到原始问题,减少重复排查成本;也可以通过 `--threshold` 调整相似度阈值,通过 `--max-pairs` 控制输出规模。输出支持 `table`、`markdown` 和 `json`,适合终端查看、Issue 评论草稿或自动化脚本消费。 + +命令保持安全边界,不会自动关闭、评论或修改 Issue。测试覆盖了本地 JSON 输入、状态和数量过滤、远端只读查询参数、相似度排序、中文 markdown 渲染、table 输出和 shortcut 注册。 diff --git a/shortcuts/workflow/issue_dedupe.go b/shortcuts/workflow/issue_dedupe.go new file mode 100644 index 0000000..61b3dd8 --- /dev/null +++ b/shortcuts/workflow/issue_dedupe.go @@ -0,0 +1,444 @@ +package workflow + +import ( + "bytes" + "fmt" + "math" + "os" + "sort" + "strings" + "text/tabwriter" + "unicode" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type IssueDedupeInput struct { + Repository string `json:"repository"` + Issues []IssueInput `json:"issues"` + Source string `json:"source"` +} + +type IssueDedupeResult struct { + Repository string `json:"repository"` + TotalIssues int `json:"total_issues"` + CandidatePairs int `json:"candidate_pairs"` + HighConfidence int `json:"high_confidence"` + MediumConfidence int `json:"medium_confidence"` + LowConfidence int `json:"low_confidence"` + Threshold int `json:"threshold"` + Pairs []IssueDedupePair `json:"pairs"` + Recommendations []string `json:"recommendations"` + Source string `json:"source"` +} + +type IssueDedupePair struct { + Score int `json:"score"` + Confidence string `json:"confidence"` + Primary IssueRef `json:"primary"` + Duplicate IssueRef `json:"duplicate"` + SharedTerms []string `json:"shared_terms"` + Reason string `json:"reason"` +} + +func newIssueDedupeShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "issue-dedupe", + Description: "Find likely duplicate issues with read-only local similarity rules", + Flags: []common.Flag{ + {Name: "from", Usage: "Read issues from a JSON file. Supports an array or an object with an issues field"}, + {Name: "state", Short: "s", Usage: "Remote issue state to fetch", Default: "open"}, + {Name: "page", Short: "p", Usage: "Remote issue page", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Maximum issues to analyze", Default: "50"}, + {Name: "threshold", Usage: "Minimum duplicate score from 0 to 100", Default: "55"}, + {Name: "max-pairs", Usage: "Maximum duplicate pairs to output", Default: "20"}, + {Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN}, + }, + Run: runIssueDedupe, + } +} + +func runIssueDedupe(ctx *common.RuntimeContext) error { + lang := normalizeLang(ctx.Arg("lang")) + input, threshold, maxPairs, err := collectIssueDedupeInput(ctx) + if err != nil { + return err + } + result := AnalyzeIssueDedupe(input, threshold, maxPairs, lang) + format := ctx.Format + if strings.TrimSpace(cmdutil.Format) == "" { + format = "table" + } + rendered, err := RenderIssueDedupe(result, format, lang) + if err != nil { + return err + } + _, err = fmt.Fprint(os.Stdout, rendered) + return err +} + +func collectIssueDedupeInput(ctx *common.RuntimeContext) (IssueDedupeInput, int, int, error) { + threshold, err := parseIntArg(ctx.Arg("threshold"), 55, "threshold") + if err != nil { + return IssueDedupeInput{}, 0, 0, err + } + if threshold > 100 { + return IssueDedupeInput{}, 0, 0, fmt.Errorf("invalid --threshold %q: must be between 0 and 100", ctx.Arg("threshold")) + } + maxPairs, err := parseIntArg(ctx.Arg("max-pairs"), 20, "max-pairs") + if err != nil { + return IssueDedupeInput{}, 0, 0, err + } + if path := strings.TrimSpace(ctx.Arg("from")); path != "" { + issues, err := readIssueInputs(path) + if err != nil { + return IssueDedupeInput{}, 0, 0, err + } + limit, err := parseIntArg(ctx.Arg("limit"), 50, "limit") + if err != nil { + return IssueDedupeInput{}, 0, 0, err + } + return IssueDedupeInput{ + Repository: repositoryFromContext(ctx, ""), + Issues: filterIssueInputs(issues, ctx.Arg("state"), limit), + Source: "local-json", + }, threshold, maxPairs, nil + } + limit, err := parseIntArg(ctx.Arg("limit"), 50, "limit") + if err != nil { + return IssueDedupeInput{}, 0, 0, err + } + page, err := parseIntArg(ctx.Arg("page"), 1, "page") + if err != nil { + return IssueDedupeInput{}, 0, 0, err + } + state := strings.TrimSpace(ctx.Arg("state")) + if state == "" { + state = "open" + } + issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{ + State: state, + Limit: limit, + Page: page, + }) + if err != nil { + return IssueDedupeInput{}, 0, 0, fmt.Errorf("workflow +issue-dedupe remote mode requires --owner and --repo or a Git remote: %w", err) + } + return IssueDedupeInput{ + Repository: repositoryFromContext(ctx, ""), + Issues: issues, + Source: "remote-read-only-fetch", + }, threshold, maxPairs, nil +} + +func AnalyzeIssueDedupe(input IssueDedupeInput, threshold int, maxPairs int, lang string) IssueDedupeResult { + lang = normalizeLang(lang) + if threshold < 0 { + threshold = 0 + } + if threshold > 100 { + threshold = 100 + } + repository := strings.TrimSpace(input.Repository) + if repository == "" { + repository = "local" + } + source := strings.TrimSpace(input.Source) + if source == "" { + source = "local" + } + fingerprints := make([]issueFingerprint, 0, len(input.Issues)) + for _, issue := range input.Issues { + fp := buildIssueFingerprint(issue) + if len(fp.Terms) > 0 { + fingerprints = append(fingerprints, fp) + } + } + pairs := []IssueDedupePair{} + for i := 0; i < len(fingerprints); i++ { + for j := i + 1; j < len(fingerprints); j++ { + score, shared := scoreIssueSimilarity(fingerprints[i], fingerprints[j]) + if score < threshold { + continue + } + pairs = append(pairs, buildIssueDedupePair(fingerprints[i], fingerprints[j], score, shared, lang)) + } + } + sort.SliceStable(pairs, func(i, j int) bool { + if pairs[i].Score != pairs[j].Score { + return pairs[i].Score > pairs[j].Score + } + return pairs[i].Primary.Number < pairs[j].Primary.Number + }) + if maxPairs > 0 && len(pairs) > maxPairs { + pairs = pairs[:maxPairs] + } + result := IssueDedupeResult{ + Repository: repository, + TotalIssues: len(input.Issues), + CandidatePairs: len(pairs), + Threshold: threshold, + Pairs: pairs, + Source: source, + } + for _, pair := range pairs { + switch pair.Confidence { + case "high": + result.HighConfidence++ + case "medium": + result.MediumConfidence++ + default: + result.LowConfidence++ + } + } + result.Recommendations = issueDedupeRecommendations(result, lang) + return result +} + +type issueFingerprint struct { + Issue IssueInput + Terms map[string]int +} + +func buildIssueFingerprint(issue IssueInput) issueFingerprint { + terms := map[string]int{} + for _, token := range tokenizeIssueText(issue.Title + " " + issue.Body) { + terms[token]++ + } + for _, label := range issue.Labels { + for _, token := range tokenizeIssueText(label) { + terms[token] += 2 + } + } + return issueFingerprint{Issue: issue, Terms: terms} +} + +func tokenizeIssueText(value string) []string { + value = strings.ToLower(value) + var b strings.Builder + for _, r := range value { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } else { + b.WriteByte(' ') + } + } + raw := strings.Fields(b.String()) + tokens := make([]string, 0, len(raw)) + for _, token := range raw { + if len([]rune(token)) < 3 || issueDedupeStopWords[token] { + continue + } + tokens = append(tokens, token) + } + return tokens +} + +var issueDedupeStopWords = map[string]bool{ + "the": true, "and": true, "for": true, "with": true, "when": true, "from": true, "this": true, "that": true, + "can": true, "not": true, "cannot": true, "should": true, "would": true, "could": true, "please": true, + "issue": true, "bug": true, "error": true, "failed": true, "failure": true, "using": true, "after": true, +} + +func scoreIssueSimilarity(a, b issueFingerprint) (int, []string) { + intersectionWeight := 0 + unionWeight := 0 + shared := []string{} + seen := map[string]bool{} + for term, weightA := range a.Terms { + weightB := b.Terms[term] + if weightB > 0 { + intersectionWeight += minInt(weightA, weightB) + shared = append(shared, term) + } + unionWeight += maxIssueDedupeInt(weightA, weightB) + seen[term] = true + } + for term, weightB := range b.Terms { + if seen[term] { + continue + } + unionWeight += weightB + } + if unionWeight == 0 { + return 0, nil + } + sort.Strings(shared) + score := int(math.Round(float64(intersectionWeight) / float64(unionWeight) * 100)) + if sameNonZeroIssueType(a.Issue, b.Issue) { + score += 8 + } + if score > 100 { + score = 100 + } + return score, limitIssueDedupeTerms(shared, 8) +} + +func sameNonZeroIssueType(a, b IssueInput) bool { + typeA := AnalyzeIssue(a, langEN).DetectedType + typeB := AnalyzeIssue(b, langEN).DetectedType + return typeA != IssueTypeUnknown && typeA == typeB +} + +func buildIssueDedupePair(a, b issueFingerprint, score int, shared []string, lang string) IssueDedupePair { + confidence := "low" + if score >= 80 { + confidence = "high" + } else if score >= 65 { + confidence = "medium" + } + primary := issueRefFromInput(a.Issue) + duplicate := issueRefFromInput(b.Issue) + if duplicate.Number > 0 && (primary.Number == 0 || duplicate.Number < primary.Number) { + primary, duplicate = duplicate, primary + } + reason := fmt.Sprintf("shared terms: %s", strings.Join(shared, ", ")) + if normalizeLang(lang) == langZH { + reason = fmt.Sprintf("共享关键词:%s", strings.Join(shared, "、")) + } + return IssueDedupePair{ + Score: score, + Confidence: confidence, + Primary: primary, + Duplicate: duplicate, + SharedTerms: shared, + Reason: reason, + } +} + +func issueRefFromInput(issue IssueInput) IssueRef { + return IssueRef{ + ID: issue.ID, + Number: issue.Number, + Title: issue.Title, + URL: issue.URL, + Author: issue.Author, + State: issue.State, + } +} + +func issueDedupeRecommendations(result IssueDedupeResult, lang string) []string { + zh := normalizeLang(lang) == langZH + if result.CandidatePairs == 0 { + if zh { + return []string{"未发现达到阈值的重复候选,可以按常规 Issue 流程继续处理。"} + } + return []string{"No duplicate candidates reached the threshold; continue with normal issue triage."} + } + recs := []string{} + if result.HighConfidence > 0 { + if zh { + recs = append(recs, "优先人工确认高置信候选,必要时在后创建的 Issue 中链接原始 Issue。") + } else { + recs = append(recs, "Review high-confidence candidates first and link newer issues to the original when appropriate.") + } + } + if zh { + recs = append(recs, "命令只提供候选对,不会自动关闭、评论或修改 Issue。") + } else { + recs = append(recs, "The command only reports candidates; it never closes, comments on, or edits issues.") + } + return recs +} + +func RenderIssueDedupe(result IssueDedupeResult, format string, lang string) (string, error) { + var buf bytes.Buffer + switch normalizeFormat(format) { + case "json": + if err := writeJSON(&buf, result); err != nil { + return "", err + } + case "markdown": + if err := writeIssueDedupeMarkdown(&buf, result, lang); err != nil { + return "", err + } + case "table": + if err := writeIssueDedupeTable(&buf, result); err != nil { + return "", err + } + default: + return "", fmt.Errorf("unsupported workflow output format %q", format) + } + return buf.String(), nil +} + +func writeIssueDedupeMarkdown(buf *bytes.Buffer, result IssueDedupeResult, lang string) error { + title := "Issue Duplicate Candidates" + if normalizeLang(lang) == langZH { + title = "Issue 重复候选" + } + if _, err := fmt.Fprintf(buf, "# %s\n\n", title); err != nil { + return err + } + if _, err := fmt.Fprintf(buf, "- Repository: `%s`\n- Issues analyzed: `%d`\n- Candidate pairs: `%d`\n- Threshold: `%d`\n- Source: `%s`\n\n", + result.Repository, result.TotalIssues, result.CandidatePairs, result.Threshold, result.Source); err != nil { + return err + } + if _, err := fmt.Fprintln(buf, "## Candidates"); err != nil { + return err + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + if len(result.Pairs) == 0 { + _, err := fmt.Fprintln(buf, "- No duplicate candidates.") + return err + } + for _, pair := range result.Pairs { + if _, err := fmt.Fprintf(buf, "- `%s` score `%d`: #%d %s -> #%d %s\n", + pair.Confidence, pair.Score, pair.Primary.Number, pair.Primary.Title, pair.Duplicate.Number, pair.Duplicate.Title); err != nil { + return err + } + if _, err := fmt.Fprintf(buf, " - %s\n", pair.Reason); err != nil { + return err + } + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + if _, err := fmt.Fprintln(buf, "## Recommendations"); err != nil { + return err + } + for _, rec := range result.Recommendations { + if _, err := fmt.Fprintf(buf, "- %s\n", rec); err != nil { + return err + } + } + return nil +} + +func writeIssueDedupeTable(buf *bytes.Buffer, result IssueDedupeResult) error { + tw := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "SCORE\tCONFIDENCE\tPRIMARY\tDUPLICATE\tSHARED_TERMS"); err != nil { + return err + } + for _, pair := range result.Pairs { + if _, err := fmt.Fprintf(tw, "%d\t%s\t#%d %s\t#%d %s\t%s\n", + pair.Score, + pair.Confidence, + pair.Primary.Number, + truncateTableText(pair.Primary.Title, 48), + pair.Duplicate.Number, + truncateTableText(pair.Duplicate.Title, 48), + strings.Join(pair.SharedTerms, ","), + ); err != nil { + return err + } + } + return tw.Flush() +} + +func limitIssueDedupeTerms(values []string, limit int) []string { + if limit <= 0 || len(values) <= limit { + return values + } + return values[:limit] +} + +func maxIssueDedupeInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/shortcuts/workflow/issue_dedupe_test.go b/shortcuts/workflow/issue_dedupe_test.go new file mode 100644 index 0000000..4d71781 --- /dev/null +++ b/shortcuts/workflow/issue_dedupe_test.go @@ -0,0 +1,148 @@ +package workflow + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestAnalyzeIssueDedupeFindsDuplicatePairs(t *testing.T) { + result := AnalyzeIssueDedupe(IssueDedupeInput{ + Repository: "owner/repo", + Source: "local-json", + Issues: []IssueInput{ + {Number: 1, Title: "Login fails on Windows with token auth", Body: "The token login command fails on Windows credential manager.", State: "open", Labels: []string{"bug", "auth"}}, + {Number: 2, Title: "Token login failure in Windows credential manager", Body: "Auth token login cannot complete on Windows.", State: "open", Labels: []string{"bug", "auth"}}, + {Number: 3, Title: "Add docs for release upload", Body: "Document release asset upload examples.", State: "open", Labels: []string{"docs"}}, + }, + }, 35, 10, "en") + + if result.CandidatePairs != 1 { + t.Fatalf("CandidatePairs = %d, want 1: %+v", result.CandidatePairs, result.Pairs) + } + pair := result.Pairs[0] + if pair.Primary.Number != 1 || pair.Duplicate.Number != 2 { + t.Fatalf("pair = #%d/#%d, want #1/#2", pair.Primary.Number, pair.Duplicate.Number) + } + if pair.Score < 35 || len(pair.SharedTerms) == 0 { + t.Fatalf("score/shared = %d/%v, want duplicate signal", pair.Score, pair.SharedTerms) + } + if len(result.Recommendations) == 0 { + t.Fatal("expected recommendations") + } +} + +func TestCollectIssueDedupeInputFromFileFiltersStateAndLimit(t *testing.T) { + path := filepath.Join(t.TempDir(), "issues.json") + writeJSONFixture(t, path, map[string]interface{}{ + "issues": []IssueInput{ + {Number: 1, Title: "open issue", State: "open"}, + {Number: 2, Title: "closed issue", State: "closed"}, + {Number: 3, Title: "second open issue", State: "open"}, + }, + }) + ctx := &common.RuntimeContext{ + Args: map[string]string{ + "from": path, + "state": "open", + "limit": "1", + "threshold": "55", + "max-pairs": "20", + }, + } + + input, threshold, maxPairs, err := collectIssueDedupeInput(ctx) + if err != nil { + t.Fatalf("collectIssueDedupeInput returned error: %v", err) + } + if threshold != 55 || maxPairs != 20 { + t.Fatalf("threshold/maxPairs = %d/%d, want 55/20", threshold, maxPairs) + } + if len(input.Issues) != 1 || input.Issues[0].Number != 1 { + t.Fatalf("issues = %+v, want only first open issue", input.Issues) + } +} + +func TestCollectIssueDedupeInputRemoteFetch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Fatalf("state = %q, want open", got) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Fatalf("page = %q, want 2", got) + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Fatalf("limit = %q, want 5", got) + } + writeWorkflowJSON(t, w, map[string]interface{}{ + "issues": []map[string]interface{}{ + {"number": 7, "title": "Login fails", "description": "Token login fails", "state": "open"}, + }, + }) + })) + defer server.Close() + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", + Args: map[string]string{ + "state": "open", + "page": "2", + "limit": "5", + "threshold": "55", + "max-pairs": "20", + }, + } + + input, _, _, err := collectIssueDedupeInput(ctx) + if err != nil { + t.Fatalf("collectIssueDedupeInput remote returned error: %v", err) + } + if input.Source != "remote-read-only-fetch" || len(input.Issues) != 1 || input.Issues[0].Number != 7 { + t.Fatalf("input = %+v, want remote issue #7", input) + } +} + +func TestRenderIssueDedupeMarkdownAndTable(t *testing.T) { + result := AnalyzeIssueDedupe(IssueDedupeInput{ + Repository: "owner/repo", + Issues: []IssueInput{ + {Number: 1, Title: "Login token fails on Windows", Body: "credential manager auth failure", Labels: []string{"auth"}}, + {Number: 2, Title: "Windows token login auth failure", Body: "credential manager login fails", Labels: []string{"auth"}}, + }, + }, 30, 10, "zh-CN") + + markdown, err := RenderIssueDedupe(result, "markdown", "zh-CN") + if err != nil { + t.Fatalf("RenderIssueDedupe markdown returned error: %v", err) + } + if !strings.Contains(markdown, "# Issue 重复候选") || !strings.Contains(markdown, "共享关键词") { + t.Fatalf("markdown output missing expected content:\n%s", markdown) + } + + table, err := RenderIssueDedupe(result, "table", "en") + if err != nil { + t.Fatalf("RenderIssueDedupe table returned error: %v", err) + } + if !strings.Contains(table, "SCORE") || !strings.Contains(table, "#1") { + t.Fatalf("table output missing expected content:\n%s", table) + } +} + +func TestShortcutsExposeIssueDedupe(t *testing.T) { + names := map[string]bool{} + for _, shortcut := range Shortcuts() { + names[shortcut.Name] = true + } + if !names["issue-dedupe"] { + t.Fatal("Shortcuts missing issue-dedupe") + } +} diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go index 4dc69e5..2e297a1 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut { newHealthShortcut(), newPRSummaryShortcut(), newRepoReportShortcut(), + newIssueDedupeShortcut(), } }