Files
zzet--gortex/internal/mcp/tools_conflicts_test.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

331 lines
11 KiB
Go

package mcp
import (
"context"
"encoding/json"
"strconv"
"testing"
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/forge"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// --- communityConflicts: the pure grouping core ----------------------------
func TestCommunityConflicts_TwoPRsSharingOneCommunity(t *testing.T) {
groups := communityConflicts(
map[int][]string{
1: {"c1"},
2: {"c1"},
},
map[string]int{"c1": 4},
map[int]float64{1: 10, 2: 3},
)
require.Len(t, groups, 1, "two PRs sharing a community → exactly one conflict cluster")
g := groups[0]
require.Equal(t, "c1", g.Community)
require.Equal(t, 4, g.Size)
require.Equal(t, []int{1, 2}, g.PRs, "colliding PRs ascending")
// Suggested order: lowest-risk PR first. #2 (risk 3) before #1 (risk 10).
require.Equal(t, []int{2, 1}, g.SuggestedOrder)
require.Greater(t, g.Risk, 0.0)
}
func TestCommunityConflicts_DisjointPRsProduceNoConflict(t *testing.T) {
groups := communityConflicts(
map[int][]string{
1: {"c1"},
2: {"c2"},
3: {"c3"},
},
map[string]int{"c1": 4, "c2": 5, "c3": 6},
map[int]float64{1: 1, 2: 2, 3: 3},
)
require.Empty(t, groups, "no community is touched by >1 PR → no clusters")
}
func TestCommunityConflicts_ThreePRsOutrankTwoPRs(t *testing.T) {
groups := communityConflicts(
map[int][]string{
// c-busy touched by 3 PRs.
1: {"c-busy", "c-pair"},
2: {"c-busy"},
3: {"c-busy", "c-pair"},
},
// c-pair is far larger, to prove PR-count dominates size.
map[string]int{"c-busy": 2, "c-pair": 999},
map[int]float64{1: 5, 2: 5, 3: 5},
)
require.Len(t, groups, 2, "both shared communities reported")
// The 3-PR community ranks first even though it is much smaller.
require.Equal(t, "c-busy", groups[0].Community)
require.Equal(t, []int{1, 2, 3}, groups[0].PRs)
require.Equal(t, "c-pair", groups[1].Community)
require.Equal(t, []int{1, 3}, groups[1].PRs)
require.Greater(t, groups[0].Risk, groups[1].Risk, "more colliding PRs → higher risk")
}
func TestCommunityConflicts_EqualPRCountBreaksOnSize(t *testing.T) {
groups := communityConflicts(
map[int][]string{
1: {"small", "big"},
2: {"small", "big"},
},
map[string]int{"small": 3, "big": 50},
map[int]float64{1: 1, 2: 2},
)
require.Len(t, groups, 2)
// Same PR count (2 each) → larger community ranks first.
require.Equal(t, "big", groups[0].Community)
require.Equal(t, "small", groups[1].Community)
require.Greater(t, groups[0].Risk, groups[1].Risk)
}
func TestCommunityConflicts_SuggestedOrderTieBreaksOnNumber(t *testing.T) {
groups := communityConflicts(
map[int][]string{
5: {"c1"},
2: {"c1"},
9: {"c1"},
},
map[string]int{"c1": 1},
// Equal risk → suggested order is PR number ascending.
map[int]float64{2: 4, 5: 4, 9: 4},
)
require.Len(t, groups, 1)
require.Equal(t, []int{2, 5, 9}, groups[0].PRs)
require.Equal(t, []int{2, 5, 9}, groups[0].SuggestedOrder)
}
func TestCommunityConflicts_EmptyCommunityIDsIgnored(t *testing.T) {
groups := communityConflicts(
map[int][]string{
1: {"", "c1"},
2: {"", "c1"},
},
map[string]int{"c1": 2},
map[int]float64{1: 1, 2: 2},
)
require.Len(t, groups, 1, "blank community ids must not form a phantom cluster")
require.Equal(t, "c1", groups[0].Community)
}
// --- handler: graph join + forge seam --------------------------------------
// conflictsTestServer builds a server whose graph has two distinct hub
// files, each defining one symbol, both placed (via a synthetic
// CommunityResult) into the SAME community. Two PRs that each touch one of
// the files therefore collide in that community. Returns the server and
// the two file paths.
func conflictsTestServer(t *testing.T) (*Server, string, string) {
t.Helper()
g := graph.New()
fileA := "internal/a/svc.go"
fileB := "internal/b/svc.go"
idA := fileA + "::DoA"
idB := fileB + "::DoB"
g.AddNode(&graph.Node{ID: idA, Kind: graph.KindFunction, Name: "DoA", FilePath: fileA, StartLine: 1, EndLine: 5})
g.AddNode(&graph.Node{ID: idB, Kind: graph.KindFunction, Name: "DoB", FilePath: fileB, StartLine: 1, EndLine: 5})
srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil)
srv.communities = &analysis.CommunityResult{
Communities: []analysis.Community{
{ID: "comm-shared", Size: 7, Members: []string{idA, idB}},
},
NodeToComm: map[string]string{idA: "comm-shared", idB: "comm-shared"},
}
return srv, fileA, fileB
}
func TestConflictsPRs_SuppliedDataYieldsCluster(t *testing.T) {
srv, fileA, fileB := conflictsTestServer(t)
// Both seams fail the test if hit — supplied data must short-circuit.
withSeams(t,
func(context.Context, string, forge.ListOpts) ([]forge.PR, error) {
t.Fatal("list seam hit")
return nil, nil
},
func(context.Context, string, int) ([]string, error) { t.Fatal("files seam hit"); return nil, nil },
)
prs := []forge.PR{{Number: 1, Title: "a", Author: "x"}, {Number: 2, Title: "b", Author: "y"}}
prsJSON, _ := json.Marshal(prs)
filesJSON, _ := json.Marshal(map[string][]string{
"1": {fileA},
"2": {fileB},
})
res := callPRTool(t, srv, "conflicts_prs", srv.handleConflictsPRs, map[string]any{
"prs": string(prsJSON),
"files": string(filesJSON),
})
require.False(t, res.IsError, "errored: %v", res)
var out struct {
Total int `json:"total"`
Conflicts []struct {
Community string `json:"community"`
Size int `json:"size"`
PRs []int `json:"prs"`
SuggestedOrder []int `json:"suggested_order"`
Risk float64 `json:"risk"`
} `json:"conflicts"`
}
unmarshalPRResult(t, res, &out)
require.Equal(t, 1, out.Total, "the two PRs collide in exactly one community")
require.Len(t, out.Conflicts, 1)
c := out.Conflicts[0]
require.Equal(t, "comm-shared", c.Community)
require.Equal(t, 7, c.Size)
require.Equal(t, []int{1, 2}, c.PRs)
require.Len(t, c.SuggestedOrder, 2)
require.Greater(t, c.Risk, 0.0)
}
func TestConflictsPRs_DisjointPRsNoCluster(t *testing.T) {
srv, fileA, _ := conflictsTestServer(t)
// Only PR #1 touches a known file; PR #2 touches an unindexed file with
// no symbols → no community → no overlap.
withSeams(t,
func(context.Context, string, forge.ListOpts) ([]forge.PR, error) {
t.Fatal("list seam hit")
return nil, nil
},
func(context.Context, string, int) ([]string, error) { t.Fatal("files seam hit"); return nil, nil },
)
prsJSON, _ := json.Marshal([]forge.PR{{Number: 1}, {Number: 2}})
filesJSON, _ := json.Marshal(map[string][]string{
"1": {fileA},
"2": {"unindexed/none.go"},
})
res := callPRTool(t, srv, "conflicts_prs", srv.handleConflictsPRs, map[string]any{
"prs": string(prsJSON),
"files": string(filesJSON),
})
require.False(t, res.IsError)
var out struct {
Total int `json:"total"`
Conflicts []map[string]any `json:"conflicts"`
}
unmarshalPRResult(t, res, &out)
require.Equal(t, 0, out.Total, "no shared community → no clusters")
require.Empty(t, out.Conflicts)
}
func TestConflictsPRs_ForgeUnavailable(t *testing.T) {
t.Setenv("GH_TOKEN", "")
t.Setenv("GITHUB_TOKEN", "")
t.Setenv("GH_ENTERPRISE_TOKEN", "")
srv, _, _ := conflictsTestServer(t)
seamHit := false
withSeams(t,
func(context.Context, string, forge.ListOpts) ([]forge.PR, error) { seamHit = true; return nil, nil },
func(context.Context, string, int) ([]string, error) { seamHit = true; return nil, nil },
)
res := callPRTool(t, srv, "conflicts_prs", srv.handleConflictsPRs, map[string]any{})
require.False(t, res.IsError, "must degrade, not error")
var out struct {
Error string `json:"error"`
Hint string `json:"hint"`
}
unmarshalPRResult(t, res, &out)
require.Equal(t, "forge unavailable", out.Error)
require.Contains(t, out.Hint, "GH_TOKEN")
require.False(t, seamHit, "an unavailable forge must short-circuit before the seam")
}
// conflictsBudgetServer builds a graph with several shared communities so
// the conflicts output has multiple rows — enough of a tail for the GCX
// budget trimmer to bite. Three communities, each touched by two PRs.
func conflictsBudgetServer(t *testing.T) (*Server, map[string][]string, []forge.PR) {
t.Helper()
g := graph.New()
nodeToComm := map[string]string{}
var members3 [3][]string
files := map[string][]string{} // PR number → files
prs := []forge.PR{}
for c := 0; c < 3; c++ {
comm := "comm" + string(rune('A'+c))
// Two PRs per community, each touching its own file in that community.
for p := 0; p < 2; p++ {
prNum := c*2 + p + 1
file := "internal/x/" + comm + "_" + string(rune('a'+p)) + ".go"
id := file + "::F"
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: "F", FilePath: file, StartLine: 1, EndLine: 3})
nodeToComm[id] = comm
members3[c] = append(members3[c], id)
files[strconv.Itoa(prNum)] = []string{file}
prs = append(prs, forge.PR{Number: prNum, Title: "p" + strconv.Itoa(prNum)})
}
}
srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil)
comms := make([]analysis.Community, 3)
for c := 0; c < 3; c++ {
comms[c] = analysis.Community{ID: "comm" + string(rune('A'+c)), Size: 5 + c, Members: members3[c]}
}
srv.communities = &analysis.CommunityResult{Communities: comms, NodeToComm: nodeToComm}
return srv, files, prs
}
func TestConflictsPRs_GCXTOONBudget(t *testing.T) {
srv, files, prs := conflictsBudgetServer(t)
prsJSON, _ := json.Marshal(prs)
filesJSON, _ := json.Marshal(files)
withSeams(t,
func(context.Context, string, forge.ListOpts) ([]forge.PR, error) {
t.Fatal("list seam hit")
return nil, nil
},
func(context.Context, string, int) ([]string, error) { t.Fatal("files seam hit"); return nil, nil },
)
// GCX round-trip carries both the summary and conflicts sections.
g := callPRTool(t, srv, "conflicts_prs", srv.handleConflictsPRs, map[string]any{
"prs": string(prsJSON), "files": string(filesJSON), "format": "gcx",
})
require.False(t, g.IsError)
gtext := g.Content[0].(mcplib.TextContent).Text
require.Contains(t, gtext, "conflicts_prs.summary")
require.Contains(t, gtext, "conflicts_prs.conflicts")
// TOON round-trip keeps a known key.
tn := callPRTool(t, srv, "conflicts_prs", srv.handleConflictsPRs, map[string]any{
"prs": string(prsJSON), "files": string(filesJSON), "format": "toon",
})
require.False(t, tn.IsError)
require.Contains(t, tn.Content[0].(mcplib.TextContent).Text, "total")
// max_bytes budget trims the GCX response below the unbudgeted size:
// the three-cluster tail gives the row trimmer something to drop.
full := callPRTool(t, srv, "conflicts_prs", srv.handleConflictsPRs, map[string]any{
"prs": string(prsJSON), "files": string(filesJSON), "format": "gcx",
})
require.False(t, full.IsError)
b := callPRTool(t, srv, "conflicts_prs", srv.handleConflictsPRs, map[string]any{
"prs": string(prsJSON), "files": string(filesJSON), "format": "gcx", "max_bytes": float64(150),
})
require.False(t, b.IsError)
require.Less(t, len(b.Content[0].(mcplib.TextContent).Text), len(full.Content[0].(mcplib.TextContent).Text),
"max_bytes must trim the response below the unbudgeted size")
}
func TestConflictsPRs_DiscoverableViaToolsSearch(t *testing.T) {
t.Setenv("GORTEX_LAZY_TOOLS", "1")
srv, _ := setupTestServer(t)
require.NotNil(t, srv.lazy)
hits := srv.lazy.Query("select:conflicts_prs", 1)
require.Len(t, hits, 1, "conflicts_prs must be discoverable by exact name")
require.Equal(t, "conflicts_prs", hits[0].tool.Name)
require.False(t, hotEagerTools["conflicts_prs"], "conflicts_prs must be a deferred tool")
}