diff --git a/README.md b/README.md index 99110a2..18752c7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/README.md). +The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/). **[中文文档](./README.zh-CN.md)** @@ -78,19 +78,11 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans jiangtx
jiangtx -
- luwanzhou -
luwanzhou -
-
- whale_hihihi -
whale_hihihi -
## Why gitlink-cli? -- **Agent-Native Design** — Structured [Skills](./skills/README.md) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup +- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup - **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands - **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output - **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded @@ -103,8 +95,8 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | Category | Capabilities | |----------|-------------| -| 📦 Repo | List, create, fork, delete repositories, read and search files, batch commit file changes, view repo info, insights, and interactions | -| 🐛 Issue | Create, update, close, batch close/update/delete, comment on issues | +| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions | +| 🐛 Issue | Create, update, close, batch close, comment on issues | | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | @@ -113,14 +105,9 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🏢 Org | Manage organizations, members, teams | | 🔧 CI | View builds, logs, CI/CD operations | | ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs | -| 🔔 Message Settings | Inspect and update personal message delivery preferences | | 🔔 Webhook | Manage repo webhooks and test deliveries | -| 📖 Wiki | List, view, create, update, and delete wiki pages | -| 🔔 Notification | List, read, and delete user messages | | 🔍 Search | Search repositories, users | -| 📊 Dataset | Query research datasets by project | | 👤 User | View user profiles and info | -| 📊 Profile | User ability, role, major, activity, and contribution statistics | | 📋 PM | Sprint management, kanban boards, weekly reports | | 🤖 Workflow | AI-powered issue triage, PR review, release notes | @@ -225,26 +212,10 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus # Read repository README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master -# Read one repository file with metadata -gitlink-cli repo +file --owner Gitlink --repo forgeplus --path README.md --ref main - # List repository files at root or a directory gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main -# Search repository files by name -gitlink-cli repo +files --owner Gitlink --repo forgeplus --search README --ref main - -# Commit one file change -gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \ - --branch main --message "docs: update README" \ - --path README.md --content "# Project" - -# Commit several file changes from a JSON operations file -gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \ - --branch main --new-branch docs/batch-update \ - --message "docs: batch update" --ops changes.json --dry-run - # Show language breakdown gitlink-cli repo +languages --owner Gitlink --repo forgeplus @@ -292,47 +263,6 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 ``` -### Wiki Management - -```bash -# List wiki pages (table of contents) -gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345 - -# View a wiki page by page name -gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home - -# Create a wiki page -gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \ - -n getting-started -t "Getting Started" -c "# Getting Started Guide" - -# Update a wiki page title and/or content -gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title" -gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# Updated content" -gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title" -c "New content" - -# Delete a wiki page -gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page -``` - -### Notifications - -```bash -# List current user's unread notifications -gitlink-cli notification +list --type notification --status unread - -# List @me messages for an explicit user -gitlink-cli notification +list --user Mengz --type atme - -# Mark messages as read -gitlink-cli notification +read --type atme --ids 101,102 - -# Mark all unread notifications as read -gitlink-cli notification +read --type notification --ids -1 - -# Delete messages -gitlink-cli notification +delete --type notification --ids 101,102 -``` - ### Member Management ```bash @@ -382,14 +312,6 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12 # Batch close issues from a CSV file gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv -# Preview batch metadata update by API issue IDs -# Note: --ids uses API issue IDs, not web URL issue numbers. -gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run - -# Destructive batch delete requires both dry-run first and --yes for real execution -gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run -gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes - # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" @@ -476,10 +398,6 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved ```bash # List branches gitlink-cli branch +list --owner Gitlink --repo forgeplus -gitlink-cli branch +list --owner Gitlink --repo forgeplus --keyword fix --state deleted - -# List all branches without pagination -gitlink-cli branch +all --owner Gitlink --repo forgeplus # Create a branch gitlink-cli branch +create --name feature/new-feature @@ -492,10 +410,6 @@ gitlink-cli branch +protect --name main # Remove branch protection gitlink-cli branch +unprotect --name main - -# Set default branch or restore a deleted branch (preview first) -gitlink-cli branch +set-default --owner Gitlink --repo forgeplus --name main --dry-run -gitlink-cli branch +restore --owner Gitlink --repo forgeplus --id 7 --name feature/old --dry-run ``` ### Release Management @@ -553,16 +467,6 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run ``` -### Ignore File Templates - -```bash -# List all available .gitignore templates -gitlink-cli ignore +list - -# Filter templates by name -gitlink-cli ignore +list --name Go -``` - ### Search ```bash @@ -573,29 +477,6 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` -### User Profile - -`profile` surfaces GitLink's native user statistics (ability, role, major, activity, -contribution). When `--user` is omitted it defaults to the authenticated user. - -```bash -# Development ability scores + language breakdown -gitlink-cli profile +ability --user zhangsan - -# Role positioning / major (discipline) categories -gitlink-cli profile +role --user zhangsan -gitlink-cli profile +major --user zhangsan - -# Ability within a time window (Unix timestamps) -gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600 - -# Recent activity (issues / PRs / commits per day) for the current user -gitlink-cli profile +activity - -# Contribution heatmap for a given year -gitlink-cli profile +contribution --user zhangsan --year 2025 -``` - ### Workflow Agent Commands `workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports: @@ -604,11 +485,11 @@ gitlink-cli profile +contribution --user zhangsan --year 2025 - `workflow +health` - `workflow +pr-summary` - `workflow +repo-report` -- `workflow +release-readiness` +- `workflow +issue-dedupe` `workflow +pr-summary` defaults to `table` when `--format` is omitted. `workflow +repo-report` defaults to `markdown` when `--format` is omitted. -`workflow +release-readiness` defaults to `table` when `--format` is omitted. +`workflow +issue-dedupe` defaults to `table` when `--format` is omitted. Examples: @@ -682,17 +563,11 @@ 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 -# Evaluate whether a release is ready to publish -gitlink-cli workflow +release-readiness \ - --version v1.2.0 \ - --changes "Add workflow gate,Fix release upload" \ - --tests "go test ./...=passed,go build ./...=passed" \ - --artifacts "windows zip=passed,linux tar=passed" \ - --rollback-plan "Revert tag and restore previous release assets" \ - --format table +# 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 -# Evaluate release readiness from a JSON gate file -gitlink-cli workflow +release-readiness --from release_readiness.json --format markdown +# Find duplicate candidates from a local issue JSON file +gitlink-cli workflow +issue-dedupe --from issues.json --threshold 60 --format markdown ``` Output formats: @@ -708,33 +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 +release-readiness` evaluates local gate signals only; it does not create tags, releases, or assets. - -### Dataset - -`dataset` manages and queries GitLink research datasets (title, description, -paper content, license, owning project). - -```bash -# List datasets for one or more projects (by numeric project ID) -gitlink-cli dataset +list --ids 5988 - -# View a repository's dataset and attachments -gitlink-cli dataset +view --owner Gitlink --repo forgeplus - -# Create / update a repository's dataset (preview first with --dry-run) -gitlink-cli dataset +create --owner me --repo proj -t "My dataset" -d "..." --license-id 359 --dry-run -gitlink-cli dataset +update --owner me --repo proj -t "My dataset" -d "updated" - -# Delete a dataset attachment (destructive: preview, then confirm with --yes) -gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --dry-run -gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --yes -``` - -> Note: `dataset +list` (platform dataset query) is verified on production -> gitlink.org.cn. The per-repo `+view`/`+create`/`+update` routes follow the -> published OpenAPI contract but are not yet deployed on production (they return -> 404 there); they will work once the platform enables them. +- `workflow +issue-dedupe` only reports duplicate candidates; it does not close, comment on, or edit issues. ### Raw API @@ -755,20 +604,6 @@ Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-s # With query parameters gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' - -# Render owner/repo from git context or explicit variables -gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}' - -# Reuse template variables in a single request -gitlink-cli api POST /v1/{{owner}}/{{repo}}/issues/{{number}}/journals \ - --body '{"notes":"handled by {{actor}}"}' \ - --var owner=Gitlink --var repo=gitlink-cli --var number=42 --var actor=bot - -# Preview a rendered single request without sending it -gitlink-cli api POST /v1/{{owner}}/{{repo}}/issues \ - --body-file issue.json \ - --var owner=Gitlink --var repo=gitlink-cli \ - --dry-run ``` ## Global Parameters @@ -806,20 +641,19 @@ git push gitlink The `skills/` directory contains Agent Skill files for AI-automated GitLink operations. -See [skills/README.md](./skills/README.md) for details. +See [skills/README.md](skills/README.md) for details. | Skill | Description | |-------|-------------| | `gitlink-shared` | Authentication, global parameters, safety rules, API notes | | `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) | -| `gitlink-issue` | Issue operations (create, update, close, batch update/delete, comment, etc.) | +| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) | | `gitlink-pr` | Pull request operations (create, merge, review, etc.) | | `gitlink-member` | Repository member and invite link management | | `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) | | `gitlink-release` | Release management (create, edit, update, view, delete, etc.) | | `gitlink-ci` | CI/CD operations (builds, logs, etc.) | | `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) | -| `gitlink-notification` | User messages (list, mark read, delete) | | `gitlink-search` | Search (repositories, users, etc.) | | `gitlink-org` | Organization management (members, teams, etc.) | | `gitlink-user` | User management (profile info, etc.) | @@ -877,7 +711,7 @@ gitlink-cli/ ## Documentation -- [Skills Guide](./skills/README.md) — AI Agent Skills detailed documentation +- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation - [Design Document](doc/design.md) — Architecture design and development plan ## FAQ @@ -942,7 +776,7 @@ gitlink-cli uses Windows Credential Manager for secure token storage. If Credent ### Q: Where can I find the full API reference? -See [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md). +See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md). ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index f057ecb..3a5a37e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -457,7 +457,7 @@ gitlink-cli search +users -k "zhangsan" ### 工作流命令 -`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和发布就绪度检查。 +`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和重复 Issue 候选检测。 ```bash # 生成单个 PR 的审查摘要 @@ -466,20 +466,14 @@ gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 - # 生成仓库工作流报告 gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown -# 根据本地门禁信号判断发布是否可以推进 -gitlink-cli workflow +release-readiness \ - --version v1.2.0 \ - --changes "新增工作流门禁,修复 Release 上传" \ - --tests "go test ./...=passed,go build ./...=passed" \ - --artifacts "windows zip=passed,linux tar=passed" \ - --rollback-plan "回滚 tag 并恢复上一版制品" \ - --format table +# 从远端只读拉取 Issue 并检测重复候选 +gitlink-cli workflow +issue-dedupe --owner Gitlink --repo gitlink-cli --limit 50 --threshold 55 --format table -# 从 JSON 门禁文件生成发布就绪度报告 -gitlink-cli workflow +release-readiness --from release_readiness.json --format markdown +# 从本地 JSON 文件检测重复候选 +gitlink-cli workflow +issue-dedupe --from issues.json --threshold 60 --format markdown ``` -`workflow +release-readiness` 默认使用 `table` 输出,会综合版本号、变更摘要、已知阻塞项、测试结果、发布制品、破坏性变更回滚方案和依赖风险,给出 `pass/review/block` 门禁结论;命令只分析本地输入,不会创建 tag、Release 或附件。 +`workflow +issue-dedupe` 默认使用 `table` 输出,会根据标题、正文和标签的共享关键词计算相似度,帮助维护者优先确认高置信重复候选;命令只输出候选对,不会关闭、评论或修改 Issue。 ### 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 6d5f01e..2e297a1 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut { newHealthShortcut(), newPRSummaryShortcut(), newRepoReportShortcut(), - newReleaseReadinessShortcut(), + newIssueDedupeShortcut(), } }