feat(compare): 增加 compare 汇总与提交筛选命令 #311

Merged
wbtiger merged 1 commits from Mengz/gitlink-cli:mengz/compare-summary-pr into master 2026-07-14 22:31:55 +08:00
6 changed files with 1092 additions and 9 deletions

View File

@ -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

View File

@ -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

View File

@ -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`

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}