diff --git a/README.md b/README.md
index 99110a2..18752c7 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
[](https://golang.org)
[](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
-
-
## 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(),
}
}