项目文件夹

文件
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

213 行
6.2 KiB
Go

// Analyzer that mines git history rather than the graph: fixes_history
// surfaces the files most often touched by bug-fix commits — the
// fix-prone hotspots a reviewer should treat with extra care.
package mcp
import (
"context"
"fmt"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// fixSubjectRe matches a commit subject that describes a bug fix:
// conventional-commit `fix:` / `fix(scope):`, a `fix` / `fixes` /
// `fixed` word, or `bugfix` / `hotfix`. Word boundaries keep "prefix"
// and "fixture" from matching.
var fixSubjectRe = regexp.MustCompile(`(?i)(^fix(\([^)]*\))?!?:|\bfix(es|ed)?\b|\bbug ?fix\b|\bhotfix\b)`)
// fixCommit is one bug-fix commit mined from git log.
type fixCommit struct {
subject string
files []string
}
// mineFixCommits runs `git log` in root and returns the commits whose
// subject describes a bug fix, capped at maxScan commits scanned.
// Returns nil on any git error (not a git repo, git not installed).
func mineFixCommits(ctx context.Context, root string, maxScan int) []fixCommit {
if maxScan <= 0 {
maxScan = 2000
}
// %x00 starts each commit record with the subject on its own
// line; --name-only appends the changed files below it.
cmd := exec.CommandContext(ctx, "git", "-C", root, "log", "--no-merges", //nolint:gosec // root is daemon-internal
"-n", strconv.Itoa(maxScan), "--name-only", "--format=%x00%s")
out, err := cmd.Output()
if err != nil {
return nil
}
var commits []fixCommit
for _, chunk := range strings.Split(string(out), "\x00") {
chunk = strings.Trim(chunk, "\n")
if chunk == "" {
continue
}
lines := strings.Split(chunk, "\n")
if !fixSubjectRe.MatchString(lines[0]) {
continue
}
fc := fixCommit{subject: lines[0]}
for _, l := range lines[1:] {
if l = strings.TrimSpace(l); l != "" {
fc.files = append(fc.files, l)
}
}
commits = append(commits, fc)
}
return commits
}
// handleAnalyzeFixesHistory ranks source files by how often a bug-fix
// commit touched them, with each file's symbols and a sample of recent
// fix-commit subjects — the fix-prone hotspots worth extra review.
//
// Filters: scope (repo prefix), limit (rows), max_commits (history
// depth scanned, default 2000).
func (s *Server) handleAnalyzeFixesHistory(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
scope := stringArg(args, "scope")
limit := 20
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
maxCommits := 2000
if v, ok := args["max_commits"].(float64); ok && v > 0 {
maxCommits = int(v)
}
roots := s.collectRepoRoots(scope)
fixCounts := map[string]int{}
recent := map[string][]string{}
totalFixCommits := 0
for prefix, root := range roots {
for _, c := range mineFixCommits(ctx, root, maxCommits) {
totalFixCommits++
for _, f := range c.files {
key := f
if prefix != "" {
key = prefix + "/" + f
}
fixCounts[key]++
if len(recent[key]) < 5 {
recent[key] = append(recent[key], c.subject)
}
}
}
}
type fixRow struct {
File string `json:"file"`
FixCommits int `json:"fix_commits"`
Symbols []string `json:"symbols,omitempty"`
Recent []string `json:"recent,omitempty"`
}
rows := make([]*fixRow, 0, len(fixCounts))
for file, n := range fixCounts {
rows = append(rows, &fixRow{
File: file,
FixCommits: n,
Symbols: s.symbolNamesInFile(file),
Recent: recent[file],
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].FixCommits != rows[j].FixCommits {
return rows[i].FixCommits > rows[j].FixCommits
}
return rows[i].File < rows[j].File
})
truncated := false
if len(rows) > limit {
rows = rows[:limit]
truncated = true
}
if s.isGCX(ctx, req) {
return s.gcxResponseWithBudget(req)(encodeAnalyze("fixes_history", rows))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%-3d %s\n", r.FixCommits, r.File)
}
if truncated {
fmt.Fprintf(&b, "... truncated to %d\n", limit)
}
if len(rows) == 0 {
b.WriteString("no bug-fix history found\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"files": rows,
"total": len(rows),
"total_fix_commits": totalFixCommits,
"truncated": truncated,
})
}
// symbolNamesInFile returns the sorted, de-duplicated names of the
// function / method / type symbols defined in filePath.
func (s *Server) symbolNamesInFile(filePath string) []string {
var names []string
seen := map[string]bool{}
for _, n := range s.graph.GetFileNodes(filePath) {
switch n.Kind {
case graph.KindFunction, graph.KindMethod, graph.KindType, graph.KindInterface:
if n.Name != "" && !seen[n.Name] {
seen[n.Name] = true
names = append(names, n.Name)
}
}
}
sort.Strings(names)
return names
}
// symbolNamesByFiles is the batched sibling of symbolNamesInFile.
// Returns a map filePath → sorted distinct names for every input
// path in one backend round-trip when the store implements
// FileSymbolNamesByPaths; falls back to the per-file loop otherwise.
// Used by find_co_changing_symbols and analyze fixes_history where
// the row count after truncation is bounded but each per-row name
// lookup was a separate query before — multiple thousand
// query-engine entry points per call on a disk backend.
func (s *Server) symbolNamesByFiles(paths []string) map[string][]string {
if len(paths) == 0 {
return nil
}
kinds := []graph.NodeKind{graph.KindFunction, graph.KindMethod, graph.KindType, graph.KindInterface}
out := make(map[string][]string, len(paths))
if scanner, ok := s.graph.(graph.FileSymbolNamesByPaths); ok {
rows := scanner.FileSymbolNamesByPaths(paths, kinds)
seenPerFile := make(map[string]map[string]bool, len(paths))
for _, r := range rows {
seen := seenPerFile[r.FilePath]
if seen == nil {
seen = make(map[string]bool)
seenPerFile[r.FilePath] = seen
}
if r.Name == "" || seen[r.Name] {
continue
}
seen[r.Name] = true
out[r.FilePath] = append(out[r.FilePath], r.Name)
}
for f := range out {
sort.Strings(out[f])
}
return out
}
for _, p := range paths {
out[p] = s.symbolNamesInFile(p)
}
return out
}