feat(workflow): 新增重复 Issue 检测命令
This commit is contained in:
parent
52b7093846
commit
74379dbbe4
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 直接调用:
|
||||
|
|
|
|||
|
|
@ -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 注册。
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newHealthShortcut(),
|
||||
newPRSummaryShortcut(),
|
||||
newRepoReportShortcut(),
|
||||
newIssueDedupeShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue