diff --git a/README.md b/README.md index e5e4318..b687d63 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🐛 Issue | Create, update, close, batch close/update/delete, comment on issues | | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | +| 🧭 Compare | Compare refs, inspect changed files, filter commits, and summarize diff hotspots | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | | 🌿 Branch | Create, delete, list, protect, unprotect branches | | 🏷️ Release | Create, edit, update, view, delete releases | @@ -396,6 +397,22 @@ gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00" gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 ``` +### Compare + +```bash +# Compare two refs +gitlink-cli compare +view --owner Gitlink --repo forgeplus --head feature/search --base master + +# List changed files between two refs +gitlink-cli compare +files --owner Gitlink --repo forgeplus --head feature/search --base master + +# List commits with author / keyword filters +gitlink-cli compare +commits --owner Gitlink --repo forgeplus --head feature/search --base master --author alice -k fix -l 10 + +# Summarize commits, top files, directories, and extensions +gitlink-cli compare +summary --owner Gitlink --repo forgeplus --head feature/search --base master --top-files 5 +``` + ### Pull Requests ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..ba225cf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -107,6 +107,7 @@ | 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue | | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | +| 🧭 Compare | 对比分支、标签或提交,查看变更文件,筛选提交并汇总差异热点 | | 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | | 🌿 分支 | 创建、删除、保护分支 | | 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release | @@ -406,6 +407,22 @@ gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00" gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 ``` +### Compare + +```bash +# 对比两个分支、标签或提交 +gitlink-cli compare +view --owner Gitlink --repo forgeplus --head feature/search --base master + +# 列出两个版本之间的变更文件 +gitlink-cli compare +files --owner Gitlink --repo forgeplus --head feature/search --base master + +# 按作者或关键字筛选提交 +gitlink-cli compare +commits --owner Gitlink --repo forgeplus --head feature/search --base master --author alice -k fix -l 10 + +# 汇总提交、热点文件、目录分布和扩展名分布 +gitlink-cli compare +summary --owner Gitlink --repo forgeplus --head feature/search --base master --top-files 5 +``` + ### Pull Request ```bash diff --git a/doc/changes/compare-summary-shortcuts.md b/doc/changes/compare-summary-shortcuts.md new file mode 100644 index 0000000..0656258 --- /dev/null +++ b/doc/changes/compare-summary-shortcuts.md @@ -0,0 +1,27 @@ +# Compare Summary Shortcuts + +## Summary + +Adds higher-level compare shortcuts so users and AI agents can inspect commit lists and summarize branch differences without manually stitching together raw compare responses. + +## Commands + +| Command | Purpose | +|---------|---------| +| `gitlink-cli compare +commits` | List commits between two refs with optional author, keyword, limit, and reverse filters. | +| `gitlink-cli compare +summary` | Summarize compare metadata, commit sample, changed file totals, file status counts, top files, path groups, and extension groups. | + +## Behavior + +- Reuse the existing compare endpoint so branch, tag, and commit refs keep the same URL-safe encoding behavior. +- Normalize commit output into stable fields such as `subject`, `author_login`, and `committer_login`. +- Aggregate compare file data into top changed files, directory groups, extension groups, and created / modified / deleted / renamed counts. +- Mark truncated summaries when `--max-files` analyzes only part of a large compare result. +- Validate `compare +files`, `compare +commits`, and `compare +summary` numeric flags before sending API requests. + +## Tests + +- `go test ./shortcuts/compare/...` +- `go build ./...` +- `go test ./...` +- `go run . compare +summary --owner Gitlink --repo gitlink-cli --head Mengz:mengz/compare-summary-shortcuts --base master --format json` diff --git a/shortcuts/compare/compare.go b/shortcuts/compare/compare.go index b39937a..d1d1180 100644 --- a/shortcuts/compare/compare.go +++ b/shortcuts/compare/compare.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "fmt" "net/url" + "strconv" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -21,11 +22,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - head, err := ctx.RequireArg("head") - if err != nil { - return err - } - base, err := ctx.RequireArg("base") + head, base, err := resolveCompareRefs(ctx) if err != nil { return err } @@ -50,17 +47,21 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - head, err := ctx.RequireArg("head") + head, base, err := resolveCompareRefs(ctx) if err != nil { return err } - base, err := ctx.RequireArg("base") + page, err := parsePositiveIntArg(ctx.Arg("page"), 1, "page") + if err != nil { + return err + } + limit, err := parsePositiveIntArg(ctx.Arg("limit"), 20, "limit") if err != nil { return err } q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) + q.Set("page", strconv.Itoa(page)) + q.Set("limit", strconv.Itoa(limit)) if file := ctx.Arg("file"); file != "" { q.Set("filepath", file) } @@ -71,6 +72,31 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "commits", + Description: "List commits between two refs with optional filters", + Flags: []common.Flag{ + {Name: "head", Usage: "Source branch, tag, or commit", Required: true}, + {Name: "base", Usage: "Target branch, tag, or commit", Required: true}, + {Name: "author", Usage: "Filter by commit author or committer"}, + {Name: "keyword", Short: "k", Usage: "Filter by commit message keyword"}, + {Name: "limit", Short: "l", Usage: "Maximum commits to return", Default: "20"}, + {Name: "reverse", Usage: "Return commits in reverse order", Bool: true, Default: "false"}, + }, + Run: runCompareCommits, + }, + { + Name: "summary", + Description: "Summarize commits and changed files between two refs", + Flags: []common.Flag{ + {Name: "head", Usage: "Source branch, tag, or commit", Required: true}, + {Name: "base", Usage: "Target branch, tag, or commit", Required: true}, + {Name: "max-files", Usage: "Maximum changed files to analyze", Default: "200"}, + {Name: "top-files", Usage: "Maximum top changed files to include", Default: "10"}, + {Name: "commit-limit", Usage: "Maximum commits to include in the summary sample", Default: "10"}, + }, + Run: runCompareSummary, + }, } } @@ -81,3 +107,15 @@ func comparePath(ctx *common.RuntimeContext, head, base string) string { func encodeRef(ref string) string { return base64.RawURLEncoding.EncodeToString([]byte(ref)) } + +func resolveCompareRefs(ctx *common.RuntimeContext) (string, string, error) { + head, err := ctx.RequireArg("head") + if err != nil { + return "", "", err + } + base, err := ctx.RequireArg("base") + if err != nil { + return "", "", err + } + return head, base, nil +} diff --git a/shortcuts/compare/summary.go b/shortcuts/compare/summary.go new file mode 100644 index 0000000..9ed92f8 --- /dev/null +++ b/shortcuts/compare/summary.go @@ -0,0 +1,657 @@ +package compare + +import ( + "fmt" + "net/url" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +const ( + defaultCompareCommitsLimit = 20 + defaultCompareSummaryCommitLimit = 10 + defaultCompareSummaryTopFiles = 10 + defaultCompareSummaryMaxFiles = 200 +) + +type compareCommit struct { + SHA string `json:"sha"` + Subject string `json:"subject"` + Message string `json:"message,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + TimeFromNow string `json:"time_from_now,omitempty"` + AuthorLogin string `json:"author_login,omitempty"` + AuthorName string `json:"author_name,omitempty"` + CommitterLogin string `json:"committer_login,omitempty"` + CommitterName string `json:"committer_name,omitempty"` +} + +type compareCommitsResult struct { + Repository string `json:"repository"` + Head string `json:"head"` + Base string `json:"base"` + CompareMessage string `json:"compare_message,omitempty"` + TotalCommits int `json:"total_commits"` + MatchedCommits int `json:"matched_commits"` + ReturnedCommits int `json:"returned_commits"` + Truncated bool `json:"truncated"` + AuthorFilter string `json:"author_filter,omitempty"` + Keyword string `json:"keyword,omitempty"` + Reversed bool `json:"reversed"` + Commits []compareCommit `json:"commits"` +} + +type compareFile struct { + Filename string + OldName string + Additions int + Deletions int + Changes int + IsCreated bool + IsDeleted bool + IsRenamed bool + IsBinary bool + IsSubmodule bool +} + +type compareFilesPage struct { + Files []compareFile + TotalFiles int + TotalAdditions int + TotalDeletions int +} + +type compareFilesFetchResult struct { + Files []compareFile + TotalFiles int + TotalAdditions int + TotalDeletions int + Truncated bool +} + +type compareChangeTotals struct { + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Changes int `json:"changes"` +} + +type compareFileTypeSummary struct { + Created int `json:"created"` + Modified int `json:"modified"` + Deleted int `json:"deleted"` + Renamed int `json:"renamed"` + Binary int `json:"binary"` + Submodule int `json:"submodule"` +} + +type compareBucket struct { + Name string `json:"name"` + Files int `json:"files"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Changes int `json:"changes"` +} + +type compareFileSummary struct { + Filename string `json:"filename"` + Status string `json:"status"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Changes int `json:"changes"` +} + +type compareSummaryResult struct { + Repository string `json:"repository"` + Head string `json:"head"` + Base string `json:"base"` + CompareMessage string `json:"compare_message,omitempty"` + CommitsCount int `json:"commits_count"` + FilesCount int `json:"files_count"` + FilesAnalyzed int `json:"files_analyzed"` + TruncatedFiles bool `json:"truncated_files"` + Authors []string `json:"authors"` + CommitsSample []compareCommit `json:"commits_sample"` + ChangeTotals compareChangeTotals `json:"change_totals"` + FileTypes compareFileTypeSummary `json:"file_types"` + PathGroups []compareBucket `json:"path_groups"` + Extensions []compareBucket `json:"extensions"` + TopFiles []compareFileSummary `json:"top_files"` +} + +func runCompareCommits(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, base, err := resolveCompareRefs(ctx) + if err != nil { + return err + } + limit, err := parsePositiveIntArg(ctx.Arg("limit"), defaultCompareCommitsLimit, "limit") + if err != nil { + return err + } + viewData, err := fetchCompareViewData(ctx, head, base) + if err != nil { + return err + } + result := buildCompareCommitsResult( + fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + head, + base, + viewData, + ctx.Arg("author"), + ctx.Arg("keyword"), + limit, + ctx.Arg("reverse") == "true", + ) + return ctx.OutputData(result) +} + +func runCompareSummary(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, base, err := resolveCompareRefs(ctx) + if err != nil { + return err + } + maxFiles, err := parsePositiveIntArg(ctx.Arg("max-files"), defaultCompareSummaryMaxFiles, "max-files") + if err != nil { + return err + } + topFiles, err := parsePositiveIntArg(ctx.Arg("top-files"), defaultCompareSummaryTopFiles, "top-files") + if err != nil { + return err + } + commitLimit, err := parsePositiveIntArg(ctx.Arg("commit-limit"), defaultCompareSummaryCommitLimit, "commit-limit") + if err != nil { + return err + } + viewData, err := fetchCompareViewData(ctx, head, base) + if err != nil { + return err + } + filesResult, err := fetchCompareFilesForSummary(ctx, head, base, maxFiles) + if err != nil { + return err + } + result := buildCompareSummary( + fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + head, + base, + viewData, + filesResult, + commitLimit, + topFiles, + ) + return ctx.OutputData(result) +} + +func parsePositiveIntArg(raw string, defaultValue int, flagName string) (int, error) { + if strings.TrimSpace(raw) == "" { + return defaultValue, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return 0, fmt.Errorf("invalid --%s value %q: use a positive integer", flagName, raw) + } + return value, nil +} + +func fetchCompareViewData(ctx *common.RuntimeContext, head, base string) (map[string]interface{}, error) { + env, err := ctx.CallAPI("GET", comparePath(ctx, head, base), nil) + if err != nil { + return nil, err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected compare response format") + } + return data, nil +} + +func fetchCompareFilesForSummary(ctx *common.RuntimeContext, head, base string, maxFiles int) (compareFilesFetchResult, error) { + result := compareFilesFetchResult{} + pageSize := 100 + if maxFiles < pageSize { + pageSize = maxFiles + } + for page := 1; len(result.Files) < maxFiles; page++ { + remaining := maxFiles - len(result.Files) + limit := pageSize + if remaining < limit { + limit = remaining + } + pageResult, err := fetchCompareFilesPage(ctx, head, base, page, limit) + if err != nil { + return compareFilesFetchResult{}, err + } + if page == 1 { + result.TotalFiles = pageResult.TotalFiles + result.TotalAdditions = pageResult.TotalAdditions + result.TotalDeletions = pageResult.TotalDeletions + } + result.Files = append(result.Files, pageResult.Files...) + if len(pageResult.Files) < limit || len(result.Files) >= result.TotalFiles { + break + } + } + if result.TotalFiles == 0 { + result.TotalFiles = len(result.Files) + } + result.Truncated = result.TotalFiles > len(result.Files) + return result, nil +} + +func fetchCompareFilesPage(ctx *common.RuntimeContext, head, base string, page, limit int) (compareFilesPage, error) { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("limit", strconv.Itoa(limit)) + env, err := ctx.CallAPIWithQuery("GET", "/v1"+comparePath(ctx, head, base)+"/files", q) + if err != nil { + return compareFilesPage{}, err + } + return parseCompareFilesPage(env) +} + +func parseCompareFilesPage(env *output.Envelope) (compareFilesPage, error) { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return compareFilesPage{}, fmt.Errorf("unexpected compare files response format") + } + rawFiles, ok := data["files"].([]interface{}) + if !ok { + return compareFilesPage{}, fmt.Errorf("compare files response missing files list") + } + files := make([]compareFile, 0, len(rawFiles)) + for _, raw := range rawFiles { + file, ok := normalizeCompareFile(raw) + if ok { + files = append(files, file) + } + } + totalFiles := intField(data, "file_nums", "files_count") + if totalFiles == 0 { + totalFiles = len(files) + } + return compareFilesPage{ + Files: files, + TotalFiles: totalFiles, + TotalAdditions: intField(data, "total_addition"), + TotalDeletions: intField(data, "total_deletion"), + }, nil +} + +func buildCompareCommitsResult(repository, head, base string, viewData map[string]interface{}, authorFilter, keyword string, limit int, reverse bool) compareCommitsResult { + commits := extractCompareCommits(viewData) + totalCommits := intField(viewData, "commits_count") + if totalCommits == 0 { + totalCommits = len(commits) + } + filtered := filterCompareCommits(commits, authorFilter, keyword) + if reverse { + reverseCompareCommits(filtered) + } + matchedCommits := len(filtered) + truncated := false + if len(filtered) > limit { + filtered = filtered[:limit] + truncated = true + } + return compareCommitsResult{ + Repository: repository, + Head: head, + Base: base, + CompareMessage: stringField(viewData, "message"), + TotalCommits: totalCommits, + MatchedCommits: matchedCommits, + ReturnedCommits: len(filtered), + Truncated: truncated, + AuthorFilter: strings.TrimSpace(authorFilter), + Keyword: strings.TrimSpace(keyword), + Reversed: reverse, + Commits: filtered, + } +} + +func buildCompareSummary(repository, head, base string, viewData map[string]interface{}, filesResult compareFilesFetchResult, commitLimit, topFiles int) compareSummaryResult { + commits := extractCompareCommits(viewData) + commitsCount := intField(viewData, "commits_count") + if commitsCount == 0 { + commitsCount = len(commits) + } + if len(commits) > commitLimit { + commits = commits[:commitLimit] + } + filesCount := filesResult.TotalFiles + if filesCount == 0 { + filesCount = intField(viewData, "files_count") + } + if filesCount == 0 { + filesCount = len(filesResult.Files) + } + totalAdditions, totalDeletions := compareTotalsFromFiles(filesResult) + return compareSummaryResult{ + Repository: repository, + Head: head, + Base: base, + CompareMessage: stringField(viewData, "message"), + CommitsCount: commitsCount, + FilesCount: filesCount, + FilesAnalyzed: len(filesResult.Files), + TruncatedFiles: filesResult.Truncated, + Authors: uniqueCompareAuthors(extractCompareCommits(viewData)), + CommitsSample: commits, + ChangeTotals: compareChangeTotals{ + Additions: totalAdditions, + Deletions: totalDeletions, + Changes: totalAdditions + totalDeletions, + }, + FileTypes: summarizeCompareFileTypes(filesResult.Files), + PathGroups: summarizeCompareBuckets(filesResult.Files, pathGroupForFile, 10), + Extensions: summarizeCompareBuckets(filesResult.Files, extensionForFile, 10), + TopFiles: topCompareFiles(filesResult.Files, topFiles), + } +} + +func extractCompareCommits(data map[string]interface{}) []compareCommit { + rawCommits, ok := data["commits"].([]interface{}) + if !ok { + return nil + } + commits := make([]compareCommit, 0, len(rawCommits)) + for _, raw := range rawCommits { + commit, ok := normalizeCompareCommit(raw) + if ok { + commits = append(commits, commit) + } + } + return commits +} + +func normalizeCompareCommit(raw interface{}) (compareCommit, bool) { + data, ok := raw.(map[string]interface{}) + if !ok { + return compareCommit{}, false + } + message := strings.TrimSpace(stringField(data, "message")) + author := nestedMap(data, "author") + committer := nestedMap(data, "committer") + return compareCommit{ + SHA: stringField(data, "sha"), + Subject: subjectFromMessage(message), + Message: message, + CreatedAt: stringField(data, "created_at"), + TimeFromNow: stringField(data, "time_from_now"), + AuthorLogin: stringField(author, "login"), + AuthorName: stringField(author, "name"), + CommitterLogin: stringField(committer, "login"), + CommitterName: stringField(committer, "name"), + }, true +} + +func normalizeCompareFile(raw interface{}) (compareFile, bool) { + data, ok := raw.(map[string]interface{}) + if !ok { + return compareFile{}, false + } + return compareFile{ + Filename: stringField(data, "filename"), + OldName: stringField(data, "old_name"), + Additions: intField(data, "additions"), + Deletions: intField(data, "deletions"), + Changes: intField(data, "changes"), + IsCreated: boolField(data, "is_created"), + IsDeleted: boolField(data, "is_deleted"), + IsRenamed: boolField(data, "is_renamed"), + IsBinary: boolField(data, "is_bin"), + IsSubmodule: boolField(data, "is_submodule"), + }, true +} + +func filterCompareCommits(commits []compareCommit, authorFilter, keyword string) []compareCommit { + authorFilter = strings.ToLower(strings.TrimSpace(authorFilter)) + keyword = strings.ToLower(strings.TrimSpace(keyword)) + if authorFilter == "" && keyword == "" { + return append([]compareCommit(nil), commits...) + } + filtered := make([]compareCommit, 0, len(commits)) + for _, commit := range commits { + if authorFilter != "" { + authorCorpus := strings.ToLower(strings.Join([]string{ + commit.AuthorLogin, + commit.AuthorName, + commit.CommitterLogin, + commit.CommitterName, + }, " ")) + if !strings.Contains(authorCorpus, authorFilter) { + continue + } + } + if keyword != "" && !strings.Contains(strings.ToLower(commit.Message), keyword) { + continue + } + filtered = append(filtered, commit) + } + return filtered +} + +func reverseCompareCommits(commits []compareCommit) { + for left, right := 0, len(commits)-1; left < right; left, right = left+1, right-1 { + commits[left], commits[right] = commits[right], commits[left] + } +} + +func uniqueCompareAuthors(commits []compareCommit) []string { + seen := map[string]bool{} + authors := make([]string, 0, len(commits)) + for _, commit := range commits { + author := firstNonEmpty(commit.AuthorLogin, commit.AuthorName, commit.CommitterLogin, commit.CommitterName) + if author == "" || seen[author] { + continue + } + seen[author] = true + authors = append(authors, author) + } + sort.Strings(authors) + return authors +} + +func summarizeCompareFileTypes(files []compareFile) compareFileTypeSummary { + summary := compareFileTypeSummary{} + for _, file := range files { + switch { + case file.IsCreated: + summary.Created++ + case file.IsDeleted: + summary.Deleted++ + case file.IsRenamed: + summary.Renamed++ + default: + summary.Modified++ + } + if file.IsBinary { + summary.Binary++ + } + if file.IsSubmodule { + summary.Submodule++ + } + } + return summary +} + +func summarizeCompareBuckets(files []compareFile, bucketFn func(string) string, limit int) []compareBucket { + type bucketAccum struct { + files int + additions int + deletions int + changes int + } + accums := map[string]*bucketAccum{} + for _, file := range files { + name := bucketFn(file.Filename) + if name == "" { + name = "(none)" + } + accum, ok := accums[name] + if !ok { + accum = &bucketAccum{} + accums[name] = accum + } + accum.files++ + accum.additions += file.Additions + accum.deletions += file.Deletions + accum.changes += file.Changes + } + buckets := make([]compareBucket, 0, len(accums)) + for name, accum := range accums { + buckets = append(buckets, compareBucket{ + Name: name, + Files: accum.files, + Additions: accum.additions, + Deletions: accum.deletions, + Changes: accum.changes, + }) + } + sort.Slice(buckets, func(i, j int) bool { + if buckets[i].Changes == buckets[j].Changes { + return buckets[i].Name < buckets[j].Name + } + return buckets[i].Changes > buckets[j].Changes + }) + if len(buckets) > limit { + buckets = buckets[:limit] + } + return buckets +} + +func topCompareFiles(files []compareFile, limit int) []compareFileSummary { + sortedFiles := append([]compareFile(nil), files...) + sort.Slice(sortedFiles, func(i, j int) bool { + if sortedFiles[i].Changes == sortedFiles[j].Changes { + return sortedFiles[i].Filename < sortedFiles[j].Filename + } + return sortedFiles[i].Changes > sortedFiles[j].Changes + }) + if len(sortedFiles) > limit { + sortedFiles = sortedFiles[:limit] + } + result := make([]compareFileSummary, 0, len(sortedFiles)) + for _, file := range sortedFiles { + result = append(result, compareFileSummary{ + Filename: file.Filename, + Status: compareFileStatus(file), + Additions: file.Additions, + Deletions: file.Deletions, + Changes: file.Changes, + }) + } + return result +} + +func compareFileStatus(file compareFile) string { + switch { + case file.IsCreated: + return "created" + case file.IsDeleted: + return "deleted" + case file.IsRenamed: + return "renamed" + default: + return "modified" + } +} + +func pathGroupForFile(filename string) string { + filename = strings.ReplaceAll(filename, "\\", "/") + if !strings.Contains(filename, "/") { + return "(root)" + } + parts := strings.Split(filename, "/") + if len(parts) == 0 || parts[0] == "" { + return "(root)" + } + return parts[0] +} + +func extensionForFile(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + if ext == "" { + return "(none)" + } + return ext +} + +func subjectFromMessage(message string) string { + if message == "" { + return "" + } + parts := strings.Split(strings.ReplaceAll(message, "\r\n", "\n"), "\n") + return strings.TrimSpace(parts[0]) +} + +func nestedMap(data map[string]interface{}, key string) map[string]interface{} { + value, _ := data[key].(map[string]interface{}) + return value +} + +func stringField(data map[string]interface{}, key string) string { + if data == nil { + return "" + } + value, _ := data[key].(string) + return strings.TrimSpace(value) +} + +func intField(data map[string]interface{}, keys ...string) int { + for _, key := range keys { + switch value := data[key].(type) { + case float64: + return int(value) + case int: + return value + case int64: + return int(value) + case string: + if parsed, err := strconv.Atoi(value); err == nil { + return parsed + } + } + } + return 0 +} + +func boolField(data map[string]interface{}, key string) bool { + if data == nil { + return false + } + value, _ := data[key].(bool) + return value +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func compareTotalsFromFiles(filesResult compareFilesFetchResult) (int, int) { + if filesResult.TotalAdditions != 0 || filesResult.TotalDeletions != 0 { + return filesResult.TotalAdditions, filesResult.TotalDeletions + } + var additions int + var deletions int + for _, file := range filesResult.Files { + additions += file.Additions + deletions += file.Deletions + } + return additions, deletions +} diff --git a/shortcuts/compare/summary_test.go b/shortcuts/compare/summary_test.go new file mode 100644 index 0000000..3c205b8 --- /dev/null +++ b/shortcuts/compare/summary_test.go @@ -0,0 +1,327 @@ +package compare + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestCompareFilesRejectsInvalidLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runCompareShortcut(t, server, "files", map[string]string{ + "head": "feature/api", + "base": "master", + "limit": "0", + }) + if err == nil { + t.Fatal("expected error for invalid --limit") + } +} + +func TestCompareCommitsFiltersAndLimitsResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "message": "can merge", + "commits_count": float64(3), + "commits": []interface{}{ + map[string]interface{}{ + "sha": "aaa111", + "message": "fix: normalize timestamps\n\nbody", + "created_at": "2026-06-10 10:00", + "author": map[string]interface{}{"login": "alice", "name": "Alice"}, + "committer": map[string]interface{}{"login": "alice", "name": "Alice"}, + }, + map[string]interface{}{ + "sha": "bbb222", + "message": "feat: add compare summary", + "created_at": "2026-06-10 11:00", + "author": map[string]interface{}{"login": "bob", "name": "Bob"}, + "committer": map[string]interface{}{"login": "bob", "name": "Bob"}, + }, + map[string]interface{}{ + "sha": "ccc333", + "message": "fix: docs cleanup", + "created_at": "2026-06-10 12:00", + "author": map[string]interface{}{"login": "alice", "name": "Alice"}, + "committer": map[string]interface{}{"login": "alice", "name": "Alice"}, + }, + }, + }) + })) + defer server.Close() + + data := runCompareShortcutCapture(t, server, "commits", map[string]string{ + "head": "feature/api", + "base": "master", + "author": "alice", + "keyword": "fix", + "limit": "1", + }) + + assertJSONField(t, data, "repository", "owner/repo") + assertJSONField(t, data, "head", "feature/api") + assertJSONField(t, data, "base", "master") + assertJSONField(t, data, "compare_message", "can merge") + assertJSONField(t, data, "total_commits", float64(3)) + assertJSONField(t, data, "matched_commits", float64(2)) + assertJSONField(t, data, "returned_commits", float64(1)) + assertJSONField(t, data, "truncated", true) + + commits := data["commits"].([]interface{}) + if len(commits) != 1 { + t.Fatalf("len(commits) = %d, want 1", len(commits)) + } + commit := commits[0].(map[string]interface{}) + assertJSONField(t, commit, "sha", "aaa111") + assertJSONField(t, commit, "subject", "fix: normalize timestamps") + assertJSONField(t, commit, "author_login", "alice") +} + +func TestCompareSummaryAggregatesResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy.json": + writeJSON(t, w, map[string]interface{}{ + "message": "can merge", + "commits_count": float64(2), + "files_count": float64(3), + "commits": []interface{}{ + map[string]interface{}{ + "sha": "aaa111", + "message": "feat: add compare summary", + "created_at": "2026-06-10 10:00", + "author": map[string]interface{}{"login": "alice", "name": "Alice"}, + "committer": map[string]interface{}{"login": "alice", "name": "Alice"}, + }, + map[string]interface{}{ + "sha": "bbb222", + "message": "test: add compare summary tests", + "created_at": "2026-06-10 11:00", + "author": map[string]interface{}{"login": "bob", "name": "Bob"}, + "committer": map[string]interface{}{"login": "bob", "name": "Bob"}, + }, + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy/files.json": + if got := r.URL.Query().Get("page"); got != "1" { + t.Fatalf("page query = %q, want 1", got) + } + if got := r.URL.Query().Get("limit"); got != "10" { + t.Fatalf("limit query = %q, want 10", got) + } + writeJSON(t, w, map[string]interface{}{ + "file_nums": float64(3), + "total_addition": float64(20), + "total_deletion": float64(5), + "message": "can merge", + "files": []interface{}{ + map[string]interface{}{ + "filename": "README.md", + "additions": float64(1), + "deletions": float64(0), + "changes": float64(1), + "is_created": false, + "is_deleted": false, + "is_renamed": false, + "is_bin": false, + }, + map[string]interface{}{ + "filename": "cmd/api/api.go", + "additions": float64(12), + "deletions": float64(5), + "changes": float64(17), + "is_created": false, + "is_deleted": false, + "is_renamed": false, + "is_bin": false, + }, + map[string]interface{}{ + "filename": "docs/guide.md", + "additions": float64(7), + "deletions": float64(0), + "changes": float64(7), + "is_created": true, + "is_deleted": false, + "is_renamed": false, + "is_bin": false, + }, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + data := runCompareShortcutCapture(t, server, "summary", map[string]string{ + "head": "feature/api", + "base": "master", + "max-files": "10", + "top-files": "2", + "commit-limit": "1", + }) + + assertJSONField(t, data, "repository", "owner/repo") + assertJSONField(t, data, "compare_message", "can merge") + assertJSONField(t, data, "commits_count", float64(2)) + assertJSONField(t, data, "files_count", float64(3)) + assertJSONField(t, data, "files_analyzed", float64(3)) + assertJSONField(t, data, "truncated_files", false) + + authors := data["authors"].([]interface{}) + if len(authors) != 2 || authors[0] != "alice" || authors[1] != "bob" { + t.Fatalf("authors = %v, want [alice bob]", authors) + } + + commitsSample := data["commits_sample"].([]interface{}) + if len(commitsSample) != 1 { + t.Fatalf("len(commits_sample) = %d, want 1", len(commitsSample)) + } + changeTotals := data["change_totals"].(map[string]interface{}) + assertJSONField(t, changeTotals, "additions", float64(20)) + assertJSONField(t, changeTotals, "deletions", float64(5)) + assertJSONField(t, changeTotals, "changes", float64(25)) + + fileTypes := data["file_types"].(map[string]interface{}) + assertJSONField(t, fileTypes, "created", float64(1)) + assertJSONField(t, fileTypes, "modified", float64(2)) + + pathGroups := data["path_groups"].([]interface{}) + if len(pathGroups) == 0 { + t.Fatal("expected path_groups to be populated") + } + firstPathGroup := pathGroups[0].(map[string]interface{}) + assertJSONField(t, firstPathGroup, "name", "cmd") + + extensions := data["extensions"].([]interface{}) + if len(extensions) == 0 { + t.Fatal("expected extensions to be populated") + } + firstExtension := extensions[0].(map[string]interface{}) + assertJSONField(t, firstExtension, "name", ".go") + + topFiles := data["top_files"].([]interface{}) + if len(topFiles) != 2 { + t.Fatalf("len(top_files) = %d, want 2", len(topFiles)) + } + firstTopFile := topFiles[0].(map[string]interface{}) + assertJSONField(t, firstTopFile, "filename", "cmd/api/api.go") + assertJSONField(t, firstTopFile, "status", "modified") +} + +func TestCompareSummaryMarksTruncatedWhenMaxFilesIsSmallerThanTotal(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy.json": + writeJSON(t, w, map[string]interface{}{ + "message": "can merge", + "commits_count": float64(1), + "files_count": float64(3), + "commits": []interface{}{ + map[string]interface{}{ + "sha": "aaa111", + "message": "fix: compare summary", + "author": map[string]interface{}{"login": "alice", "name": "Alice"}, + }, + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy/files.json": + if got := r.URL.Query().Get("limit"); got != "2" { + t.Fatalf("limit query = %q, want 2", got) + } + writeJSON(t, w, map[string]interface{}{ + "file_nums": float64(3), + "total_addition": float64(10), + "total_deletion": float64(1), + "files": []interface{}{ + map[string]interface{}{ + "filename": "README.md", + "additions": float64(1), + "deletions": float64(0), + "changes": float64(1), + "is_created": false, + "is_deleted": false, + "is_renamed": false, + "is_bin": false, + }, + map[string]interface{}{ + "filename": "shortcuts/compare/summary.go", + "additions": float64(9), + "deletions": float64(1), + "changes": float64(10), + "is_created": true, + "is_deleted": false, + "is_renamed": false, + "is_bin": false, + }, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + data := runCompareShortcutCapture(t, server, "summary", map[string]string{ + "head": "feature/api", + "base": "master", + "max-files": "2", + }) + + assertJSONField(t, data, "files_count", float64(3)) + assertJSONField(t, data, "files_analyzed", float64(2)) + assertJSONField(t, data, "truncated_files", true) +} + +func runCompareShortcutCapture(t *testing.T, server *httptest.Server, name string, args map[string]string) map[string]interface{} { + t.Helper() + + oldStdout := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe error: %v", err) + } + defer reader.Close() + defer writer.Close() + defer func() { + os.Stdout = oldStdout + }() + os.Stdout = writer + runErr := runCompareShortcut(t, server, name, args) + writer.Close() + + if runErr != nil { + t.Fatalf("shortcut %q returned error: %v", name, runErr) + } + + outputBytes, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + var envelope map[string]interface{} + if err := json.Unmarshal(outputBytes, &envelope); err != nil { + t.Fatalf("failed to unmarshal command output %q: %v", string(outputBytes), err) + } + data, ok := envelope["data"].(map[string]interface{}) + if !ok { + t.Fatalf("command output missing data object: %v", envelope) + } + return data +} + +func assertJSONField(t *testing.T, data map[string]interface{}, key string, want interface{}) { + t.Helper() + if got := data[key]; got != want { + t.Fatalf("%s = %v (%T), want %v (%T)", key, got, got, want, want) + } +}