feat(compare): 新增 compare 汇总与提交筛选命令
This commit is contained in:
parent
71ca2bb683
commit
6cebb6fbf9
17
README.md
17
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue